Merge origin/main into #11118

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-07 10:56:15 +01:00
commit b06f5f36b9
99 files changed
+5916 -563

No files matched your search

+4
View File
@@ -223,6 +223,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
## Flywheel native model directional shading
The two-light diffuse expression and transformed-normal policy in
@@ -67,6 +67,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
true,
false,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -225,6 +225,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -127,6 +127,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
assert!(
+7 -7
View File
@@ -114,12 +114,8 @@ mod tests {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
faces: vec![BakedFace::new(
ModelQuad {
positions: [
[0.0, 0.5, 0.0],
[0.0, 0.5, 1.0],
@@ -142,7 +138,11 @@ mod tests {
render_type: None,
shade: true,
},
}],
0,
true,
false,
None,
)],
}
}
#[test]
+18
View File
@@ -1434,6 +1434,24 @@ impl ChatScreen {
self.edited(m, c);
}
}
/// Edit the existing input and refresh its command responder when it changes.
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
let before = (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
);
f(&mut self.input);
if before
!= (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
)
{
self.edited(m, c);
}
}
/// Typed text (`charTyped` per character).
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
for ch in text.chars() {
@@ -11,7 +11,7 @@ use horizon_world::{
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Mutex, OnceLock, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
@@ -108,6 +108,9 @@ pub enum BlockEntity {
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
// A client update can consume transient effects without changing saved
// fields. Most entities never use this; do not allocate their queue.
client_update_fields: OnceLock<Mutex<Compound>>,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
@@ -138,11 +141,55 @@ impl ModBlockEntity {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
client_update_fields: OnceLock::new(),
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// Replace the owner's pending, one-shot client update fields. These
/// fields are not saved, cloned, compared or retained for drawing, and
/// changing them does not change this entity's revision. The owner must
/// explicitly take and merge them into its update tag. Calling this does
/// not schedule a block update.
///
/// An empty batch clears a pending batch without allocating an idle queue.
pub fn set_client_update_fields(&mut self, fields: Compound) {
if fields.is_empty() {
self.clear_client_update_fields();
return;
}
*self
.client_update_fields
.get_or_init(|| Mutex::new(Compound::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner) = fields;
}
/// Discard the owner's pending client update fields, without changing
/// saved data, revision or a queue that has never been used.
pub fn clear_client_update_fields(&mut self) {
if let Some(fields) = self.client_update_fields.get() {
fields
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
/// Atomically take the owner's pending fields at update-tag construction.
/// A loaded chunk and a block change use this same boundary. Construct a
/// tag once, then send that tag to every recipient; another construction
/// sees an empty batch. A save or a clone does not consume this batch.
///
/// This is safe for an update-tag hook receiving only a shared reference,
/// including a saved chunk encoded by a worker thread. If a previous lock
/// holder panicked, preserve its pending data instead of dropping it.
pub fn take_client_update_fields(&self) -> Compound {
self.client_update_fields
.get()
.map_or_else(Compound::new, |fields| {
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
})
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
@@ -706,6 +753,85 @@ pub struct CommandBlock {
mod tests {
use super::*;
#[test]
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let saved = machine.fields.clone();
let debug = format!("{machine:?}");
machine.clear_client_update_fields();
machine.set_client_update_fields(Compound::new());
assert!(machine.take_client_update_fields().is_empty());
assert!(machine.client_update_fields.get().is_none());
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
let copy = machine.clone();
assert_eq!(machine, copy, "transient fields are not retained contents");
assert_ne!(machine.revision(), copy.revision());
assert!(copy.client_update_fields.get().is_none());
assert!(copy.take_client_update_fields().is_empty());
assert_eq!(machine.take_client_update_fields(), expected);
machine.set_client_update_fields(expected);
machine.clear_client_update_fields();
assert!(machine.take_client_update_fields().is_empty());
assert_eq!(machine.fields, saved);
assert_eq!(machine.revision(), revision);
assert_eq!(format!("{machine:?}"), debug);
}
#[test]
fn concurrent_client_update_constructions_consume_one_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
let received = std::thread::scope(|scope| {
let handles = (0..16)
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
let nonempty = received
.into_iter()
.filter(|c| !c.is_empty())
.collect::<Vec<_>>();
assert_eq!(nonempty, [expected]);
}
#[test]
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
std::thread::scope(|scope| {
assert!(
scope
.spawn(|| {
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
panic!("interrupt before consuming the queued batch");
})
.join()
.is_err()
);
});
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
}
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
+78 -28
View File
@@ -187,9 +187,57 @@ pub enum OmenTick {
/// It added this effect, then returned false.
Add(EffectInstance),
}
/// A saved player's effect: id, duration, amplifier, ambient, particles
/// visible, icon shown (`MobEffectInstance.Details`).
pub type SavedEffect = (String, i32, i32, bool, bool, bool);
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
#[derive(Clone, Debug, PartialEq)]
pub struct SavedEffect {
pub id: String,
pub duration: i32,
pub amplifier: i32,
pub ambient: bool,
/// `show_particles`.
pub particles: bool,
/// `show_icon`.
pub icon: bool,
/// `hidden_effect`: the weaker, longer instance of the same effect that
/// comes back when this one ends.
pub hidden: Option<Box<SavedEffect>>,
}
impl SavedEffect {
/// Ambient off, particles and icon shown, nothing hidden: the codec's
/// defaults.
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
Self {
id: id.into(),
duration,
amplifier,
ambient: false,
particles: true,
icon: true,
hidden: None,
}
}
fn of(e: &EffectInstance) -> Self {
Self {
id: potions::namespaced(e.id).to_owned(),
duration: e.duration,
amplifier: e.amplifier,
ambient: e.ambient,
particles: e.visible,
icon: e.show_icon,
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
}
}
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
/// with it.
fn instance(&self, id: &'static str) -> EffectInstance {
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
e.ambient = self.ambient;
e.visible = self.particles;
e.show_icon = self.icon;
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
e
}
}
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -222,36 +270,20 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible, icon shown).
/// The effects of a saved player (`Player.active_effects`), each with
/// the instances it hides.
pub fn from_saved(saved: &[SavedEffect]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles, icon) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
e.show_icon = *icon;
effects.add(e);
for s in saved {
if let Some(def) = potions::effect(&s.id) {
effects.add(s.instance(def.id));
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<SavedEffect> {
self.list
.iter()
.map(|e| {
(
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
e.visible,
e.show_icon,
)
})
.collect()
self.list.iter().map(SavedEffect::of).collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown.
@@ -579,7 +611,10 @@ mod tests {
let saved = fx.saved();
assert_eq!(
saved,
[("minecraft:speed".to_owned(), 100, 0, false, true, false)]
[SavedEffect {
icon: false,
..SavedEffect::new("minecraft:speed", 100, 0)
}]
);
let loaded = PlayerEffects::from_saved(&saved);
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
@@ -620,6 +655,21 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
#[test]
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
// /effect give poison 6 s level II over poison 30 s level I, saved
// and loaded: when level II runs out, level I resumes with what is
// left of its 30 s.
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("poison", 600, 0));
fx.add(EffectInstance::new("poison", 120, 1));
let mut loaded = PlayerEffects::from_saved(&fx.saved());
for _ in 0..120 {
loaded.tick();
}
let poison = loaded.get("poison").expect("level I resumed");
assert_eq!((poison.amplifier, poison.duration), (0, 480));
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
@@ -635,11 +685,11 @@ mod tests {
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true, true)]);
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true, true)])
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
.list
.is_empty()
);
+36 -2
View File
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
/// the sky reaches the eye, halved in a biome without precipitation.
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
rain * sky * if biome_rains { 1.0 } else { 0.5 }
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
biome_rains: bool,
delta_ticks: f32,
) {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
let target = rain_fog_target(rain, sky_light, biome_rains);
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
}
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
/// at its level and easing would wait for ticks that do not come.
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
}
fn rgb(c: u32) -> [f32; 3] {
[
(c >> 16 & 255) as f32 / 255.0,
@@ -726,6 +736,30 @@ mod tests {
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
/// of the way, as before.
#[test]
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
assert_eq!(
rain_fog_target(1.0, 15, false),
0.5,
"a dry biome halves it"
);
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
let start = FogState {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
assert_eq!(
eased.rain_fog_multiplier.to_bits(),
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
+73 -21
View File
@@ -2035,24 +2035,26 @@ fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
)
}
/// The frame of a renderer made with a shader API 0.2 pack picked under
/// `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md): float, so values over 1
/// survive from the world to the tonemap.
/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md)
/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1
/// survive from the world to the tonemap and the HDR pass.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` for a 0.2 pack under the switch
/// (`linear`: `(picked, the device draws it)`); else `WIDE_FRAME` when it is made with HDR on
/// and Smooth Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool),
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.0 && linear.1 {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
@@ -2529,6 +2531,7 @@ impl Renderer {
wide_frame: bool,
shader_pack: bool,
linear_pack: bool,
hdr_float: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2664,9 +2667,10 @@ impl Renderer {
crate::graphics::offer_smooth_gradients(wide_ok);
let linear = (
linear_pack,
hdr_float,
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
);
if linear == (true, false) {
if linear.0 && !linear.2 {
log::warn!(
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
@@ -3943,12 +3947,8 @@ impl Renderer {
alpha_blend,
false,
);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
config.format.remove_srgb_suffix(),
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -6688,6 +6688,22 @@ impl Renderer {
}
Ok(report)
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
if let Some(native) = &mut self.native {
native.frame.reset_history();
if let Some(post) = &mut native.post {
post.reset_history();
}
}
}
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
@@ -6859,7 +6875,7 @@ impl Renderer {
&& !std::mem::replace(&mut self.frame_kept_logged, true)
{
log::info!(
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader API 0.2 pack picked or dropped since runs on it",
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it",
self.frame_format
);
}
@@ -12596,8 +12612,14 @@ mod tests {
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
// No 0.2 pack (or a device without the float frame) leaves it as before.
for linear in [(false, false), (false, true), (true, false)] {
// No float frame asked for (or a device without it) leaves it as before.
for linear in [
(false, false, false),
(false, false, true),
(true, false, false),
(false, true, false),
(true, true, false),
] {
assert_eq!(
frame_format(true, true, true, Bgra8Unorm, linear),
Rgba16Unorm
@@ -12627,11 +12649,41 @@ mod tests {
use wgpu::TextureFormat::Bgra8UnormSrgb;
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, (true, true)),
frame_format(
wide_frame,
hdr_offered,
draws,
Bgra8UnormSrgb,
(true, false, true)
),
LINEAR_FRAME
);
}
}
/// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the
/// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as
/// without the switch.
#[test]
fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for (wide_frame, draws) in [(true, true), (false, false)] {
assert_eq!(
frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)),
LINEAR_FRAME
);
assert_eq!(
frame_format(
wide_frame,
false,
draws,
Bgra8UnormSrgb,
(false, true, true)
),
Bgra8Unorm
);
}
}
/// What `draws_wide_frame` needs of a device: both features, and the usages and flags
/// every pass takes of the frame. Missing any one of them keeps the 8-bit frame.
#[test]
@@ -849,6 +849,15 @@ impl NativeFrame {
inputs
}
/// Forgets the frames before, as a freshly loaded pack: the post stage's next `history` is
/// black, eased values start at their targets, wetness dry, the frame count at 0.
pub(super) fn reset_history(&mut self) {
self.last = None;
self.frames = 0;
self.wetness = (0.0, None);
self.start = std::time::Instant::now();
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
/// skipped for a frame, so the first frame it does render still sees black history.
pub(super) fn take_history_reset(&mut self) -> bool {
@@ -1119,6 +1128,13 @@ impl NativePost {
.collect()
}
/// Forgets the pack's own targets (`[[post]] output`): made again, blank, on the next frame,
/// as for a pack just made. Its pipelines stay.
pub(super) fn reset_history(&mut self) {
self.named = None;
self.groups = None;
}
fn prepare(
&mut self,
device: &wgpu::Device,
@@ -1737,6 +1753,152 @@ mod tests {
assert!(!native.take_history_reset());
}
/// After a reset, the same frames give the same inputs, to the bit, as a pack just made,
/// however the frames before moved the eased values, wetness and the frame count.
#[test]
fn a_reset_frame_gives_a_fresh_pack_s_inputs() {
use crate::gfx::pack_uniforms::FrameTiming;
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let new = || {
NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
)
};
let (mut used, mut fresh) = (new(), new());
frame.pack.rain = 1.0;
frame.pack.eye_light = [12, 3];
frame.pack.eye_in = 1;
for n in 1..=5 {
frame.pack.frame_timing = Some(FrameTiming::nth(n, 700.0));
used.next_inputs(&frame, [4, 4]);
}
used.reset_history();
frame.pack.eye_light = [2, 15];
frame.pack.eye_in = 0;
for n in 1..=3 {
frame.pack.frame_timing = Some(FrameTiming::nth(n, 50.0));
let inputs = [&mut used, &mut fresh].map(|f| f.next_inputs(&frame, [4, 4]));
assert_eq!(
bytemuck::bytes_of(&inputs[0]),
bytemuck::bytes_of(&inputs[1]),
"frame {n}"
);
assert_eq!(used.take_history_reset(), fresh.take_history_reset());
}
}
/// After a reset a pass reads its pack's own targets blank, as in a pack just made: here
/// `fb`, which a pass reads before the pass that writes it, so it holds last frame's.
#[test]
fn a_reset_post_reads_its_own_targets_blank() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1
id = \"t\"
name = \"T\"
version = \"1\"
api = \"0.1\"
[[post]]
file = \"a.wgsl\"
[[post]]
file = \"b.wgsl\"
output = \"fb\"
[[post]]
file = \"c.wgsl\"
";
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(textureLoad(fb, vec2<i32>(input.pixel), 0).rgb + 0.25, 1.0); }
";
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }
";
let files = [
(MANIFEST, manifest),
("a.wgsl", a),
("b.wgsl", pass),
("c.wgsl", pass),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let red = |post: &mut NativePost| {
let mut encoder = device.create_command_encoder(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], false, None);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let red = buffer.slice(..).get_mapped_range().unwrap()[0];
buffer.unmap();
red
};
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
for want in [64, 128, 191] {
let got = red(&mut post);
assert!(near(got, want), "{got} before the reset, {want} expected");
}
post.reset_history();
let got = red(&mut post);
assert!(near(got, 64), "{got} after the reset: fb kept last frame's");
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
}
impl PackImage {
pub(super) fn reset_history(&mut self) {
self.view = None;
self.copy = None;
self.before = None;
self.made_for = [0; 2];
}
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
@@ -316,6 +323,11 @@ impl StandIn {
}
}
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
*images = stand_ins(device, wanted);
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn reset_history(&mut self) {
self.group = None;
self.buffers.clear();
self.made_for = [0; 2];
}
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod history_reset_tests;
#[cfg(test)]
mod world_target_tests;
@@ -399,6 +402,8 @@ struct Pass {
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
/// Shader-declared defaults must survive a reset of the mutable upload block.
initial_block: Vec<u8>,
/// (index into `pack_uniforms::NAMES`, declared type, offset).
uniforms: Vec<(Option<usize>, String, u32)>,
samplers: Vec<Binding>,
@@ -452,6 +457,16 @@ struct Pass {
}
impl Pass {
/// Back to the uniform values the pass was made with, and no bind groups.
fn reset_history(&mut self) {
self.block.clone_from(&self.initial_block);
self.groups.clear();
self.portal_groups.clear();
for compute in &mut self.compute {
compute.reset_history();
}
}
fn render(&self) -> &wgpu::RenderPipeline {
match &self.pipeline {
Pipeline::Render(p) => p,
@@ -1973,6 +1988,7 @@ fn build_pass(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -2108,6 +2124,7 @@ fn build_compute(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -3124,23 +3141,7 @@ impl PackPost {
.create_view(&Default::default()),
textures,
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default()),
super::pack_shadow::stand_in(device),
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow"),
compare: Some(wgpu::CompareFunction::LessEqual),
@@ -3189,6 +3190,65 @@ impl PackPost {
})
}
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
/// and the centre depth too. Compiled programs and the pack's textures stay.
pub fn reset_history(&mut self, device: &wgpu::Device) {
self.uniforms.reset_history();
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
self.custom.restart_at(0);
self.custom.forget_world();
self.center_depth = None;
self.targets = None;
self.main = 0;
self.mips_valid = 0;
self.rgb_unset = true;
self.final_target = None;
self.blit_group = None;
self.page_groups.clear();
for image in &mut self.images {
image.reset_history();
}
self.buffers.reset_history();
pack_images::reset_stand_ins(device, &mut self.stand_ins);
if let Some(shadow) = &mut self.shadow_targets {
shadow.reset_history(device, &self.uniforms.shadow);
}
self.shadow.0 = super::pack_shadow::stand_in(device);
self.shadow_cleared = false;
self.shadow_cache = Default::default();
self.setup_pending = true;
self.portal_texture = None;
self.portal_rect = [0.0; 4];
let sky = self
.sky
.iter_mut()
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
let entities = self
.entities
.iter_mut()
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
for pass in self
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
.chain(&mut self.clouds)
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp)
{
pass.reset_history();
}
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
/// current mapping. The buffers it both samples and stores to are copied first, and it
/// samples the copies.
@@ -0,0 +1,109 @@
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
#[test]
#[ignore = "requires a host GPU with compute and fragment storage"]
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
add(
"setup.csh",
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
);
add(
"begin.csh",
&format!(
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
),
);
for name in ["composite", "shadowcomp"] {
add(&format!("{name}.vsh"), vertex);
let target = if name == "composite" { 2 } else { 0 };
let (declarations, value) = if name == "composite" {
("", "vec4(0.125)")
} else {
(
"uniform sampler2D shadowcolor0;varying vec2 uv;",
"texture2D(shadowcolor0,uv)+vec4(0.125)",
)
};
add(
&format!("{name}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{target} */\n\
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
),
);
}
add("final.vsh", vertex);
add(
"final.fsh",
&format!(
"#version 430 compatibility\n{state}\
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
),
);
add(
"shaders.properties",
"shadow.enabled=true\nbufferObject.0=8\n\
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
blend.composite=ONE ONE ONE ONE\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let load = || horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
let check = |pixels: &[[u8; 4]], frame: u8| {
let counter = 32 * frame;
for (i, pixel) in pixels.iter().enumerate() {
let expected = if i % 16 < 8 {
[counter, counter, counter, 255]
} else {
[counter, 32, 96, 255]
};
assert!(
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
);
}
};
for frame in 1..=3 {
check(&gpu.render(&mut post, [16, 16]), frame);
}
// At the same size too: the retained colortex2 and shadowcolor0, the image and
// the storage buffer start over, and setup runs once again. The control is a pack never
// reset.
for _ in 0..2 {
post.reset_history(&gpu.device);
for frame in 1..=2 {
let pixels = gpu.render(&mut post, [16, 16]);
check(&pixels, frame);
if frame == 1 {
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
}
}
fresh.reset_history(&gpu.device);
}
}
@@ -76,7 +76,50 @@ fn target(
(texture, view)
}
/// The depth stand-in is initialized to no shadow on the next pack render.
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
Some([a, b]) => {
colors |= 1 << n;
if a.texture() != b.texture() {
doubled |= 1 << n;
}
a.texture().format()
}
None => wgpu::TextureFormat::Rgba8Unorm,
});
*self = Self::new(
device,
shadow,
formats,
colors,
doubled,
self.depth1.is_some(),
);
}
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
/// resolution is kept within the device's limit.
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl FrameUniforms {
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
/// centre depth), keeping the pack's atlas size and shadow settings.
pub(super) fn reset_history(&mut self) {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
..Self::default()
};
}
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
/// at their targets, the clock from 0; the atlas size it was given stays.
#[test]
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
let hud = HudBatch::new([16, 16]);
let mut f = frame(&hud);
let mut uniforms = FrameUniforms {
atlas: [256, 128],
..Default::default()
};
let mut inputs = PackInputs {
rain: 1.0,
eye_light: [15, 15],
frame_timing: Some(FrameTiming {
counter: 800,
elapsed: 99.0,
delta: 1.0,
}),
..Default::default()
};
uniforms.center_depth_raw = Some(0.25);
uniforms.update(&f, &inputs, [16, 16]);
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
f.yaw = 77.0;
inputs.rain = 0.25;
inputs.eye_light = [3, 8];
inputs.frame_timing = None;
uniforms.reset_history();
uniforms.update(&f, &inputs, [16, 16]);
let float = |name| match uniforms.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
};
let int = |name| match uniforms.values[index(name).unwrap()] {
Value::I(v) => v,
_ => panic!("integer uniform expected"),
};
assert_eq!(int("frameCounter")[0], 0);
assert_eq!(float("frameTime")[0], 0.0);
assert_eq!(int("atlasSize")[..2], [256, 128]);
assert_eq!(float("wetness")[0], 0.25);
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
assert_eq!(float("centerDepthSmooth")[0], 1.0);
for (previous, current) in [
("previousCameraPosition", "cameraPosition"),
("gbufferPreviousModelView", "gbufferModelView"),
("gbufferPreviousProjection", "gbufferProjection"),
] {
assert_eq!(
float(previous).map(f32::to_bits),
float(current).map(f32::to_bits)
);
}
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -324,8 +324,9 @@ mod tests {
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack under `HORIZON_HDR_FRAME=float`
/// (`Renderer::frame_format`): what the tonemap left over 1 is clipped on the way.
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
/// the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
+7 -3
View File
@@ -1092,8 +1092,9 @@ impl Graphics {
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
// silicon only the performance cores count (horizon_threads::default_workers).
let auto = horizon_threads::jobs::default_threads();
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1476,7 +1477,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+15 -8
View File
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
+5
View File
@@ -1354,6 +1354,11 @@ impl HudAtlas {
bordered.insert(image.0.clone());
}
}
if full {
let (width, height, rgba) =
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 mezz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

+141 -39
View File
@@ -90,6 +90,11 @@ fn round_to_u16(v: f32) -> u16 {
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// A model quad's own UVs in `TerrainVertex` fixed point, the packing
/// `TerrainVertex::new` does, baked once per face (`BakedFace::uv_fixed`).
pub(crate) fn baked_uv(uv: [[f32; 2]; 4]) -> [[u16; 2]; 4] {
uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)))
}
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
/// which span `[0, 2048)`: merged faces repeat their sprite once per block.
pub const TILED_UV_STEPS: f32 = 32.0;
@@ -103,6 +108,23 @@ impl TerrainVertex {
color,
}
}
/// A vertex whose UV is already in `UV_STEPS` fixed point (a model face's
/// own UVs, baked in `BakedFace::uv_fixed`).
pub(crate) fn packed_uv(
pos: [f32; 3],
uv: [u16; 2],
layer: u16,
color: u32,
light: u16,
) -> Self {
Self {
pos,
uv,
layer,
light,
color,
}
}
/// A vertex of a merged distant-terrain face, its UV in blocks from the
/// face's corner. Drawn with an instance UV scale of `1 / TILED_UV_STEPS`.
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
@@ -122,6 +144,63 @@ impl TerrainVertex {
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
#[test]
fn baked_face_keeps_shape_bounds_and_fixed_point_uvs() {
use crate::models::catalog::BakedFace;
use horizon_assets::{
bake::ModelQuad,
model::{Direction, Material},
};
let face = BakedFace::new(
ModelQuad {
positions: [
[-0.25, 0.5, 0.125],
[-0.25, 0.5, 0.875],
[0.625, 0.5, 0.875],
[0.625, 0.5, 0.125],
],
uv: [
[-0.25, f32::NAN],
[1.0 / 65536.0, 3.0 / 65536.0],
[1.5, 2.0],
[f32::INFINITY, f32::NEG_INFINITY],
],
facing: Direction::Up,
cull: None,
material: Material {
sprite: "test:face".into(),
force_translucent: false,
},
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
0,
true,
false,
None,
);
// Literal expectations: axis-ordered extrema and complements, plus
// clamping and half-step rounding in 1/32768 UV units.
let bounds: [f32; 12] = [
0.5, 0.5, 0.125, 0.875, -0.25, 0.625, 0.5, 0.5, 0.875, 0.125, 1.25, 0.375,
];
for shape in face.shapes {
assert_eq!(shape.bounds.map(f32::to_bits), bounds.map(f32::to_bits));
assert!(shape.partial);
}
assert!(!face.shapes[0].cubic);
assert!(face.shapes[1].cubic);
assert_eq!(face.uv_fixed, [[0, 0], [1, 2], [49152, 65535], [65535, 0]]);
}
#[test]
fn model_uvs_pack_exactly() {
for i in 0..=32 {
@@ -957,6 +1036,12 @@ const P: usize = 16 + 2 * B as usize;
fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// `pi`'s index offset of a step `n`: `pi(p + n) - pi(p)`, so the samples
/// around a cell are `base + step_index(n)` rather than another `pi`.
#[inline]
const fn step_index(n: [i32; 3]) -> isize {
(n[0] + P as i32 * n[2] + (P * P) as i32 * n[1]) as isize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
@@ -1079,6 +1164,17 @@ const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// `step_index` of each direction's normal, indexed like `DIRECTIONS`: the
/// padded index of a block's neighbour is its own plus one of these.
const OFFSETS: [isize; 6] = {
let mut out = [0isize; 6];
let mut i = 0;
while i < 6 {
out[i] = step_index(DIRECTIONS[i].1);
i += 1;
}
out
};
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -1540,8 +1636,12 @@ impl Mesher<'_> {
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
// The padded index of the row's x = 0 cell: `pi(x, y, z)` is
// `row + x` and a neighbour adds `OFFSETS[d]`.
let row = pi(0, y, z);
for x in 0..16 {
let id = pad.blocks[pi(x, y, z)];
let cell = row + x as usize;
let id = pad.blocks[cell];
if id == 0 {
continue;
}
@@ -1596,7 +1696,7 @@ impl Mesher<'_> {
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let neighbor = pad.blocks[(cell as isize + OFFSETS[d as usize]) as usize];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
@@ -1656,7 +1756,8 @@ impl Mesher<'_> {
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
let other =
pad.blocks[(cell as isize + step_index([0, dy, 0])) as usize];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
@@ -1666,6 +1767,7 @@ impl Mesher<'_> {
&mut tints,
&mut out,
[x, y, z],
cell,
origin,
face,
ao,
@@ -1706,6 +1808,7 @@ impl Mesher<'_> {
tints: &mut Tints,
out: &mut SectionMesh,
pos: [i32; 3],
cell: usize,
origin: [f32; 3],
face: &BakedFace,
ao: bool,
@@ -1730,11 +1833,14 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// The quad's shape (bounds, partial, cubic), baked per face for full and
// non-full blocks: a pure function of the quad and the block's `full`.
let shape = &face.shapes[usize::from(state.full)];
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
lighting::ambient_occlusion(pad, info, state, pos, shape, q.facing);
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
@@ -1744,7 +1850,7 @@ impl Mesher<'_> {
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
None => lighting::flat_shaped(pad, state, pos, q.facing, shape),
};
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
@@ -1768,8 +1874,7 @@ impl Mesher<'_> {
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
@@ -1810,44 +1915,41 @@ impl Mesher<'_> {
&mut push,
)
};
let vertex = |tint: Option<u32>, i: usize, uv: [u16; 2]| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
TerrainVertex::packed_uv(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv,
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
),
uv[i],
)
})
let fixed = uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)));
std::array::from_fn::<_, 4, _>(|i| (vertex(tint, i, fixed[i]), uv[i]))
};
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
let corners = if state.pane && tiled {
vertices(tint, standard())
} else {
// The face's own UVs, already packed: no per-vertex rounding.
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
};
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -19,6 +19,11 @@ use std::{convert::Infallible, sync::OnceLock};
/// `FluidRenderer.MAX_FLUID_HEIGHT`.
const MAX_HEIGHT: f32 = 0.888_888_9;
const EPSILON: f32 = 0.001;
/// `super::step_index` of the unit steps of the padded index: one block east
/// (+x), one south (+z), one up (+y).
const X_STEP: isize = 1;
const P_STEP: isize = super::P as isize;
const UP: isize = super::step_index([0, 1, 0]);
/// For the occlusion face toward `direction`: the height from the bottom over
/// which its slice flush with the `direction` side covers the whole width
@@ -224,14 +229,14 @@ pub(super) fn tesselate(
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
// The cell's own padded index: every other cell sampled here is a constant
// step from it (`super::step_index`), so no `pi` per sample.
let cell = pi(x, y, z) as isize;
let at = |step: isize| &info[pad.blocks[(cell + step) as usize] as usize];
let me = at(0);
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let neighbor = |d: Direction| at(super::OFFSETS[d as usize]);
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
let should_render = |d: Direction| !same(neighbor(d)) && !occluded_by_self(me, d);
let render_up = !same(up);
@@ -265,10 +270,10 @@ pub(super) fn tesselate(
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
let height = |step: isize| -> f32 {
let s = at(step);
if same(s) {
if same(at([p[0], p[1] + 1, p[2]])) {
if same(at(step + UP)) {
1.0
} else {
s.fluid.own_height()
@@ -279,15 +284,15 @@ pub(super) fn tesselate(
-1.0
}
};
let own = height([x, y, z]);
let own = height(0);
let (mut ne, mut nw, mut se, mut sw) = (1.0f32, 1.0f32, 1.0f32, 1.0f32);
if own < 1.0 {
let n = height([x, y, z - 1]);
let s = height([x, y, z + 1]);
let e = height([x + 1, y, z]);
let w = height([x - 1, y, z]);
let n = height(-P_STEP);
let s = height(P_STEP);
let e = height(X_STEP);
let w = height(-X_STEP);
// calculateAverageHeight(self, height2, height1, corner)
let average = |h2: f32, h1: f32, corner: [i32; 3]| -> f32 {
let average = |h2: f32, h1: f32, corner: isize| -> f32 {
if h1 >= 1.0 || h2 >= 1.0 {
return 1.0;
}
@@ -313,16 +318,17 @@ pub(super) fn tesselate(
add(h2);
sum[0] / sum[1]
};
ne = average(n, e, [x + 1, y, z - 1]);
nw = average(n, w, [x - 1, y, z - 1]);
se = average(s, e, [x + 1, y, z + 1]);
sw = average(s, w, [x - 1, y, z + 1]);
ne = average(n, e, X_STEP - P_STEP);
nw = average(n, w, -X_STEP - P_STEP);
se = average(s, e, X_STEP + P_STEP);
sw = average(s, w, -X_STEP + P_STEP);
}
let light = |p: [i32; 3]| {
let above = [p[0], p[1] + 1, p[2]];
let light = |step: isize| {
let i = (cell + step) as usize;
let above = (cell + step + UP) as usize;
pack_coords(lighting::max_coords(
lighting::coords(pad, at(p), p),
lighting::coords(pad, at(above), above),
lighting::coords_at(pad, lighting::state_at(pad, info, i), i),
lighting::coords_at(pad, lighting::state_at(pad, info, above), above),
))
};
let (still, flow, overlay) = if water {
@@ -393,7 +399,7 @@ pub(super) fn tesselate(
// FluidState.shouldRenderBackwardUpFace over the 3x3 cells above.
let backward = (-1..=1).any(|ox| {
(-1..=1).any(|oz| {
let s = at([x + ox, y + 1, z + oz]);
let s = at(UP + ox as isize * X_STEP + oz as isize * P_STEP);
!same(s) && !s.solid
})
});
@@ -412,7 +418,7 @@ pub(super) fn tesselate(
corners,
layer,
color(cardinal(D::Up)),
light([x, y, z]),
light(0),
backward,
Direction::Up as usize,
);
@@ -428,12 +434,12 @@ pub(super) fn tesselate(
],
still,
color(cardinal(D::Down)),
light([x, y - 1, z]),
light(-UP),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let side_light = light(0);
let up_shade = cardinal(D::Up);
for (i, d) in [
Direction::North,
@@ -3,6 +3,7 @@
//! and `ModelBlockRenderer`'s integer colour arithmetic (`ARGB.gray`,
//! `scaleRGB`, `multiply`).
use super::{Info, Padded, pi};
use crate::models::catalog::QuadShape;
use horizon_assets::model::Direction as D;
/// `LightCoordsUtil.FULL_BRIGHT`.
@@ -25,28 +26,37 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
/// The normals of `D`'s variants, in the order [`ADJACENCY`] is indexed by.
const NORMALS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
D::Up => [0, 1, 0],
D::North => [0, 0, -1],
D::South => [0, 0, 1],
D::West => [-1, 0, 0],
D::East => [1, 0, 0],
}
NORMALS[face as usize]
}
pub(super) fn step(p: [i32; 3], face: D) -> [i32; 3] {
let n = normal(face);
[p[0] + n[0], p[1] + n[1], p[2] + n[2]]
}
fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
&info[pad.blocks[pi(p[0], p[1], p[2])] as usize]
/// The block state of the padded cell at index `i` (see `pi`, `step_index`).
#[inline]
pub(super) fn state_at<'a>(pad: &Padded, info: &'a [Info], i: usize) -> &'a Info {
&info[pad.blocks[i] as usize]
}
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of the cell at index `i`, as seen through `state`.
#[inline]
pub(super) fn coords_at(pad: &Padded, state: &Info, i: usize) -> u32 {
packed_coords(state, pad.light[i])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
@@ -238,64 +248,21 @@ const ADJACENCY: [Adjacency; 6] = [
adjacency(D::West),
adjacency(D::East),
];
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape).
struct Shape {
bounds: [f32; 12],
partial: bool,
cubic: bool,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> Shape {
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index as usize] = value;
bounds[index as usize + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial = |a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
D::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
D::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
D::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
D::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
D::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
D::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
Shape {
bounds,
partial,
cubic,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> QuadShape {
QuadShape::new(positions, face, full)
}
/// `prepareQuadFlat` for a quad without a cull face: light from the
/// neighbour when the quad is flush with the face, else from the block itself.
pub(super) fn flat(
/// neighbour when the quad is flush with the face (cubic), else from the block
/// itself. `shape` is the quad's baked [`QuadShape`].
pub(super) fn flat_shaped(
pad: &Padded,
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
shape: &QuadShape,
) -> u32 {
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
let at = if shape.cubic { step(pos, face) } else { pos };
coords(pad, own, at)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
@@ -314,6 +281,51 @@ pub(super) fn flat_position(
None => pos,
}
}
/// A face's AO sample offsets from the sample base, in `pi` index space, so a
/// sample is `base + offset` rather than a `pi` per coordinate triple.
struct Samples {
/// The four corners (`adjacency`'s).
corners: [isize; 4],
/// Each corner stepped out of the face (the permeability sample).
out: [isize; 4],
/// Corner `a` stepped toward corner `b`.
diagonal: [[isize; 4]; 4],
/// `step(pos, face)` from the block's own cell.
step: isize,
}
const fn samples(face: D) -> Samples {
let mut corners = [0isize; 4];
let mut out = [0isize; 4];
let mut diagonal = [[0isize; 4]; 4];
let step = super::step_index(NORMALS[face as usize]);
let mut i = 0;
while i < 4 {
let c = super::step_index(NORMALS[ADJACENCY[face as usize].corners[i] as usize]);
corners[i] = c;
out[i] = c + step;
let mut j = 0;
while j < 4 {
diagonal[i][j] =
c + super::step_index(NORMALS[ADJACENCY[face as usize].corners[j] as usize]);
j += 1;
}
i += 1;
}
Samples {
corners,
out,
diagonal,
step,
}
}
const SAMPLES: [Samples; 6] = [
samples(D::Down),
samples(D::Up),
samples(D::North),
samples(D::South),
samples(D::West),
samples(D::East),
];
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
pub(super) fn ambient_occlusion(
@@ -321,26 +333,34 @@ pub(super) fn ambient_occlusion(
info: &[Info],
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
shape: &QuadShape,
face: D,
) -> ([f32; 4], [u32; 4]) {
let shape = shape(positions, face, own.full);
let base = if shape.cubic { step(pos, face) } else { pos };
let samples = &SAMPLES[face as usize];
// The block's own cell and the sample base (the neighbour when the quad is
// cubic): every corner and diagonal is a constant step from it.
let cell = pi(pos[0], pos[1], pos[2]) as isize;
let base = if shape.cubic {
cell + samples.step
} else {
cell
};
let adjacency = &ADJACENCY[face as usize];
let corner = |i: usize| step(base, adjacency.corners[i]);
let sample = |p: [i32; 3]| {
let s = state(pad, info, p);
(coords(pad, s, p), s.shade)
let corner = |i: usize| base + samples.corners[i];
let sample = |i: isize| {
let i = i as usize;
let s = state_at(pad, info, i);
(coords_at(pad, s, i), s.shade)
};
let (light0, shade0) = sample(corner(0));
let (light1, shade1) = sample(corner(1));
let (light2, shade2) = sample(corner(2));
let (light3, shade3) = sample(corner(3));
let permeable = |i: usize| state(pad, info, step(corner(i), face)).permeable;
let permeable = |i: usize| state_at(pad, info, (base + samples.out[i]) as usize).permeable;
let (p0, p1, p2, p3) = (permeable(0), permeable(1), permeable(2), permeable(3));
let diagonal = |a: usize, b: usize, open: bool| {
if open {
sample(step(corner(a), adjacency.corners[b]))
sample(base + samples.diagonal[a][b])
} else {
(light0, shade0)
}
@@ -349,15 +369,15 @@ pub(super) fn ambient_occlusion(
let (light03, shade03) = diagonal(0, 3, p3 || p0);
let (light12, shade12) = diagonal(1, 2, p2 || p1);
let (light13, shade13) = diagonal(1, 3, p3 || p1);
let next = step(pos, face);
let next_state = state(pad, info, next);
let next = (cell + samples.step) as usize;
let next_state = state_at(pad, info, next);
let light_center = if shape.cubic || !next_state.solid {
coords(pad, next_state, next)
coords_at(pad, next_state, next)
} else {
coords(pad, own, pos)
coords_at(pad, own, cell as usize)
};
let shade_center = if shape.cubic {
state(pad, info, base).shade
state_at(pad, info, base as usize).shade
} else {
own.shade
};
@@ -393,6 +413,57 @@ pub(super) fn ambient_occlusion(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ao_offsets_match_coordinate_samples_at_section_edges() {
// Explicit coordinate directions, without NORMALS, ADJACENCY or step_index.
let (down, up, north, south, west, east) = (
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
);
let faces = [
(D::Down, down, [west, east, north, south]),
(D::Up, up, [east, west, north, south]),
(D::North, north, [up, down, east, west]),
(D::South, south, [west, east, down, up]),
(D::West, west, [up, down, north, south]),
(D::East, east, [down, up, north, south]),
];
let add = |a: [i32; 3], b: [i32; 3]| std::array::from_fn(|i| a[i] + b[i]);
let index = |p: [i32; 3]| pi(p[0], p[1], p[2]);
for x in [0, 1, 14, 15] {
for y in [0, 1, 14, 15] {
for z in [0, 1, 14, 15] {
let pos = [x, y, z];
for (face, normal, corners) in faces {
let samples = &SAMPLES[face as usize];
let cell = index(pos) as isize;
assert_eq!((cell + samples.step) as usize, index(add(pos, normal)));
for cubic in [false, true] {
let base_pos = if cubic { add(pos, normal) } else { pos };
let base = if cubic { cell + samples.step } else { cell };
for (i, corner) in corners.into_iter().enumerate() {
let p = add(base_pos, corner);
assert_eq!((base + samples.corners[i]) as usize, index(p));
assert_eq!((base + samples.out[i]) as usize, index(add(p, normal)));
}
for (a, b) in [(0, 2), (0, 3), (1, 2), (1, 3)] {
assert_eq!(
(base + samples.diagonal[a][b]) as usize,
index(add(add(base_pos, corners[a]), corners[b]))
);
}
}
}
}
}
}
}
const fn packed(block: u32, sky: u32) -> u32 {
block << 4 | sky << 20
}
@@ -1,6 +1,44 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_steps_match_coordinate_lookup_through_the_border() {
// Coordinate lookup is the old addressing path, independent of step_index
// and the direction table. Include every cell and the full two-cell halo.
let neighbors = [
(Direction::Down, [0, -1, 0]),
(Direction::Up, [0, 1, 0]),
(Direction::North, [0, 0, -1]),
(Direction::South, [0, 0, 1]),
(Direction::West, [-1, 0, 0]),
(Direction::East, [1, 0, 0]),
];
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
let cell = (pi(0, y, z) + x as usize) as isize;
assert_eq!(cell as usize, pi(x, y, z));
for (face, [dx, dy, dz]) in neighbors {
assert_eq!(
(cell + OFFSETS[face as usize]) as usize,
pi(x + dx, y + dy, z + dz)
);
}
for dy in -2..=2 {
for dz in -2..=2 {
for dx in -2..=2 {
assert_eq!(
(cell + step_index([dx, dy, dz])) as usize,
pi(x + dx, y + dy, z + dz)
);
}
}
}
}
}
}
}
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
let mut pad = Padded::default();
@@ -9,6 +9,7 @@ use horizon_assets::{
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
choice::CompiledChoices,
},
model::Direction,
};
use horizon_core::{
position::BlockPos,
@@ -26,6 +27,65 @@ use std::{
sync::OnceLock,
};
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape). A function of the quad's positions and the
/// block's `isCollisionShapeFullBlock` only, so it is baked per face, once for
/// each.
#[derive(Clone, Copy)]
pub struct QuadShape {
/// The quad's bounds by face index (`mesher::lighting`'s `SizeInfo`
/// order: down, up, north, south, west, east), and `1.0 - the bound`.
pub bounds: [f32; 12],
pub partial: bool,
pub cubic: bool,
}
impl QuadShape {
pub fn new(positions: &[[f32; 3]; 4], face: Direction, full: bool) -> Self {
// `SizeInfo` indices into the face shape.
const DOWN: usize = 0;
const UP: usize = 1;
const NORTH: usize = 2;
const SOUTH: usize = 3;
const WEST: usize = 4;
const EAST: usize = 5;
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index] = value;
bounds[index + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial =
|a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
Direction::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
Direction::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
Direction::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
Direction::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
Direction::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
Direction::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
QuadShape {
bounds,
partial,
cubic,
}
}
}
pub struct BakedFace {
pub quad: ModelQuad,
pub layer: u32,
@@ -37,6 +97,33 @@ pub struct BakedFace {
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
/// two bytes, within the padding every face already has.
pub fusion: Option<std::num::NonZeroU16>,
/// `prepareQuadShape` of the quad, indexed by the block's `full` flag: the
/// mesher would otherwise recompute it for every face of every block.
pub shapes: [QuadShape; 2],
/// The quad's own UVs in `TerrainVertex` fixed point, which the mesh
/// otherwise recomputes for every face of every block.
pub uv_fixed: [[u16; 2]; 4],
}
impl BakedFace {
pub fn new(
quad: ModelQuad,
layer: u32,
opaque: bool,
translucent: bool,
fusion: Option<std::num::NonZeroU16>,
) -> Self {
let shapes = [false, true].map(|full| QuadShape::new(&quad.positions, quad.facing, full));
let uv_fixed = crate::mesher::baked_uv(quad.uv);
Self {
quad,
layer,
opaque,
translucent,
fusion,
shapes,
uv_fixed,
}
}
}
pub struct BakedModel {
pub ambient_occlusion: bool,
@@ -102,13 +189,7 @@ fn bake_resolved(
sprites.region_opaque(layer, &quad.uv),
),
};
Ok(BakedFace {
translucent,
opaque,
quad,
layer,
fusion: None,
})
Ok(BakedFace::new(quad, layer, opaque, translucent, None))
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
@@ -118,13 +118,7 @@ mod tests {
)
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 7,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 7, true, false, None))
.collect();
let Some(styles) = cube_styles(&faces) else {
assert!(lock);
@@ -153,13 +147,7 @@ mod tests {
bake_model_transformed(&model, BlockModelTransform::new([0; 3], false))
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 0,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 0, true, false, None))
.collect();
faces[0].opaque = false;
assert!(cube_styles(&faces).is_none());
+29 -1
View File
@@ -184,6 +184,14 @@ impl Weather {
self.rain_level.1 =
(self.rain_level.1 + if self.raining { 0.01 } else { -0.01 }).clamp(0.0, 1.0);
}
/// The levels at their targets at once (rain 1 while it rains, else 0, and thunder the
/// same), both halves, so the frame shows them at any partial tick: a frozen world under
/// `HORIZON_STILL`, which would otherwise have to run about a hundred ticks to fade them.
pub fn settle_levels(&mut self) {
let target = |on: bool| if on { 1.0 } else { 0.0 };
self.rain_level = (target(self.raining), target(self.raining));
self.thunder_level = (target(self.thundering), target(self.thundering));
}
/// Level.getRainLevel / getThunderLevel (thunder scales with rain).
pub fn levels(&self, partial: f32) -> (f32, f32) {
let rain = self.rain_level.0 + (self.rain_level.1 - self.rain_level.0) * partial;
@@ -562,11 +570,31 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::{DirtySections, World};
use super::{DirtySections, Weather, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// `/weather thunder` then `/weather clear` in a frozen world: the levels sit at their
/// targets at every partial tick, where a tick of fading would have moved them 0.01.
#[test]
fn settled_weather_shows_its_targets_at_any_partial_tick() {
let mut weather = Weather {
raining: true,
thundering: true,
rain_level: (0.25, 0.26),
..Weather::default()
};
weather.settle_levels();
for partial in [0.0, 0.37, 1.0] {
assert_eq!(weather.levels(partial), (1.0, 1.0), "{partial}");
}
weather.raining = false;
weather.thundering = false;
weather.settle_levels();
assert_eq!(weather.levels(0.5), (0.0, 0.0));
}
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
@@ -892,5 +892,9 @@
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"horizon.pad.notice.connected.press": "Press to use it"
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
}
@@ -1200,6 +1200,7 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
+21
View File
@@ -280,6 +280,7 @@ mod native_server;
mod native_shaders;
mod native_structures;
mod native_terrain_model;
pub mod native_text_field;
mod native_ticks;
mod native_travel;
mod native_view_distance;
@@ -8203,6 +8204,11 @@ impl Game<'_> {
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
} else if self.clock.still() {
// Under `HORIZON_STILL` a frozen world's rain and thunder sit at their levels, as a
// shader pack's smoothed values do, so `/weather rain` shows full rain without
// stepping the world (and its mobs) through the fade.
self.world.weather.settle_levels();
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
@@ -18818,6 +18824,19 @@ impl Game<'_> {
}
}
}
// Title boxes keep their text on the key path. A field hook takes
// it through the same per-character editor as IME and control input.
if !canceled
&& control::title_owns_text(&self.screen, self.title.is_some())
&& self.types_text()
&& let Some(text) = key_event.text.as_deref()
&& let Some(result) = self.type_text_field_input(text)
{
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
key_event.text = None;
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
@@ -22135,6 +22154,7 @@ fn build_renderer(
wide_frame,
shader_pack.draws_flat,
shader_pack.linear,
shader_pack.hdr_float,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -22208,6 +22228,7 @@ fn loading_renderer(
false,
false,
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
+3 -1
View File
@@ -1647,7 +1647,9 @@ impl Game<'_> {
}
if text == "\\b" {
title.backspace();
} else {
} else if let Some(result) = self.type_text_field_input(text) {
result?;
} else if let Some(title) = self.title.as_mut() {
title.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
+13 -2
View File
@@ -4042,6 +4042,10 @@ impl Game<'_> {
},
None => self.player_display_name(),
};
// A player's command whose output is captured (a function, `execute …
// run say`, the command harness) gets the announcement with that
// output, in order; everyone else gets the broadcast.
let captured_for = self.chat_capture.as_ref().and(self.lan_acting);
let line = match id {
"say" => {
// SayCommand broadcasts independently of the command source's feedback.
@@ -4051,6 +4055,7 @@ impl Game<'_> {
"say_command",
component_nbt(&name.component()),
component_nbt(&message.component()),
captured_for,
);
}
Text::translate("chat.type.announcement", &[name, message])
@@ -4081,9 +4086,15 @@ impl Game<'_> {
secure: false,
});
}
// SayCommand sends no feedback: the broadcast above already reached
// the guest whose command is being captured.
if self.lan.is_some() && self.chat_capture.is_some() {
// The broadcast skipped the acting player: their copy goes with
// the captured output. Without one (the console), the broadcast
// reached everyone and the console logged it above.
if captured_for.is_some()
&& let Some(capture) = self.chat_capture.as_mut()
{
capture.push(line);
}
return Ok(1);
}
self.player_message(line);
@@ -1,6 +1,10 @@
//! `RaidCommand`: `/raid start|stop|check|spawnleader|setomen|glow|sound`.
use super::{Game, R, Text, err};
use horizon_client::{command::Context, mobs::raider};
use horizon_client::{
command::{Arg, Context},
mobs::raider,
};
use horizon_core::random::RandomSource;
impl Game<'_> {
pub(in crate::game) fn cmd_raid(&mut self, id: &str, ctx: &Context) -> R<i32> {
@@ -39,7 +43,9 @@ impl Game<'_> {
Ok(-1)
}
},
"raid.check" => match self.raiding_nearby(at).filter(|id| self.raid_started(*id)) {
// RaidCommand.getRaid is getRaidAt: a raid still counting down
// to its first wave is found too (#11372).
"raid.check" => match self.raiding_nearby(at) {
Some(raid) => {
self.feedback(Text::literal("Found a started raid!"));
let status = self.raid_status_text(raid);
@@ -96,13 +102,90 @@ impl Game<'_> {
}
Ok(1)
}
// RaidCommand.playSound: only for the text "local", at volume 2,
// with a seed from the level's random (playSeededSound).
"raid.sound" => {
let at = [player.x + 5.0, player.y, player.z];
self.audio
.play("minecraft:event.raid.horn", Some(at), 1.0, 1.0);
// ComponentArgument: `local`, "local" and 'local' are all the
// text "local".
let kind = match ctx.get("type") {
Some(Arg::Str(s)) => s.trim(),
_ => "",
};
if matches!(kind, "local" | "\"local\"" | "'local'") {
let seed = self.world.random.next_long();
let at = [player.x + 5.0, player.y, player.z];
self.audio
.play_seeded("minecraft:event.raid.horn", Some(at), 2.0, 1.0, seed);
}
Ok(1)
}
_ => Err(err("commands.help.failed", &[])),
}
}
}
#[cfg(test)]
mod tests {
use super::Game;
use crate::game::native_machines::tests::server_game;
use horizon_client::{command::vanilla, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
/// Runs a line of the dev tree (`/raid` is registered only there).
fn run_dev(game: &mut Game<'_>, line: &str) -> i32 {
let d = vanilla::build_with(true);
let parse = d.parse(line, &game.ui.registries);
let (ctx, _) = parse.executable().expect(line);
let (ctx, root) = (ctx.clone(), parse.context.clone());
game.run_chain(&root, &ctx).expect(line)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn raid_check_finds_a_raid_counting_down_and_the_horn_is_local_only() {
let mut game = server_game();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
// RaidCommand needs the owner's permission level (3).
game.ui.registries.permission = 4;
game.chat_capture = Some(Vec::new());
game.audio.capture = Some(Vec::new());
let (id, _) = game.raiding.raids.create_or_extend([0, 64, 0], 2, None, 3);
assert!(!game.raiding.raids.get(id).unwrap().started);
assert_eq!(run_dev(&mut game, "raid check"), 1);
let lines: Vec<String> = game
.chat_capture
.take()
.unwrap()
.iter()
.map(|t| t.plain())
.collect();
assert_eq!(
lines,
[
"Found a started raid!",
"Num groups spawned: 0 Raid omen level: 0 Num mobs: 0 Raid health: 0.0 / 0.0",
]
);
run_dev(&mut game, "raid sound global");
assert!(game.audio.capture.as_ref().unwrap().is_empty());
run_dev(&mut game, "raid sound local");
let played: Vec<_> = game
.audio
.capture
.take()
.unwrap()
.into_iter()
.map(|s| (s.event, s.position, s.volume))
.collect();
assert_eq!(
played,
[(
"minecraft:event.raid.horn".to_owned(),
[5.5, 64.0, 0.5],
2.0
)]
);
}
}
+21
View File
@@ -260,6 +260,21 @@ impl Game<'_> {
/// Nothing here is a key — the caller drops control characters. `Err` when
/// no text box of the screen takes them.
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
// Overlays own the input even when a text box remains focused behind them.
if self.dialog_open() {
self.dialog_type(text);
return Ok(());
}
if self.prompt_open() {
return Ok(());
}
if let Some(result) = self.type_text_field_input(text) {
return result;
}
self.type_chars_unhooked(text)
}
pub(super) fn type_chars_unhooked(&mut self, text: &str) -> Result<()> {
if self.book.is_some() {
self.book_type(text);
return Ok(());
@@ -1015,6 +1030,12 @@ impl Game<'_> {
// key event fires it. A cancelled character is dropped, so
// `type` types the survivors only.
// `\b` is the socket's backspace sentinel, not two characters.
if text != "\\b"
&& let Some(result) = self.type_text_field_input(text)
{
result?;
return ok();
}
let typed = if text == "\\b" {
text.to_owned()
} else {
@@ -927,6 +927,7 @@ impl Game<'_> {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
// day.json: false from tick 12542, true from 23460 (a step track).
monsters_burn: self.dim == Dim::Overworld
@@ -1148,11 +1149,15 @@ impl Game<'_> {
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
let mut villager_slots = Vec::new();
let mut solid_slots = Vec::new();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == horizon_client::mobs::Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
laps.lap("creature_prepare");
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
@@ -1191,6 +1196,7 @@ impl Game<'_> {
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
nearest_player,
player: None,
teams: std::sync::Arc::clone(&base.teams),
@@ -1254,6 +1260,7 @@ impl Game<'_> {
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
difficulty,
nearest_player,
player: active_player,
@@ -8685,6 +8692,7 @@ mod pearl_tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -34,7 +34,7 @@ const HINT: u32 = 0xAA_AAAA;
/// The open screen: its search box and the list's scroll.
pub(super) struct DebugOptionsUi {
search: EditBox,
pub(super) search: EditBox,
scroll: Scroll,
}
@@ -44,6 +44,8 @@ impl Game<'_> {
/// Answers the requests waiting on the control endpoint.
pub(super) fn poll_server_control(&mut self, state: &mut ServerState) {
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
// A `console stop` just before stops this server for the next request too.
self.note_stopping(state.lists.lock().unwrap().stop);
let reply = match request.line.trim().split_once(' ') {
_ if request.line.trim() == "server-status" => self.server_status(state),
// A proxy reading whether it may send players here (transfer.rs).
@@ -8,7 +8,7 @@
//! it sends players here). They run
//! between two ticks, on the server thread, so the player is never caught half-way through a
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
//! `[transfer]` is off.
//! `[transfer]` is off, and those that bring a player in once the server has begun to stop.
use crate::game::{Dim, Game, lan::LanHost};
use horizon_client::lan::arrivals;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -40,6 +40,16 @@ impl Game<'_> {
reply
}
/// Once a stop has begun (`/stop` from the console, a player or the control endpoint, or a stop
/// signal), the transfers know it before the next request is answered: what would bring a player
/// in is refused from then on, and the ways out stay open (`arrivals::Transfers::stopping`).
pub(super) fn note_stopping(&mut self, stop: bool) {
let stopping = stop || horizon_threads::stop_requests() > 0;
if let Some(transfers) = self.lan.as_mut().and_then(|h| h.hub.transfers.as_mut()) {
transfers.stopping |= stopping;
}
}
/// `transfer-status`: the counts a proxy reads before it sends players here
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
/// the log.
+10
View File
@@ -100,7 +100,17 @@ impl Game<'_> {
/// Text the input method committed (`WindowEvent::Ime(Commit)`).
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
// Dialogs have their own fields; the other topmost prompts take no text.
if self.prompt_open() && !self.dialog_open() {
return;
}
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if let Some(result) = self.type_text_field_input(&text) {
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
return;
}
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
// reaches a text box by characters, as a key's own text does, so the
// open screen hears of each of them first and a cancelled one never
@@ -2802,6 +2802,9 @@ impl Game<'_> {
S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => {
self.guest_far_terrain(host, conn, &p.data)
}
S::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
self.guest_recipe_transfer(host, conn, &p.data);
}
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
+66 -15
View File
@@ -10,7 +10,10 @@ use horizon_client::{
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
menu::{
Env, Input, Kind, Menu,
place::{PostPlace, TransferError},
},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -452,7 +455,6 @@ impl Game<'_> {
conn: u32,
p: &horizon_protocol::packets::recipes::PlaceRecipe,
) {
use horizon_client::menu::place::PostPlace;
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -481,7 +483,63 @@ impl Game<'_> {
let (player, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
let creative = g.creative();
let placed = if p.container == 0 {
let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
if placed == Some(PostPlace::Ghost) {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
}
pub(super) fn guest_recipe_transfer(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
use horizon_protocol::buf::{Reader, Writer};
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
if g.brand != "horizon" || g.spectator() {
return;
}
let mut reader = Reader::new(data);
let (Ok(container), Ok(id)) = (reader.varint(), reader.string(512)) else {
return;
};
if reader.remaining() != 0 {
return;
}
let result = if container == 0 && (g.menu.is_some() || g.creative()) {
Err(TransferError::Unavailable)
} else {
self.lan_apply_recipe(host, gi, container, |menu, env| {
menu.transfer_recipe(env, &id)
})
.unwrap_or(Err(TransferError::Unavailable))
};
let mut reply = Writer::new();
reply.varint(container);
reply.u8(match result {
Ok(()) => 0,
Err(TransferError::Missing) => 1,
Err(TransferError::NoRoom) => 2,
Err(TransferError::Unavailable) => 3,
});
host.hub.guests[gi].send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(),
data: reply.data,
},
));
}
fn lan_apply_recipe<R>(
&mut self,
host: &mut LanHost,
gi: usize,
container: i32,
apply: impl FnOnce(&mut Menu, &Env) -> R,
) -> Option<R> {
let conn = host.hub.guests[gi].conn;
let placed = if container == 0 {
let crafting = host.play.entry(conn).or_default();
let mut menu = crafting
.crafting
@@ -493,7 +551,7 @@ impl Game<'_> {
for (k, s) in grid.into_iter().enumerate() {
menu.set_slot(env, 1 + k, s);
}
menu.place_recipe(env, &id, p.use_max, creative, None)
apply(menu, env)
});
let g = &mut host.hub.guests[gi];
for k in 0..4 {
@@ -504,23 +562,16 @@ impl Game<'_> {
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu, pos)) = host
let (window, state, mut menu, pos) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == container))?;
let placed = self.guest_menu_op(host, gi, &mut menu, apply);
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
placed
};
if placed == PostPlace::Ghost {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
Some(placed)
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
+76 -21
View File
@@ -116,11 +116,6 @@ pub(super) struct Minecart {
on_rails: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
/// The horizontal speed the cart wanted when the collision stopped it:
/// `MinecartTNT.tick` reads the delta after the move, which a cart on a
/// rail keeps (`AbstractMinecart.moveAlongTrack` re-applies the track's
/// velocity), so a cart that ran into a block still counts as moving.
collision_speed: f64,
fall_distance: f64,
/// The squared speed `MinecartTNT.causeFallDamage` blows up with, set by
/// a landing of three blocks or more and taken by the cart's next tick
@@ -401,7 +396,6 @@ impl Minecart {
on_rails: false,
on_ground: false,
horizontal_collision: false,
collision_speed: 0.0,
fall_distance: 0.0,
fall_blast: None,
hurt_time: 0,
@@ -669,9 +663,6 @@ impl Game<'_> {
c.on_ground = delta.y < 0.0 && moved.y > delta.y;
c.horizontal_collision =
(moved.x - delta.x).abs() > 1.0e-7 || (moved.z - delta.z).abs() > 1.0e-7;
if c.horizontal_collision {
c.collision_speed = delta.x * delta.x + delta.z * delta.z;
}
if (moved.x - delta.x).abs() > 1.0e-7 {
c.velocity.x = 0.0;
}
@@ -1264,11 +1255,6 @@ impl Game<'_> {
None,
);
c.velocity = horizon_world::entity_move::scale_by_block_speed(c.velocity, factor);
// `comeOffTrack` moves the cart and keeps the delta the move resolved
// (then scales it by the air drag), so a cart an obstacle stopped here
// is not moving any more and `MinecartTNT.tick` does not detonate it -
// unlike the rail paths, which re-apply the track's velocity.
c.collision_speed = 0.0;
if !c.on_ground {
// scale(getAirDrag()): 0.95F, widened.
let drag = f64::from(0.95_f32);
@@ -1370,11 +1356,6 @@ impl Game<'_> {
// movement through powder snow. PowderSnowBlock uses getKnownMovement,
// which is getDeltaMovement for a minecart.
self.entity_powder_snow_particles(c.previous, c.position, c.bounds(), c.velocity, false);
if self.minecart_improvements() {
// MinecartTNT.tick reads the final delta after AbstractMinecart.tick,
// including the pushes and fluid interaction after the track steps.
c.collision_speed = c.velocity.horizontal_distance_squared();
}
// Variants.
match c.kind {
CartKind::Furnace => {
@@ -1412,8 +1393,14 @@ impl Game<'_> {
self.explode_cart(c, speed);
return false;
}
if tnt_ignites_by_collision(c.horizontal_collision, c.collision_speed) {
self.explode_cart(c, c.collision_speed);
// MinecartTNT.tick reads the delta left after AbstractMinecart.tick
// (pushes and fluids included). The move zeroed the blocked axes
// (Entity.restituteMovementAfterCollisions) and no track step puts
// them back: a head-on stop leaves nothing, a diagonal one keeps
// the free axis (#11380).
let speed = c.velocity.horizontal_distance_squared();
if tnt_ignites_by_collision(c.horizontal_collision, speed) {
self.explode_cart(c, speed);
return false;
}
}
@@ -3369,3 +3356,71 @@ mod tests {
}
}
horizon_client::impl_joined!(Minecart);
#[cfg(test)]
mod tnt_impact_tests {
use super::{CartKind, Minecart};
use crate::game::native_machines::tests::server_game;
use horizon_core::geometry::Vec3;
/// Ticks a moving TNT cart until it stops or blows up; true when it
/// blew up.
fn runs_into_the_wall(rails: bool, at: Vec3, velocity: Vec3) -> bool {
let mut game = server_game();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let rail = registry.default_state("minecraft:rail").unwrap();
let mut east_west = registry.state(rail).unwrap().clone();
east_west
.properties
.insert("shape".into(), "east_west".into());
let rail = registry.id(&east_west).unwrap();
for x in 0..16 {
for z in 0..16 {
game.world.set(x, 63, z, stone).unwrap();
}
}
for z in 0..16 {
game.world.set(9, 64, z, stone).unwrap();
game.world.set(9, 65, z, stone).unwrap();
}
if rails {
for x in 1..9 {
game.world.set(x, 64, 8, rail).unwrap();
}
}
let mut cart = Minecart::new(1, CartKind::Tnt, at);
cart.velocity = velocity;
for _ in 0..40 {
if !game.tick_cart(&mut cart, [0.0; 2]) {
return true;
}
}
false
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tnt_cart_stopped_head_on_by_a_wall_does_not_blow_up() {
// OldMinecartBehavior.moveAlongTrack: the move zeroes the blocked x
// and the track's re-pointing scales what is left, so MinecartTNT
// reads no speed at the wall.
assert!(!runs_into_the_wall(
true,
Vec3::new(3.5, 64.0, 8.5),
Vec3::new(0.4, 0.0, 0.0)
));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tnt_cart_off_its_rails_glancing_off_a_wall_blows_up() {
// comeOffTrack keeps the delta the move resolved: the wall zeroes x,
// z goes on at over a tenth of a block a tick.
assert!(runs_into_the_wall(
false,
Vec3::new(8.3, 64.0, 4.5),
Vec3::new(0.4, 0.0, 0.4)
));
}
}
@@ -994,6 +994,7 @@ use super::{
native_reload_listeners::NativeReloadListener,
native_world_draw::NativeWorldDraw,
};
use crate::mod_api::NativeTextField;
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::EntityDataValue;
use horizon_client::mod_api::{
@@ -3522,6 +3523,9 @@ impl Game<'_> {
/// sent and whenever its block changes, on the server thread or, for a
/// saved chunk sent past the view distance (`lan::far`), another one; a client's copy of the block
/// entity (made by `native_block_entity` there) merges it into its `nbt`.
/// An owner can merge `entity.take_client_update_fields()` into this tag
/// for one-shot fields that are neither saved nor cloned. The tag is
/// constructed once before a block change is broadcast to its viewers.
/// Leave it unset to send nothing. API 45.
pub(super) fn native_block_entity_update_tag(
pos: [i32; 3],
@@ -3996,6 +4000,24 @@ impl Game<'_> {
canceled,
);
}
pub(super) fn native_screen_text_field_char_typed_pre(
&self,
screen: &NativeScreenView<'_>,
field: &mut NativeTextField,
character: char,
modifiers: u32,
canceled: &mut bool,
) {
mod_api::dispatch_screen_text_field_char_typed_pre(
self,
mod_api::mods(),
screen,
field,
character,
modifiers,
canceled,
);
}
/// The open screen as a native mod sees it (`NativeScreenView`): its id,
/// the open menu's type and title, its slot count, and its panel's
/// rectangle, with the mouse and the partial tick. `screen` is the screen
@@ -4169,6 +4191,12 @@ impl Game<'_> {
let Some(text) = text else {
return;
};
if self.types_text()
&& let Some(result) = self.type_text_field_input(text)
{
Self::report_text_input_result(result);
return;
}
let Some(screen) = self.screen_view_now() else {
return;
};
@@ -11281,6 +11309,11 @@ impl Game<'_> {
pub(super) fn native_place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
self.recipe_place(book_id, all)
}
/// Move ingredients for a viewer recipe into the open crafting grid.
/// Unlike the recipe book click, this doesn't require learning the recipe.
pub(super) fn native_transfer_recipe(&mut self, book_id: i32) {
self.transfer_viewer_recipe(book_id);
}
/// Creative mode only: gives the player `count` of `item`
/// (`minecraft:diamond`) as taking it from the creative inventory does
/// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the
+71 -2
View File
@@ -23,6 +23,7 @@ const SLOT: f32 = 18.0;
const COLUMNS: (usize, usize) = (12, 2);
/// The gap between the window and the panel, and the screen's edges.
const MARGIN: f32 = 8.0;
const RECIPES_SETTINGS_WIDTH: f32 = 20.0;
/// Items a panel may hold.
const ITEMS: usize = 8192;
@@ -62,13 +63,15 @@ pub(super) struct Panel {
/// The mod's items as stacks; `None` for an item the game doesn't have.
stacks: Vec<Option<ItemStack>>,
page: usize,
search: Option<EditBox>,
pub(super) search: Option<EditBox>,
/// The search text the mod last heard.
sent: String,
/// Only while dispatching Search: an empty reply still needs an editable field.
searching: bool,
pub(super) recipes_settings_return: Option<Screen>,
/// The recipes shown instead of the items.
pub(super) recipes: Option<super::native_panel_recipes::Pages>,
pub(super) transfer_warning: Option<(&'static str, &'static str)>,
}
impl Panel {
@@ -116,6 +119,7 @@ impl Panel {
/// nothing, except while filtering the item list with a non-empty query.
fn native_changed(&mut self, keep_search: bool) {
self.versions += 1;
self.transfer_warning = None;
if let Some(shown) = self.native.as_mut() {
shown.version = self.versions;
if shown.items.is_empty() && shown.recipes.is_none() && !keep_search {
@@ -136,6 +140,7 @@ pub(super) struct Layout {
/// The page arrows' row.
pub(super) arrows_y: f32,
pub(super) search: [f32; 4],
settings: Option<[f32; 4]>,
}
impl Layout {
fn per_page(&self) -> usize {
@@ -222,7 +227,18 @@ impl Game<'_> {
let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor();
let arrows_y = MARGIN;
let top = arrows_y + 17.0 + 4.0;
let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0];
let mut search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0];
let settings = self.panel.of.as_ref().and_then(|(owner, _)| {
(owner.native_id() == "recipes").then(|| {
search[2] -= RECIPES_SETTINGS_WIDTH;
[
search[0] + search[2],
search[1],
RECIPES_SETTINGS_WIDTH,
search[3],
]
})
});
let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize;
(rows > 0).then_some(Layout {
area: [left, 0.0, right - left, batch.height],
@@ -231,6 +247,7 @@ impl Game<'_> {
rows,
arrows_y,
search,
settings,
})
}
fn browser_pages(&self, layout: &Layout) -> usize {
@@ -365,6 +382,31 @@ impl Game<'_> {
let mut search = search.clone();
search.draw(batch, &self.gui.atlas, None);
self.panel.search = Some(search);
if let Some(rect @ [x, y, width, height]) = layout.settings {
let hovered = inside(cursor, rect);
let atlas = &self.gui.atlas;
let sprite = if hovered {
"gui/sprites/widget/button_highlighted"
} else {
"gui/sprites/widget/button"
};
horizon_client::ui::nine_slice(batch, atlas, sprite, x, y, width, height, u32::MAX);
batch.sprite_scaled(
atlas,
"horizon/recipes/settings",
x + 4.0,
y + 2.0,
12.0,
12.0,
u32::MAX,
);
let label = self.translate("options.title", "Options");
horizon_client::widgets::record(&label, rect, true);
if hovered && self.carried.is_none() {
self.draw_tooltip_lines(batch, &[(label, u32::MAX)], cursor.0, cursor.1);
return;
}
}
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
@@ -388,6 +430,23 @@ impl Game<'_> {
return false;
};
let cursor = self.cursor;
if layout.settings.is_some_and(|rect| inside(cursor, rect)) {
if !right {
self.list_native_mods();
if let Some(index) = self
.settings
.mods
.installed
.iter()
.position(|entry| entry.id == "recipes" && entry.native.is_some())
{
self.mod_browser_closed();
self.panel.recipes_settings_return = Some(self.screen);
self.open_options_at(false, horizon_client::options_ui::Page::NativeMod(index));
}
}
return true;
}
let shift = self.has_shift_down();
let atlas = &self.gui.atlas;
let search = self.panel.search.get_or_insert_with(|| {
@@ -499,10 +558,20 @@ impl Game<'_> {
}
/// The container screen closed: the search box lets go of the keys.
pub(super) fn mod_browser_closed(&mut self) {
self.panel.transfer_warning = None;
if let Some(search) = self.panel.search.as_mut() {
search.focused = false;
}
}
pub(super) fn close_transferred_recipe(&mut self) {
if let Some(owner) = self.browser_sync()
&& owner.native_id() == "recipes"
&& self.panel.recipes.is_some()
{
self.mod_browser_closed();
self.panel_event(&owner, BrowserEvent::Back);
}
}
/// The item under the pointer on a container screen, for the mods' keys
/// (the `Key` event): the panel's, else a slot's.
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
@@ -453,6 +453,22 @@ impl Game<'_> {
);
}
}
if let Some((key, fallback)) = self.panel.transfer_warning {
let warning = self.translate(key, fallback);
let lines = [(
horizon_client::text::Text::literal(warning.clone()).resolve_legacy(),
rgba(255, 100, 100, 255),
)];
let (x, y, w, h) = Self::tooltip_geometry(
&self.gui.atlas,
&lines,
cursor,
(batch.width, batch.height),
[0.0; 2],
);
horizon_client::widgets::record(&warning, [x, y, w, h], false);
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
}
}
/// The tabs, on slots; the selected one framed.
@@ -194,7 +194,7 @@ fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
}
/// `HORIZON_HDR_FRAME=float`: shader API 0.2 packs are drawn, on a float frame where the
/// renderer is made with one picked.
/// renderer is made with one picked, as HDR without a pack is (`PickedPack::hdr_float`).
pub(super) fn linear_frame_switch() -> bool {
std::env::var("HORIZON_HDR_FRAME").is_ok_and(|v| v == "float")
}
@@ -0,0 +1,531 @@
//! Focused existing text boxes, without exposing a mutable UI or queued writes.
use super::*;
use crate::mod_api;
use horizon_client::{
screen_draw::{Measure, TextField},
ui::EditBox,
};
/// The field before a character is inserted. Cursor and selection anchor count
/// Unicode scalar characters, not UTF-8 bytes or UTF-16 code units. Host filters
/// and limits are read-only and apply to every replacement.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NativeTextField {
pub value: String,
pub cursor: usize,
pub highlight: usize,
max_length: usize,
multiline: bool,
}
impl NativeTextField {
pub fn max_length(&self) -> usize {
self.max_length
}
pub fn multiline(&self) -> bool {
self.multiline
}
fn from_edit(edit: &EditBox) -> Self {
Self {
value: edit.value.clone(),
cursor: edit.cursor,
highlight: edit.highlight,
max_length: edit.max_length,
multiline: edit.multiline,
}
}
fn from_text(edit: &TextField) -> Self {
Self {
value: edit.value.clone(),
cursor: edit.cursor,
highlight: edit.highlight,
max_length: edit.max_length,
multiline: false,
}
}
fn apply_edit(&self, edit: &mut EditBox) {
if self.value != edit.value {
edit.set_cursor(0, false);
edit.highlight = edit.value.chars().count();
edit.insert_text(&self.value);
}
if self.cursor != edit.cursor || self.highlight != edit.highlight {
edit.set_cursor(self.cursor, false);
edit.highlight = self.highlight.min(edit.value.chars().count());
}
}
fn apply_text(&self, edit: &mut TextField, measure: &Measure<'_>) {
if self.value != edit.value {
edit.move_cursor_to(measure, 0, false);
edit.highlight = edit.value.chars().count();
edit.insert_text(measure, &self.value);
}
if self.cursor != edit.cursor || self.highlight != edit.highlight {
edit.move_cursor_to(measure, self.highlight, false);
edit.move_cursor_to(measure, self.cursor, true);
}
}
}
fn edit_box(edit: &mut EditBox, replacement: Option<&NativeTextField>) -> Option<NativeTextField> {
if !edit.focused {
return None;
}
if let Some(field) = replacement {
field.apply_edit(edit);
}
Some(NativeTextField::from_edit(edit))
}
impl Game<'_> {
/// Specialized book/sign/dialog/rule, options and numeric editors retain
/// their own validators; this hook does not expose those editors yet.
fn focused_native_text_field(
&mut self,
replacement: Option<&NativeTextField>,
) -> Option<NativeTextField> {
if !self.screen_hooks_open() {
return None;
}
if control::title_owns_text(&self.screen, self.title.is_some()) {
let mut result = None;
self.title
.as_mut()?
.edit_focused_text_field(|edit| result = edit_box(edit, replacement));
return result;
}
match self.screen {
Screen::Chat | Screen::CommandBlock => {
let measure = Measure(&self.gui.atlas.font);
let commands = horizon_client::chat::Commands {
dispatcher: chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let screen = if self.screen == Screen::Chat {
self.ui.chat_screen.as_mut()?
} else {
&mut self.command_block_screen.as_mut()?.command
};
if !screen.input.focused {
return None;
}
let result = NativeTextField::from_text(&screen.input);
if let Some(field) = replacement {
screen.edit_input(&measure, &commands, |edit| field.apply_text(edit, &measure));
}
Some(result)
}
Screen::Creative if self.creative_screen.tab().search && !self.creative_ignore_text => {
let result = edit_box(&mut self.creative_screen.search, replacement);
if replacement.is_some() {
self.creative_screen.scroll = 0.0;
}
result
}
Screen::Social => {
let result = edit_box(&mut self.social.search, replacement);
if replacement.is_some() {
self.social.scroll = 0.0;
}
result
}
Screen::DebugOptions => edit_box(&mut self.debug_options.as_mut()?.search, replacement),
Screen::Inventory if self.mod_browser_typing() => {
let result = edit_box(self.panel.search.as_mut()?, replacement);
if replacement.is_some() {
self.mod_browser_text(None, false);
}
result
}
Screen::Inventory if self.recipe_search_focused() => {
edit_box(self.recipe_ui.search.as_mut()?, replacement)
}
Screen::Inventory if self.station_typing() => {
let result = edit_box(&mut self.station.as_mut()?.rename, replacement);
if replacement.is_some() {
self.station_text(None, false);
}
result
}
_ => None,
}
}
// Hookable source bodies cannot contain macros; diagnostics stay here.
pub(super) fn report_text_input_result(result: Result<()>) {
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
}
pub(super) fn type_text_field_input(&mut self, text: &str) -> Option<Result<()>> {
if mod_api::mods().is_empty() {
return None;
}
try_field_input(self, text)
}
}
trait FieldTarget {
fn owns_input(&self) -> bool;
fn old_hook(&mut self, character: char) -> bool;
fn field(&mut self, replacement: Option<&NativeTextField>) -> Option<NativeTextField>;
fn hook(&mut self, field: &mut NativeTextField, character: char) -> bool;
fn insert(&mut self, character: char) -> Result<()>;
}
fn try_field_input(target: &mut impl FieldTarget, text: &str) -> Option<Result<()>> {
if !target.owns_input() || target.field(None).is_none() {
return None;
}
Some(type_characters(target, text))
}
fn type_characters(target: &mut impl FieldTarget, text: &str) -> Result<()> {
for character in text.chars() {
if !target.owns_input() {
return Ok(());
}
if !character.is_control() && target.old_hook(character) {
continue;
}
let canceled =
if let Some(mut field) = target.field(None).filter(|_| !character.is_control()) {
let original = field.clone();
let canceled = target.hook(&mut field, character);
if field != original {
target.field(Some(&field));
}
canceled
} else {
false
};
if !canceled && target.owns_input() {
target.insert(character)?;
}
}
Ok(())
}
impl FieldTarget for Game<'_> {
fn owns_input(&self) -> bool {
self.screen_hooks_open()
}
fn old_hook(&mut self, character: char) -> bool {
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_char_typed_pre(
&screen,
character,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
fn field(&mut self, replacement: Option<&NativeTextField>) -> Option<NativeTextField> {
self.focused_native_text_field(replacement)
}
fn hook(&mut self, field: &mut NativeTextField, character: char) -> bool {
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_text_field_char_typed_pre(
&screen,
field,
character,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
fn insert(&mut self, character: char) -> Result<()> {
self.type_chars_unhooked(&character.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mod_api::{
Flow, Hooks, Host, NativeScreenView, ScreenCharTypedPreEvent,
ScreenTextFieldCharTypedPreEvent,
};
use std::sync::{Arc, Mutex};
struct HostStub;
impl Host for HostStub {}
struct Replacement(Arc<Mutex<Vec<String>>>);
impl Hooks for Replacement {
fn screen_char_typed_pre(
&self,
_: &dyn Host,
event: &mut ScreenCharTypedPreEvent<'_>,
) -> Flow {
self.0.lock().unwrap().push(format!("old:{}", event.c));
if event.c == '?' {
Flow::Cancel
} else {
Flow::Continue
}
}
fn screen_text_field_char_typed_pre(
&self,
_: &dyn Host,
event: &mut ScreenTextFieldCharTypedPreEvent<'_>,
) -> Flow {
self.0.lock().unwrap().push(format!(
"field:{}:{}:{}:{}",
event.character, event.field.value, event.field.cursor, event.field.highlight
));
if event.character == '!' {
event.field.value = "a\u{1f600}b".into();
event.field.cursor = 2;
event.field.highlight = 2;
Flow::Cancel
} else {
Flow::Continue
}
}
}
fn screen() -> NativeScreenView<'static> {
NativeScreenView {
id: "direct_connect",
menu: None,
title: None,
rect: [0.0; 4],
slots: 0,
mouse: [0.0; 2],
partial_tick: 0.0,
}
}
struct TitleTarget {
title: horizon_client::title::Title,
mods: Vec<Box<dyn Hooks>>,
}
impl FieldTarget for TitleTarget {
fn owns_input(&self) -> bool {
true
}
fn old_hook(&mut self, character: char) -> bool {
let mut canceled = false;
mod_api::dispatch_screen_char_typed_pre(
&HostStub,
&self.mods,
&screen(),
character,
0,
&mut canceled,
);
canceled
}
fn field(&mut self, replacement: Option<&NativeTextField>) -> Option<NativeTextField> {
let mut value = None;
self.title
.edit_focused_text_field(|edit| value = edit_box(edit, replacement));
value
}
fn hook(&mut self, field: &mut NativeTextField, character: char) -> bool {
let mut canceled = false;
mod_api::dispatch_screen_text_field_char_typed_pre(
&HostStub,
&self.mods,
&screen(),
field,
character,
0,
&mut canceled,
);
canceled
}
fn insert(&mut self, character: char) -> Result<()> {
self.title.type_text(&character.to_string());
Ok(())
}
}
#[test]
fn title_pipeline_keeps_old_cancellation_and_observes_each_real_insertion() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut title = horizon_client::title::Title::new(
std::path::PathBuf::from("/nonexistent-native-field-fixture/saves"),
std::path::Path::new("/nonexistent-native-field-fixture"),
);
title.mode =
horizon_client::title::Mode::Multiplayer(horizon_client::multiplayer::Screen::Direct {
address: "a\u{1f600}b".into(),
});
title.edit_focused_text_field(|edit| {
edit.set_cursor(1, false);
edit.highlight = 2;
});
let mut target = TitleTarget {
title,
mods: vec![Box::new(Replacement(events.clone()))],
};
try_field_input(&mut target, "x?!").unwrap().unwrap();
assert_eq!(
*events.lock().unwrap(),
[
"old:x",
"field:x:a\u{1f600}b:1:2",
"old:?",
"old:!",
"field:!:axb:2:2"
]
);
let field = target.field(None).unwrap();
assert_eq!(
(field.value.as_str(), field.cursor, field.highlight),
("a\u{1f600}b", 2, 2)
);
target.title.mode = horizon_client::title::Mode::Main;
events.lock().unwrap().clear();
assert!(try_field_input(&mut target, "x").is_none());
assert!(
events.lock().unwrap().is_empty(),
"unfocused input is left to the original handler"
);
}
#[test]
fn edit_box_replacements_keep_actual_limits_filtering_and_scalar_selection() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
edit.max_length = 4;
edit.set_value("a\u{1f600}b");
let mut field = NativeTextField::from_edit(&edit);
assert_eq!((field.max_length(), field.multiline()), (4, false));
field.value = "a\n§\u{1f600}bcdef".into();
field.cursor = usize::MAX;
field.highlight = 1;
field.max_length = usize::MAX;
field.multiline = true;
field.apply_edit(&mut edit);
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}bc", 4, 1)
);
assert_eq!((edit.max_length, edit.multiline), (4, false));
edit.focused = false;
assert!(edit_box(&mut edit, None).is_none());
}
#[test]
fn multiline_replacements_use_the_actual_edit_box_filter() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
edit.multiline = true;
edit.max_length = 5;
let mut field = NativeTextField::from_edit(&edit);
field.value = "a\nb§\0cdef".into();
field.cursor = usize::MAX;
field.highlight = usize::MAX;
field.apply_edit(&mut edit);
assert_eq!(edit.value, "a\nbcd");
assert_eq!((edit.cursor, edit.highlight), (5, 5));
}
#[test]
fn overlay_ownership_leaves_the_real_chat_and_callbacks_untouched() {
use horizon_client::{
chat::{ChatAbilities, ChatScreen, ChatVisibility, Commands},
font::Font,
hud::Rect,
};
let font = Font::load(
std::path::Path::new("/nonexistent-native-overlay-font"),
&Default::default(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let registries = horizon_client::command::Registries::default();
let commands = Commands {
dispatcher: horizon_client::command::vanilla::game_dispatcher(),
registries: &registries,
};
struct CoveredChat {
chat: ChatScreen,
calls: usize,
}
impl FieldTarget for CoveredChat {
fn owns_input(&self) -> bool {
false
}
fn old_hook(&mut self, _: char) -> bool {
self.calls += 1;
false
}
fn field(&mut self, _: Option<&NativeTextField>) -> Option<NativeTextField> {
self.calls += 1;
Some(NativeTextField::from_text(&self.chat.input))
}
fn hook(&mut self, _: &mut NativeTextField, _: char) -> bool {
self.calls += 1;
false
}
fn insert(&mut self, _: char) -> Result<()> {
self.calls += 1;
panic!("covered chat must not receive insertion")
}
}
let mut target = CoveredChat {
chat: ChatScreen::new(
&Measure(&font),
&commands,
"underlying",
false,
0,
640,
360,
ChatAbilities::new(ChatVisibility::Full),
),
calls: 0,
};
let handled = try_field_input(&mut target, "x");
assert!(
handled.is_none(),
"the overlay retains ownership of the fallback"
);
assert_eq!(target.calls, 0);
assert_eq!(target.chat.value(), "underlying");
let mut dialog_input = EditBox::new(0.0, 0.0, 100.0, 20.0);
if handled.is_none() {
dialog_input.insert_text("x");
}
assert_eq!(dialog_input.value, "x");
assert_eq!(target.chat.value(), "underlying");
}
#[test]
fn measured_text_field_replacements_use_its_real_filter_and_bounds() {
use horizon_client::{font::Font, hud::Rect};
let font = Font::load(
std::path::Path::new("/nonexistent-native-field-font"),
&Default::default(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let measure = Measure(&font);
let mut edit = TextField::new(0, 0, 100, 20);
edit.max_length = 3;
edit.focused = true;
let mut field = NativeTextField::from_text(&edit);
field.value = "§a\n\u{1f600}bc".into();
field.cursor = 1;
field.highlight = usize::MAX;
field.apply_text(&mut edit, &measure);
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}b", 1, 3)
);
}
}
+3 -1
View File
@@ -379,7 +379,9 @@ impl Game<'_> {
Event::Close => {
self.settings.save(&Self::options_path());
let to_game = std::mem::take(&mut self.options_to_game);
self.screen = if std::mem::take(&mut self.options_to_world_options) {
self.screen = if let Some(screen) = self.panel.recipes_settings_return.take() {
screen
} else if std::mem::take(&mut self.options_to_world_options) {
Screen::WorldOptions
} else if self.options_from_title || to_game {
Screen::Playing
+44 -7
View File
@@ -25,7 +25,7 @@ pub(super) struct Raiding {
patrol: PatrolSpawner,
siege: Siege,
/// The raids of the dimension the player is in, and that dimension.
raids: Raids,
pub(super) raids: Raids,
loaded: Option<Dim>,
/// `ServerPlayer.raidOmenPosition` (`raid_omen_position`), read from the
/// player data on first use.
@@ -103,8 +103,10 @@ impl RaidLevel for Lvl<'_, '_> {
fn surface_height(&self, x: i32, z: i32) -> i32 {
self.game.surface_height(x, z)
}
/// `ServerLevel.isPositionEntityTicking`: a loaded chunk whose entities
/// tick (`canPositionTick`) in entity ticking range, not merely loaded.
fn is_position_entity_ticking(&self, p: [i32; 3]) -> bool {
self.has_chunk_at(p)
self.has_chunk_at(p) && self.game.tickets.levels().entity_ticking_at(p[0], p[2])
}
fn ravager_can_spawn(&self, [x, y, z]: [i32; 3]) -> bool {
let g = self.game;
@@ -448,9 +450,6 @@ impl Game<'_> {
pub(super) fn raiding_nearby(&self, at: [i32; 3]) -> Option<i32> {
self.raiding.raids.nearby(at).map(|(id, _)| id)
}
pub(super) fn raid_started(&self, id: i32) -> bool {
self.raiding.raids.get(id).is_some_and(|r| r.started)
}
/// `/raid start`: a raid in the local village at the given omen level.
pub(super) fn raid_command_create(&mut self, at: [i32; 3], omen_level: i32) -> bool {
self.load_raids();
@@ -490,13 +489,14 @@ impl Game<'_> {
};
let members: Vec<&Mob> = self.raid_members(id).collect();
let health: f32 = members.iter().fold(0.0, |a, m| a + m.health);
// Java's string concatenation of the floats: `20.0`, not `20`.
format!(
"Num groups spawned: {} Raid omen level: {} Num mobs: {} Raid health: {} / {}",
r.groups_spawned,
r.omen_level,
members.len(),
health,
r.total_health
horizon_client::snbt::java_float(health),
horizon_client::snbt::java_float(r.total_health)
)
}
/// A raid's members (`groupRaiderMap`): the living raiders it counts.
@@ -1623,3 +1623,40 @@ impl Game<'_> {
#[cfg(test)]
#[path = "raids/tests/bed_loss.rs"]
mod bed_loss_tests;
#[cfg(test)]
mod entity_ticking_tests {
use super::{Lvl, RaidLevel};
use crate::game::native_machines::tests::server_game;
use horizon_client::{chunk::Chunk, ticket_levels::Sources};
use std::{collections::HashSet, sync::Arc};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_wave_spawns_only_where_entities_tick() {
let mut game = server_game();
game.world
.chunks
.insert((6, 0), Arc::new(Chunk::empty(6, 0)));
// A player in chunk (0, 0) with a simulation distance of 5: chunk
// (6, 0) is loaded but its entities do not tick.
game.tickets.update(Some(Sources {
players: vec![(0, 0)],
view_distance: 10,
simulation_distance: 5,
..Sources::default()
}));
let centers = HashSet::new();
let level = Lvl {
game: &game,
difficulty: 2,
centers: &centers,
};
assert!(level.is_position_entity_ticking([8, 64, 8]));
assert!(!level.is_position_entity_ticking([6 * 16 + 8, 64, 8]));
assert!(
!level.is_position_entity_ticking([-40, 64, 8]),
"not loaded"
);
}
}
+95 -1
View File
@@ -65,7 +65,7 @@ struct View {
pub(super) struct RecipeUi {
book: Option<Book>,
tab: Option<Tab>,
search: Option<EditBox>,
pub(super) search: Option<EditBox>,
page: usize,
/// Ticks, for the ghost and button cycling (`RecipeBookComponent.time`).
time: f32,
@@ -1019,6 +1019,100 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(BookType::Crafting, index, all);
}
pub(super) fn transfer_viewer_recipe(&mut self, display_id: i32) {
self.panel.transfer_warning = None;
if !self.mod_recipe_screen() || (self.creative && self.craft_size != 3) {
self.show_transfer_result(3);
return;
}
if self.remote.as_ref().is_some_and(|r| !r.recipe_transfer) {
// Older Horizon and vanilla servers still accept book clicks.
if self.recipe_place(display_id, false).is_err() {
self.show_transfer_result(4);
}
return;
}
self.ensure_book();
let Some(id) = self
.recipe_ui
.book
.as_ref()
.and_then(|book| {
book.entries
.iter()
.find(|entry| entry.display_id == display_id)
})
.map(|entry| entry.id.clone())
else {
self.show_transfer_result(3);
return;
};
if self.remote.is_some() {
let mut request = horizon_protocol::buf::Writer::new();
request.varint(self.remote_menu_id());
request.string(&id);
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(),
data: request.data,
},
));
return;
}
use horizon_client::menu::place::TransferError;
let mut result = Err(TransferError::Unavailable);
self.menu_session(|menu, env, _| {
result = menu.transfer_recipe(env, &id);
Some(result.is_ok())
});
self.show_transfer_result(match result {
Ok(()) => 0,
Err(TransferError::Missing) => 1,
Err(TransferError::NoRoom) => 2,
Err(TransferError::Unavailable) => 3,
});
if result.is_ok() {
self.recipe_book_changed();
}
}
pub(super) fn remote_recipe_transfer(&mut self, data: &[u8]) {
if data.is_empty() {
if let Some(remote) = &mut self.remote {
remote.recipe_transfer = true;
}
return;
}
let mut reader = horizon_protocol::buf::Reader::new(data);
if let (Ok(container), Ok(result)) = (reader.varint(), reader.u8())
&& reader.remaining() == 0
&& container == self.remote_menu_id()
&& self.mod_recipe_screen()
&& self.panel.recipes.is_some()
{
self.show_transfer_result(result);
}
}
fn show_transfer_result(&mut self, result: u8) {
if result == 0 {
self.close_transferred_recipe();
}
self.panel.transfer_warning = match result {
1 => Some(("horizon.recipe_transfer.missing", "Missing Items")),
2 => Some(("horizon.recipe_transfer.no_room", "Inventory full")),
3 => Some((
"horizon.recipe_transfer.unavailable",
"Recipe does not fit this menu",
)),
4 => Some((
"horizon.recipe_transfer.unsupported",
"Server requires a known recipe",
)),
_ => None,
};
}
/// `ServerboundPlaceRecipePacket`: the server places the recipe
/// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost
/// recipe when the items are missing.
+7
View File
@@ -514,6 +514,7 @@ pub(super) struct Remote {
/// seat. None on a server.
pub(super) save_folder: Option<String>,
pub brand: String,
pub(super) recipe_transfer: bool,
/// This connection's configured feature flags, carried through reconfiguration.
pub(super) features: Vec<String>,
/// The server announced it runs mods' quiet commands.
@@ -1105,6 +1106,7 @@ impl Game<'_> {
session,
save_folder: None,
brand: "unknown".into(),
recipe_transfer: false,
features: vec!["minecraft:vanilla".into()],
quiet_commands: false,
mod_net: false,
@@ -3355,6 +3357,9 @@ impl Game<'_> {
Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"),
}
}
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
self.remote_recipe_transfer(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
@@ -6301,6 +6306,8 @@ impl Game<'_> {
self.tick_hand();
if running {
self.tick_particles();
} else if self.clock.still() {
self.settle_rain_fog();
}
self.tick_frame_visuals(running);
// Under `HORIZON_STILL` they wait for the world too: endermen's and blazes' particles
+30 -20
View File
@@ -3,7 +3,9 @@
use super::{Dim, Game};
use horizon_client::assets::Properties;
use horizon_client::camera::NearPlane;
use horizon_client::fog::{FogData, FogInputs, FogType, fog_type, update_rain_fog};
use horizon_client::fog::{
FogData, FogInputs, FogType, fog_type, settle_rain_fog, update_rain_fog,
};
use horizon_client::{
animate_tick::{AnimateContext, animate_tick, campfire_particles, marker_particle_target},
biome::Biomes,
@@ -390,6 +392,31 @@ impl Game<'_> {
self.texture_ticks += 1;
}
}
/// What `AtmosphericFogEnvironment.updateRainFogState` reads at the eye: the rain, the sky
/// light and whether the biome has precipitation.
fn rain_fog_inputs(&self, eye: [f64; 3]) -> (f32, u8, bool) {
let b = [
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
];
let rain = self.view_weather(1.0).0;
let biome = self
.world
.chunks
.get(&(b[0] >> 4, b[2] >> 4))
.map_or(0, |c| c.biome(b[0], b[1], b[2]));
let rains = world_biomes!(self).get(biome).precipitation;
(rain, self.world.light(b[0], b[1], b[2]) >> 4, rains)
}
/// Under `HORIZON_STILL` a frozen client's rain fog sits at its level, as the rain does: it
/// eases only in `tick_particles`, which waits for the world.
pub(super) fn settle_rain_fog(&mut self) {
if let Some(e) = self.eye_position() {
let (rain, sky_light, rains) = self.rain_fog_inputs(e);
settle_rain_fog(&mut self.fog, rain, sky_light, rains);
}
}
pub(super) fn tick_particles(&mut self) {
let eye = self.eye_position();
if let Some(e) = eye {
@@ -407,25 +434,8 @@ impl Game<'_> {
let in_water =
fog_type(&self.world, self.res.facts(), e, &self.near_plane()) == FogType::Water;
self.fog.tick(in_water);
let b = [
e[0].floor() as i32,
e[1].floor() as i32,
e[2].floor() as i32,
];
let rain = self.view_weather(1.0).0;
let biome = self
.world
.chunks
.get(&(b[0] >> 4, b[2] >> 4))
.map_or(0, |c| c.biome(b[0], b[1], b[2]));
let rains = world_biomes!(self).get(biome).precipitation;
update_rain_fog(
&mut self.fog,
rain,
self.world.light(b[0], b[1], b[2]) >> 4,
rains,
1.0,
);
let (rain, sky_light, rains) = self.rain_fog_inputs(e);
update_rain_fog(&mut self.fog, rain, sky_light, rains, 1.0);
}
self.particles.particle_level = self.settings.particles;
if let Some(e) = eye {
@@ -96,6 +96,9 @@ pub(crate) struct PickedPack {
pub draws_flat: bool,
/// It is a shader API 0.2 pack under `HORIZON_HDR_FRAME=float`: the frame is float.
pub linear: bool,
/// No pack runs (none picked, or its mod is off), and HDR is on under `HORIZON_HDR_FRAME=float`:
/// the frame is float where HDR output is offered (docs/design/hdr-linear-light.md, step 3).
pub hdr_float: bool,
}
pub(super) fn picked_pack_shape(settings: &horizon_client::settings::Settings) -> PickedPack {
@@ -118,6 +121,7 @@ pub(super) fn picked_pack_shape(settings: &horizon_client::settings::Settings) -
PickedPack {
draws_flat: active && pack_draws_flat(&path),
linear: active && linear(),
hdr_float: !active && settings.horizon.hdr && super::native_shaders::linear_frame_switch(),
}
}
+2 -2
View File
@@ -23,8 +23,8 @@ pub(super) struct Social {
/// who left is still listed by name.
names: HashMap<u128, String>,
page: Page,
search: EditBox,
scroll: f32,
pub(super) search: EditBox,
pub(super) scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
}
+138 -9
View File
@@ -102,6 +102,10 @@ impl Settings {
pub struct Transfers {
pub settings: Settings,
pub expected: Arc<Mutex<Expectations>>,
/// The server has begun to stop (`/stop` or a stop signal; the game sets it, and a stop is not
/// taken back): no player is expected, imported or placed any more, but the players here can
/// still be exported and released, which is their way out.
pub stopping: bool,
}
impl Transfers {
@@ -110,7 +114,11 @@ impl Transfers {
settings.max_expected,
settings.max_expect,
)));
Self { settings, expected }
Self {
settings,
expected,
stopping: false,
}
}
/// The table, locked. A thread that panicked while holding it left it
@@ -219,9 +227,14 @@ impl Expectations {
/// How many expectations are still alive at `now`, without sweeping the others.
pub fn alive(&self, now: Instant) -> usize {
self.holding(None, now)
}
/// The places the live expectations of players other than `except` hold at `now`.
pub fn holding(&self, except: Option<u128>, now: Instant) -> usize {
self.until
.values()
.filter(|deadline| now < **deadline)
.iter()
.filter(|(uuid, deadline)| Some(**uuid) != except && now < **deadline)
.count()
}
@@ -515,15 +528,28 @@ fn error(message: &str) -> serde_json::Value {
/// The reply to every `player-*` request on a server with transfers off.
pub const OFF: &str = "transfers are off on this server";
/// The reply to `player-expect` when the players here and those expected take every place.
pub const FULL: &str = "the server is full";
/// The reply to `player-expect`, `player-import` and `player-confirm` once the server has begun
/// to stop: a player must not arrive on a server that is about to close.
pub const STOPPING: &str = "the server is stopping";
/// `player-expect <uuid> [seconds]`: the player will log in within that time
/// (`[transfer]`'s `expect_seconds` by default, `max_expect_seconds` at most).
/// The reply says whether an earlier expectation was renewed. The login uses
/// the expectation up when it starts; one that no login used is reported when
/// it runs out.
/// it runs out. Each expectation holds a place: once the places the player limit
/// counts and those of the other expected players reach `max-players`, the
/// request is refused as full, unless the player is an op who bypasses the limit
/// (`PlayerList.canPlayerLogin`). It is refused too once the server has begun to stop.
pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Value {
let Some(transfers) = &hub.transfers else {
return error(OFF);
};
if transfers.stopping {
return error(STOPPING);
}
let mut parts = args.split_whitespace();
let Some(uuid) = parts.next().and_then(parse_uuid) else {
return error("player-expect needs the player's unique id");
@@ -541,7 +567,18 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va
if hub.guests.iter().any(|g| g.uuid == uuid) {
return error("that player is already connected to this server");
}
let shared = &hub.lan.shared;
let (online, max_players) = (shared.online(), shared.max_players());
let bypass = shared
.config
.bypasses_limit
.as_ref()
.is_some_and(|b| b(uuid));
let mut table = transfers.table();
// A renewal holds no second place.
if !bypass && online + table.holding(Some(uuid), now) >= max_players {
return error(FULL);
}
match table.expect(uuid, ttl, now) {
Ok(renewed) => serde_json::json!({
"ok": true,
@@ -572,6 +609,7 @@ pub fn status_request(hub: &Hub, now: Instant) -> serde_json::Value {
"online": online,
"max_players": max_players,
"full": online >= max_players,
"stopping": transfers.stopping,
})
}
@@ -617,7 +655,8 @@ pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value {
/// `player-confirm <uuid> <hand-over>`: the held player whose record of that hand-over was
/// imported, ready to be placed (`Hub::imported_arrival`). The game writes its file, then places
/// it (`Hub::confirm_arrival`). Until then `player-drop` removes it without a save or a word to
/// anyone.
/// anyone. Once the server has begun to stop it is refused, though a retry after the commit is
/// still answered.
pub fn confirm_args(hub: &Hub, args: &str) -> Result<(u128, u128), String> {
let mut parts = args.split_whitespace();
let Some(uuid) = parts.next().and_then(parse_uuid) else {
@@ -630,6 +669,9 @@ pub fn confirm_args(hub: &Hub, args: &str) -> Result<(u128, u128), String> {
return Err("player-confirm takes a unique id and a hand-over id".into());
}
if hub.placed_arrival(uuid, id).is_none() {
if hub.transfers.as_ref().is_some_and(|t| t.stopping) {
return Err(STOPPING.into());
}
hub.imported_arrival(uuid, id)?;
}
Ok((uuid, id))
@@ -873,10 +915,13 @@ mod hub_tests {
use std::time::{Duration, Instant};
fn hub() -> Hub {
let config = NetConfig {
hub_with(NetConfig {
announce: false,
..NetConfig::loopback(0, "ArrivalsTest".into(), 4)
};
})
}
fn hub_with(config: NetConfig) -> Hub {
let mut sync = data::SyncData::empty();
// The entity types the tracker names: the player, as id 0.
sync.builtin.registries.insert(
@@ -1333,7 +1378,7 @@ mod hub_tests {
assert_eq!(
super::status_request(&hub, now),
serde_json::json!({"ok": true, "players": 0, "held": 0, "expected": 0, "frozen": 0,
"online": 0, "max_players": 4, "full": false})
"online": 0, "max_players": 4, "full": false, "stopping": false})
);
let (mut placed, _a) = guest(1, 1, "Placed");
placed.placed = true;
@@ -1360,7 +1405,7 @@ mod hub_tests {
assert_eq!(
super::status_request(&hub, now),
serde_json::json!({"ok": true, "players": 2, "held": 1, "expected": 1, "frozen": 1,
"online": 3, "max_players": 4, "full": false})
"online": 3, "max_players": 4, "full": false, "stopping": false})
);
assert!(hub.lan.shared.try_admit(address, 6, "p").is_none());
let status = super::status_request(&hub, now);
@@ -1449,6 +1494,8 @@ mod hub_tests {
#[test]
fn player_expect_refuses_when_the_table_is_full() {
let mut hub = hub();
// Places for every expectation: it is the table that is full, not the server.
hub.lan.shared.set_max_players(1000);
let now = Instant::now();
for n in 0..super::Settings::default().max_expected as u128 {
let reply = super::expect_request(&mut hub, &format!("{:032x}", 1000 + n), now);
@@ -1461,6 +1508,88 @@ mod hub_tests {
);
}
/// Each expectation holds a place: once the logins and the other expected players take every
/// place, a new expectation is refused as a login would be, though not for an op who bypasses
/// the limit. A renewal holds no second place, and one past its time frees its place.
#[test]
fn player_expect_refuses_a_full_server_and_the_expected_hold_their_places() {
const OP: u128 = 0xF;
let mut hub = hub_with(NetConfig {
announce: false,
bypasses_limit: Some(std::sync::Arc::new(|uuid| uuid == OP)),
..NetConfig::loopback(0, "ArrivalsTest".into(), 2)
});
let now = Instant::now();
let expect = |hub: &mut Hub, uuid: u128, at: Instant| {
super::expect_request(hub, &format!("{uuid:032x}"), at)
};
let address = "127.0.0.1:25565".parse().unwrap();
assert!(hub.lan.shared.try_admit(address, 1, "p").is_none());
assert_eq!(expect(&mut hub, 2, now)["ok"], true);
assert_eq!(expect(&mut hub, 2, now)["renewed"], true);
let full = serde_json::json!({ "error": super::FULL });
assert_eq!(expect(&mut hub, 3, now), full);
assert_eq!(expect(&mut hub, OP, now)["ok"], true);
assert_eq!(table(&hub).len(), 2);
// Player 2's expectation ran out: its place is free again.
let later = now + Duration::from_secs(11);
assert_eq!(expect(&mut hub, 3, later)["ok"], true);
assert_eq!(expect(&mut hub, 4, later), full);
// A login gives a place back when it ends (`Shared::release`).
hub.lan.shared.release(1);
assert_eq!(expect(&mut hub, 4, later)["ok"], true);
}
/// Once the server has begun to stop, no player is expected, imported or placed any more, and
/// `transfer-status` says so; a player here can still be exported and released, which is its
/// way out, and a held one dropped.
#[test]
fn a_server_that_is_stopping_takes_no_one_in_but_lets_its_players_go() {
let stopping = serde_json::json!({ "error": super::STOPPING });
let stop = |hub: &mut Hub| hub.transfers.as_mut().unwrap().stopping = true;
let mut hub = hub();
let now = Instant::now();
stop(&mut hub);
assert_eq!(super::expect_request(&mut hub, UUID, now), stopping);
assert!(table(&hub).is_empty());
assert_eq!(super::status_request(&hub, now)["stopping"], true);
// A player here is exported and released as before.
let (mut mover, _rx) = guest(1, UUID_N, "Mover");
mover.placed = true;
hub.guests.push(mover);
assert!(super::export_record(&mut hub, 0, true, Default::default()).is_ok());
let (mut hub, _seen, _mine, h) = frozen_with_a_watcher();
stop(&mut hub);
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved"));
assert_eq!(reply["released"], "moved");
// The record of a held player cannot be imported...
let (record, mut hub, _seen, _mine) = held_in_a_second_hub();
stop(&mut hub);
let error =
import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap_err();
assert_eq!(error, super::STOPPING);
assert!(hub.guests[1].held && !hub.guests[1].imported);
// ... nor one already imported confirmed, but it can be dropped.
let (mut hub, seen, _mine) = import_without_placing(serde_json::json!({}));
stop(&mut hub);
assert_eq!(confirm(&mut hub, UUID), stopping);
assert!(hub.guests[1].held && !hub.guests[1].placed);
assert_eq!(super::drop_request(&mut hub, UUID)["held"], true);
assert_eq!(ids(&seen), Vec::<i32>::new());
}
/// After the commit, a retry finds the player placed from that hand-over whatever came since:
/// a stop that began meanwhile does not turn the retry into a refusal.
#[test]
fn a_retry_after_the_commit_is_answered_on_a_server_that_is_stopping() {
let (mut hub, _seen, _mine) = import_without_placing(serde_json::json!({}));
let id = hub.guests[1].arrival_handover.unwrap();
let args = format!("{UUID} {}", super::handover_text(id));
assert_eq!(confirm(&mut hub, &args)["placed"], true);
hub.transfers.as_mut().unwrap().stopping = true;
assert_eq!(super::confirm_args(&hub, &args), Ok((UUID_N, id)));
}
/// Dropping a held player disconnects it and says nothing to the world.
#[test]
fn player_drop_removes_a_held_player_without_a_word_to_anyone() {
+42
View File
@@ -1580,6 +1580,48 @@ mod tests {
/// A mod block's block entity goes out with its block's own type and the
/// tag its mod gives; a mod block whose mod made none is left out.
#[test]
fn a_loaded_chunks_update_tag_consumes_only_transient_client_fields() {
let builtin = Builtin::parse(
r#"{"minecraft:block_entity_type":{"entries":{"test:machine":{"protocol_id":49}}}}"#,
)
.unwrap();
let states = ["minecraft:air", "test:machine"].map(|n| (Arc::<str>::from(n), false));
let mut encoder = ChunkEncoder::new(states, &builtin, &[], &[]);
encoder.mod_update_tag = Some(|_, entity| {
let mut tag = entity.nbt.clone();
tag.extend(entity.take_client_update_fields());
Some(tag)
});
let mut chunk = Chunk::empty(0, 0);
let pos = (1, MIN_Y + 2, 3);
chunk.set(pos.0, pos.1, pos.2, 1);
let mut machine = ModBlockEntity::new(crate::containers::ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let effects = Tag::List(vec![Tag::Int(23)]);
machine.set_client_update_fields(Compound::from([("effects".into(), effects.clone())]));
let entities = HashMap::from([(pos, BlockEntity::Mod(Box::new(machine)))]);
let saved = crate::save::encode_block_entity(pos, &entities[&pos], "test:machine");
let first = encoder.encode(&chunk, &entities).chunk.block_entities;
assert_eq!(first[0].tag.get("effects"), Some(&effects));
assert_eq!(first[0].tag.get("progress"), Some(&Tag::Int(7)));
let second = encoder.encode(&chunk, &entities).chunk.block_entities;
assert!(second[0].tag.get("effects").is_none());
assert_eq!(second[0].tag.get("progress"), Some(&Tag::Int(7)));
assert_eq!(
crate::save::encode_block_entity(pos, &entities[&pos], "test:machine"),
saved
);
let BlockEntity::Mod(machine) = &entities[&pos] else {
unreachable!();
};
assert_eq!(machine.revision(), revision);
}
#[test]
fn mod_block_entities_carry_their_blocks_type_and_the_mods_tag() {
let builtin = Builtin::parse(
+108
View File
@@ -1437,6 +1437,12 @@ impl Hub {
// for, to a Horizon client only.
if g.brand == "horizon" {
send_recipe_content(g);
g.send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: super::recipes::TRANSFER_CHANNEL.into(),
data: Vec::new(),
},
));
}
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
self.send_permission_level(i);
@@ -5340,6 +5346,108 @@ mod tests {
(hub, rx)
}
#[test]
fn transient_client_update_fields_reach_all_viewers_once_and_wait_without_viewers() {
use crate::containers::{BlockEntity, ModBlockEntity, ModBlockEntityFields};
use horizon_world::nbt::Compound;
use std::sync::atomic::AtomicUsize;
static CONSTRUCTIONS: AtomicUsize = AtomicUsize::new(0);
CONSTRUCTIONS.store(0, Ordering::Relaxed);
let (mut hub, first) = hub_with_a_guest();
let builtin = horizon_protocol::registry::Builtin::parse(
r#"{"minecraft:block_entity_type":{"entries":{"test:machine":{"protocol_id":49}}}}"#,
)
.unwrap();
let states = ["minecraft:air", "test:machine"].map(|n| (Arc::<str>::from(n), false));
hub.encoder = ChunkEncoder::new(states, &builtin, &[], &[]);
hub.encoder.mod_update_tag = Some(|_, entity| {
CONSTRUCTIONS.fetch_add(1, Ordering::Relaxed);
let mut tag = entity.nbt.clone();
tag.extend(entity.take_client_update_fields());
Some(tag)
});
let (pos, retained) = (
(1, 64, 3),
Compound::from([("progress".into(), Tag::Int(7))]),
);
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut chunk = Chunk::empty(0, 0);
chunk.set(pos.0, pos.1, pos.2, 1);
world.chunks.insert((0, 0), Arc::new(chunk));
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: retained.clone(),
..Default::default()
});
let revision = machine.revision();
let effects = Tag::List(vec![Tag::Int(23)]);
machine.set_client_update_fields(Compound::from([("effects".into(), effects.clone())]));
world
.block_entities
.insert(pos, BlockEntity::Mod(Box::new(machine)));
world.block_log = Some(vec![pos]);
// No recipient has the chunk yet: an attempted broadcast must not
// construct a tag, or consume an effect before a client can see it.
hub.broadcast_block_changes(&mut world);
assert_eq!(CONSTRUCTIONS.load(Ordering::Relaxed), 0);
assert!(first.try_recv().is_err());
hub.guests[0].sent_chunks.insert((0, 0));
let (tx, second) = std::sync::mpsc::channel();
let mut other = Guest::new(
crate::lan::Joined {
conn: 2,
uuid: 2,
name: "Ada".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: crate::lan::PlayerSender::new(tx, Arc::default()),
latency: Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25566".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 64.0, 0.5],
8,
);
other.placed = true;
other.dimension = "minecraft:overworld".into();
other.sent_chunks.insert((0, 0));
hub.guests.push(other);
let tags = |rx: &std::sync::mpsc::Receiver<crate::lan::Out>| {
let mut tags = Vec::new();
while let Ok(crate::lan::Out::Packet(body)) = rx.try_recv() {
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::BlockEntityData(packet)) = C::decode(id, r.rest()) {
assert_eq!(packet.pos, [pos.0, pos.1, pos.2]);
tags.push(packet.tag);
}
}
tags
};
world.block_log.as_mut().unwrap().extend([pos, pos]);
hub.broadcast_block_changes(&mut world);
let mut expected = retained.clone();
expected.insert("effects".into(), effects);
for recipient in [&first, &second] {
assert_eq!(tags(recipient), [Tag::Compound(expected.clone())]);
}
assert_eq!(CONSTRUCTIONS.load(Ordering::Relaxed), 1);
world.block_log.as_mut().unwrap().push(pos);
hub.broadcast_block_changes(&mut world);
for recipient in [&first, &second] {
assert_eq!(tags(recipient), [Tag::Compound(retained.clone())]);
}
assert_eq!(CONSTRUCTIONS.load(Ordering::Relaxed), 2);
let BlockEntity::Mod(machine) = &world.block_entities[&pos] else {
unreachable!();
};
assert_eq!(machine.revision(), revision);
assert_eq!(machine.nbt, retained);
}
/// `store_mod_commands` keeps a new mod tree without sending it (the
/// game sends it after the mods filtered it: `native_refresh_commands`),
/// and `set_mod_commands` still sends a changed tree, and only a changed one.
+10 -2
View File
@@ -460,14 +460,22 @@ impl Hub {
/// `PlayerList.broadcastChatMessage` for a non-player command source:
/// an unsigned message with the source's display name, visible as player chat.
pub fn broadcast_source_chat(&self, chat_type: &str, name: Tag, message: Tag) {
/// `except` is a connection that gets the line another way (with its
/// command's captured output).
pub fn broadcast_source_chat(
&self,
chat_type: &str,
name: Tag,
message: Tag,
except: Option<u32>,
) {
let bound = play::ChatTypeBound {
chat_type: self.chat_type_id(chat_type),
name,
target: None,
};
for (k, guest) in self.guests.iter().enumerate() {
if guest.placed {
if guest.placed && Some(guest.conn) != except {
self.send_disguised(k, message.clone(), bound.clone());
}
}
+9 -6
View File
@@ -320,11 +320,10 @@ fn check_values(level: &Level) -> Result<(), String> {
if !(0.0..=40.0).contains(&level.food_exhaustion) {
return Err("the record's food exhaustion is past what a player can have".into());
}
if level
.active_effects
.iter()
.any(|(_, _, amplifier, ..)| !(0..=255).contains(amplifier))
{
if level.active_effects.iter().any(|e| {
std::iter::successors(Some(e), |e| e.hidden.as_deref())
.any(|e| !(0..=255).contains(&e.amplifier))
}) {
return Err("the record holds an effect no player can have".into());
}
Ok(())
@@ -485,8 +484,12 @@ impl Kept {
/// statistics (`Imported::advancements`, `Imported::stats`) and its attachments.
///
/// Nothing changes when it refuses: the record is read and checked first, and the
/// guest is touched only once everything is known to be well.
/// guest is touched only once everything is known to be well. It refuses everything once
/// the server has begun to stop.
pub fn import_player(hub: &mut Hub, uuid: u128, request: &Value) -> Result<Imported, String> {
if hub.transfers.as_ref().is_some_and(|t| t.stopping) {
return Err(super::arrivals::STOPPING.into());
}
let request = read_request(request)?;
let (tag, handover) = read_compound(request.record, uuid)?;
let Some(i) = hub
+4
View File
@@ -66,6 +66,10 @@ fn recipes(dir: &Path, namespace: &str, out: &mut BTreeMap<String, Value>) {
}
}
/// Horizon's optional recipe viewer transfer. An empty clientbound payload
/// advertises support. Requests carry a menu id and resource location.
pub const TRANSFER_CHANNEL: &str = "horizon:recipe_transfer";
/// The recipes by id in vanilla's registration order (path, then namespace),
/// the order `RecipeManager` numbers groups and lists the stonecutter's
/// recipes in, and so the order the host's own tables keep.
+56 -3
View File
@@ -298,6 +298,14 @@ pub enum PostPlace {
Ghost,
}
/// Why a viewer transfer could not move a complete set of ingredients.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransferError {
Missing,
NoRoom,
Unavailable,
}
/// The menu slots a placement works on.
struct Grid {
width: usize,
@@ -384,7 +392,8 @@ impl Menu {
}
let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect();
let post = if stacked.try_pick(&ingredients, 1, None) {
self.place(env, id, &grid, &placement, &mut stacked, use_max);
let matches = self.recipe_in_grid(env, id);
self.place(env, &grid, &placement, &mut stacked, use_max, matches);
PostPlace::Nothing
} else {
self.clear_grid(env, &grid);
@@ -395,6 +404,51 @@ impl Menu {
post
}
/// Move one set of ingredients from the player's real inventory and grid.
/// The viewer leaves both alone on failure, including in creative mode.
pub fn transfer_recipe(&mut self, env: &Env, id: &str) -> Result<(), TransferError> {
if !matches!(self.kind, Kind::Inventory | Kind::Crafting) || self.carried.is_some() {
return Err(TransferError::Unavailable);
}
let grid = self.place_grid().ok_or(TransferError::Unavailable)?;
let placement = env
.recipes
.placement(id)
.ok_or(TransferError::Unavailable)?;
if placement.impossible()
|| placement.ingredients.len() > grid.inputs.len()
|| placement
.shaped
.is_some_and(|(w, h)| w > grid.width || h > grid.height)
{
return Err(TransferError::Unavailable);
}
let mut stacked = self.stacked_contents(env);
let ingredients: Vec<_> = placement.ingredients.iter().collect();
if !stacked.try_pick(&ingredients, 1, None) {
return Err(TransferError::Missing);
}
// The vanilla placement can return leftover grid items as drops.
// A viewer transfer must keep the original slots if it cannot move
// every input without dropping anything.
let previous = self.containers.clone();
let (drops, dropped) = (self.drops.len(), self.dropped.len());
self.place(env, &grid, &placement, &mut stacked, false, false);
let no_room = self.dropped.len() != dropped;
if no_room || !self.recipe_in_grid(env, id) {
self.containers = previous;
self.drops.truncate(drops);
self.dropped.truncate(dropped);
return Err(if no_room {
TransferError::NoRoom
} else {
TransferError::Unavailable
});
}
self.recompute(env);
Ok(())
}
/// `recipeMatches`: the recipe already fills the grid.
fn recipe_in_grid(&self, env: &Env, id: &str) -> bool {
match self.kind {
@@ -417,14 +471,13 @@ impl Menu {
fn place(
&mut self,
env: &Env,
id: &str,
grid: &Grid,
placement: &Placement,
stacked: &mut Stacked,
use_max: bool,
matches: bool,
) {
let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect();
let matches = self.recipe_in_grid(env, id);
let biggest = stacked.try_pick_all(&ingredients, i32::MAX, None);
if matches {
for &s in &grid.inputs {
+30
View File
@@ -2487,8 +2487,33 @@ pub struct Seen {
pub hurt_recently: bool,
/// `Hoglin.canBeHunted`: an adult hoglin without `CannotBeHunted`.
pub huntable: bool,
/// A happy ghast's `isOnStillTimeout` (a player on its back holds it).
pub still: bool,
/// `Entity.isVehicle`: something rides it.
pub carrying: bool,
}
impl Seen {
/// Whether `canBeCollidedWith` can ever hold for this kind: the slots
/// [`MobWorld::others_solid`] lists.
pub fn may_block_mobs(&self) -> bool {
matches!(self.species, Species::Shulker | Species::HappyGhast)
}
/// `canBeCollidedWith(mover)` for a mob of species `mover` moving into
/// it (`EntityGetter.getEntityCollisions`): a living shulker
/// (`Shulker`), or an adult happy ghast held still, or carrying someone
/// when the mover is a happy ghast too (`HappyGhast`). Every other mob
/// keeps `Entity`'s false.
pub fn blocks_mob(&self, mover: Species) -> bool {
match self.species {
Species::Shulker => self.alive,
Species::HappyGhast => {
self.alive
&& !self.baby
&& (self.still || (self.carrying && mover == Species::HappyGhast))
}
_ => false,
}
}
/// `LivingEntity.hasEffect`, by id without namespace.
pub fn has_effect(&self, effect: &str) -> bool {
self.effects.contains(&effect)
@@ -2678,6 +2703,8 @@ impl Mob {
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
is_vehicle: equine::is_vehicle(m),
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
carrying: m.rider.is_some() || m.extra.rider.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
.extra
@@ -3055,6 +3082,9 @@ pub struct MobWorld<'a> {
/// keeps them (the mob ticks: a slot keeps its mob, and its species, all
/// tick). Each zombie looking for a village read every slot.
pub others_villagers: Option<&'a [u32]>,
/// The slots of `others` that may stop a moving mob
/// ([`Seen::may_block_mobs`]), when the caller keeps them; None: any.
pub others_solid: Option<&'a [u32]>,
pub daylight: bool,
/// The `gameplay/monsters_burn` attribute: the overworld day timeline's
/// step track, false in every other dimension.
+164 -2
View File
@@ -653,6 +653,40 @@ impl Mob {
}
/// Entity.move with block collisions and step-up; updates collision flags
/// and fall distance (Entity.checkFallDamage).
/// `EntityGetter.getEntityCollisions(this, box.expandTowards(movement))`:
/// the boxes of the mobs whose `canBeCollidedWith(this)` holds (a living
/// shulker, a happy ghast held still), but not this mob's vehicle or a
/// fellow passenger (`Entity.canCollideWith`).
fn entity_collisions(&self, w: &MobWorld, movement: Vec3) -> Vec<Aabb> {
let area = self.bounds().expand_towards(movement);
if area.average_size() < 1.0e-7 {
return Vec::new();
}
let area = area.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
let mover = self.species();
let blocks = |s: &&super::Seen| {
s.id != self.id
&& s.blocks_mob(mover)
&& s.bounds.intersects(area)
&& !self.passenger_of_same_vehicle(s.id, s.vehicle)
};
match w.others_solid {
Some(slots) => slots
.iter()
.map(|&i| &w.others[i as usize])
.filter(blocks)
.map(|s| s.bounds)
.collect(),
None => w.others.iter().filter(blocks).map(|s| s.bounds).collect(),
}
}
/// `Entity.isPassengerOfSameVehicle` toward the entity `id` riding
/// `vehicle`: one rides the other, or both ride the same one.
fn passenger_of_same_vehicle(&self, id: u32, vehicle: Option<u32>) -> bool {
self.extra.mount == Some(id)
|| vehicle == Some(self.id)
|| (self.extra.mount.is_some() && vehicle == self.extra.mount)
}
pub fn do_move(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
let mut wanted = self.velocity;
// Entity.move: a block the mob is stuck in (sweet berry bush) scales
@@ -672,10 +706,19 @@ impl Mob {
..Default::default()
}),
};
// Entity.collide: the world border's walls stop the mob too.
// Entity.collide: the world border's walls stop the mob too, and so
// do the entities it may not pass through.
let border = w.border.map(|b| (b, [self.position.x, self.position.z]));
let entities = self.entity_collisions(w, wanted);
let moved = scene
.resolve_in_border(self.bounds(), wanted, step, self.on_ground, border)
.resolve_in_border_with(
self.bounds(),
wanted,
step,
self.on_ground,
border,
&entities,
)
.unwrap_or(Vec3::ZERO);
let length = moved.x * moved.x + moved.y * moved.y + moved.z * moved.z;
let wanted_length = wanted.x * wanted.x + wanted.y * wanted.y + wanted.z * wanted.z;
@@ -2132,6 +2175,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -2249,6 +2293,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -2307,6 +2352,123 @@ mod tests {
);
}
/// `Entity.collide` takes `getEntityCollisions` too: a zombie dropping
/// onto a living shulker or a happy ghast held still (a player on its
/// back) lands on it; a dead shulker, a happy ghast that is not held, or
/// a ghastling lets it through (`canBeCollidedWith`).
#[test]
fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let data = super::super::MobData::load(&root, &facts);
let types = super::super::MobTypes::load(&root).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let items = crate::items::Items::load(
&root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = std::sync::Arc::new(crate::collisions::load(&root, registry).unwrap());
let suffocation =
crate::mobs::suffocation::bind(collision.clone(), &motion.causes_suffocation).unwrap();
let lid_openers = std::collections::HashMap::new();
let air = registry.default_state("minecraft:air").unwrap();
let mut world = crate::world::World::new(air, 0);
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
let mob = |id: u32, kind: &str, at: Vec3| {
Mob::new(id, std::sync::Arc::new(types.types[kind].clone()), at, 0.0)
};
// Where a zombie starting 3 blocks above `below`'s top ends after 20
// falling moves, with `below` in the mobs it sees.
let landing = |below: &Mob| -> f64 {
let others = [below.seen(None)];
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
suffocation: &suffocation,
data: &data,
player: None,
controlling_player_movement: None,
nearest_player: None,
others: &others,
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 6000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
universal_anger: false,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: super::super::spawning::LocalDifficulty::new(2, 6000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
border: None,
};
let top = below.bounds().max_position().y;
let mut zombie = mob(1, "minecraft:zombie", Vec3::new(8.5, top + 3.0, 8.5));
for _ in 0..20 {
zombie.velocity = Vec3::new(0.0, -0.5, 0.0);
zombie.do_move(&w, &mut Vec::new());
}
zombie.position.y - top
};
let mut shulker = mob(2, "minecraft:shulker", Vec3::new(8.5, 64.0, 8.5));
assert_eq!(landing(&shulker), 0.0, "a living shulker holds it up");
shulker.health = 0.0;
assert!(landing(&shulker) < -5.0, "a dead shulker does not");
let mut ghast = mob(3, "minecraft:happy_ghast", Vec3::new(8.5, 64.0, 8.5));
assert!(
landing(&ghast) < -5.0,
"a happy ghast left alone is not solid"
);
ghast.extra.counter = 10;
assert_eq!(
landing(&ghast),
0.0,
"held still, its back holds the zombie"
);
let mut ghastling = mob(4, "minecraft:happy_ghast", Vec3::new(8.5, 64.0, 8.5));
super::super::finalize::set_baby(&mut ghastling, true);
ghastling.extra.counter = 10;
assert!(landing(&ghastling) < -5.0, "a ghastling never is");
}
/// `LivingEntity.aiStep` calls `travel` and only then
/// `applyEffectsFromBlocks`, so the lift of a bubble column is added after
/// the water's drag and is not scaled by it. A pig 1.0 high in a column of
+1
View File
@@ -512,6 +512,7 @@ pub(crate) fn with_resource_test_world(
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
@@ -236,6 +236,7 @@ mod tests {
others_grid: None,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
+1
View File
@@ -30,6 +30,7 @@ pub use crate::game::native_client_services::{
NativeClientEntity, NativeClientHead, NativeClientItem, NativeClientProfile, NativeClientState,
NativeScreenKind, NativeWorldId,
};
pub use crate::game::native_text_field::NativeTextField;
pub use crate::game::{
ItemEnergyBuffer, NativeBiomeId, NativeBlockEdit, NativeBlockReadError, NativeBlockRun,
NativeClientBiomes, NativeClientChunks, NativeClock, NativeColumnBlocks, NativeComponentError,
+176 -61
View File
@@ -4087,29 +4087,7 @@ impl Level {
self.active_effects = match p.get("active_effects") {
Some(Tag::List(list)) => list
.iter()
.filter_map(|e| {
let id = text(e.get("id"))?;
let get = |k: &str| number(e.get(k));
// `MobEffectInstance.Details` stores `amplifier` as
// `ExtraCodecs.UNSIGNED_BYTE`: the NBT byte is signed, and
// `UnsignedBytes.toInt` maps it back to 0..=255. A plain
// sign-extend turns 255 (byte -1) into -1, which
// `EffectInstance::new` then clamps to the lowest level.
let amplifier = match e.get("amplifier") {
Some(Tag::Byte(v)) => i32::from(*v as u8),
other => number(other).unwrap_or(0) as i32,
};
let particles = get("show_particles").is_none_or(|v| v != 0);
Some((
id,
get("duration").unwrap_or(0) as i32,
amplifier,
get("ambient").unwrap_or(0) != 0,
particles,
// Details.CODEC: `show_icon` defaults to `show_particles`.
get("show_icon").map_or(particles, |v| v != 0),
))
})
.filter_map(|e| Some(read_effect_details(e, &text(e.get("id"))?)))
.collect(),
_ => Vec::new(),
};
@@ -4223,28 +4201,9 @@ impl Level {
Tag::List(
self.active_effects
.iter()
.map(|(id, duration, amplifier, ambient, particles, icon)| {
let mut effect =
vec![("id", string(id)), ("duration", Tag::Int(*duration))];
// MobEffectInstance.Details: amplifier defaults to zero.
if *amplifier != 0 {
// Same unsigned byte the codec writes:
// `Mth.clamp` to 0..=255, then `byteValue`.
effect.push((
"amplifier",
Tag::Byte((*amplifier).clamp(0, 255) as u8 as i8),
));
}
// optionalFieldOf("ambient", false) and
// ("show_particles", true): defaults are not written;
// show_icon is always written.
if *ambient {
effect.push(("ambient", Tag::Byte(1)));
}
if !*particles {
effect.push(("show_particles", Tag::Byte(0)));
}
effect.push(("show_icon", Tag::Byte(i8::from(*icon))));
.map(|e| {
let mut effect = vec![("id", string(&e.id))];
effect.extend(effect_details(e));
Tag::compound(effect)
})
.collect(),
@@ -5206,6 +5165,59 @@ fn record_is_old(root: &NamedTag) -> bool {
};
before_blending || root.tag.get("blending_data").is_some()
}
/// `MobEffectInstance.Details` read under its effect's `id`, the
/// `hidden_effect` chain included.
fn read_effect_details(e: &Tag, id: &str) -> crate::effects::SavedEffect {
let get = |k: &str| number(e.get(k));
// `Details` stores `amplifier` as `ExtraCodecs.UNSIGNED_BYTE`: the NBT
// byte is signed, and `UnsignedBytes.toInt` maps it back to 0..=255. A
// plain sign-extend turns 255 (byte -1) into -1, which
// `EffectInstance::new` then clamps to the lowest level.
let amplifier = match e.get("amplifier") {
Some(Tag::Byte(v)) => i32::from(*v as u8),
other => number(other).unwrap_or(0) as i32,
};
let particles = get("show_particles").is_none_or(|v| v != 0);
crate::effects::SavedEffect {
id: id.to_owned(),
duration: get("duration").unwrap_or(0) as i32,
amplifier,
ambient: get("ambient").unwrap_or(0) != 0,
particles,
// Details.CODEC: `show_icon` defaults to `show_particles`.
icon: get("show_icon").map_or(particles, |v| v != 0),
hidden: e
.get("hidden_effect")
.map(|h| Box::new(read_effect_details(h, id))),
}
}
/// `MobEffectInstance.Details.MAP_CODEC`'s fields, `hidden_effect` nested.
fn effect_details(e: &crate::effects::SavedEffect) -> Vec<(&'static str, Tag)> {
let mut details = vec![("duration", Tag::Int(e.duration))];
// Amplifier defaults to zero; when written it is the codec's unsigned
// byte: `Mth.clamp` to 0..=255, then `byteValue`.
if e.amplifier != 0 {
details.push((
"amplifier",
Tag::Byte(e.amplifier.clamp(0, 255) as u8 as i8),
));
}
// optionalFieldOf("ambient", false) and ("show_particles", true):
// defaults are not written; show_icon is always written.
if e.ambient {
details.push(("ambient", Tag::Byte(1)));
}
if !e.particles {
details.push(("show_particles", Tag::Byte(0)));
}
details.push(("show_icon", Tag::Byte(i8::from(e.icon))));
if let Some(hidden) = &e.hidden {
details.push(("hidden_effect", Tag::compound(effect_details(hidden))));
}
details
}
#[cfg(test)]
mod tests {
use crate::mobs::spawner::SpawnDataExt;
@@ -6519,6 +6531,36 @@ mod tests {
};
assert_eq!(again.update_tag(), spawner.update_tag());
}
#[test]
fn transient_client_update_fields_are_not_saved_or_restored() {
use crate::containers::{BlockEntity, ModBlockEntity, ModBlockEntityFields};
let saved = horizon_world::nbt::parse_snbt(
r#"{id:"test:machine",x:5,y:-60,z:12,progress:7,unknown:{value:2b}}"#,
)
.unwrap()
.0;
let (pos, mut entity) = decode_block_entity(&saved).unwrap();
let BlockEntity::Mod(machine) = &mut entity else {
panic!("not a mod's");
};
let revision = machine.revision();
let pending = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(pending.clone());
assert_eq!(encode_block_entity(pos, &entity, "test:machine"), saved);
let (_, cold) = decode_block_entity(&saved).unwrap();
let BlockEntity::Mod(cold) = cold else {
panic!("not a mod's");
};
assert!(cold.take_client_update_fields().is_empty());
let BlockEntity::Mod(machine) = &entity else {
unreachable!();
};
assert_eq!(machine.take_client_update_fields(), pending);
assert_eq!(machine.revision(), revision);
let fresh = ModBlockEntity::new(ModBlockEntityFields::default());
assert!(fresh.take_client_update_fields().is_empty());
}
#[test]
fn mod_block_entity_round_trips() {
let saved = horizon_world::nbt::parse_snbt(
@@ -7156,6 +7198,76 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
fn eff(
id: &str,
duration: i32,
amplifier: i32,
ambient: bool,
particles: bool,
icon: bool,
) -> crate::effects::SavedEffect {
crate::effects::SavedEffect {
ambient,
particles,
icon,
..crate::effects::SavedEffect::new(id, duration, amplifier)
}
}
#[test]
fn a_player_keeps_the_effects_hidden_under_a_stronger_one() {
// MobEffectInstance.Details.CODEC nests `hidden_effect` (no id of its
// own): Poison II for 6 s over Poison I for 30 s over Poison I
// forever, as `/effect give` leaves them.
let tag = Tag::compound([(
"active_effects",
Tag::List(vec![Tag::compound([
("id", string("minecraft:poison")),
("duration", Tag::Int(120)),
("amplifier", Tag::Byte(1)),
("show_icon", Tag::Byte(1)),
(
"hidden_effect",
Tag::compound([
("duration", Tag::Int(600)),
("ambient", Tag::Byte(1)),
("show_icon", Tag::Byte(1)),
(
"hidden_effect",
Tag::compound([
("duration", Tag::Int(-1)),
("show_particles", Tag::Byte(0)),
("show_icon", Tag::Byte(0)),
]),
),
]),
),
])]),
)]);
let mut level = Level::default();
level.read_player(&tag);
let expected = crate::effects::SavedEffect {
hidden: Some(Box::new(crate::effects::SavedEffect {
hidden: Some(Box::new(eff(
"minecraft:poison",
-1,
0,
false,
false,
false,
))),
..eff("minecraft:poison", 600, 0, true, true, true)
})),
..eff("minecraft:poison", 120, 1, false, true, true)
};
assert_eq!(level.active_effects, [expected]);
// Written back, the chain is the same compound.
assert_eq!(
level.player_tag().get("active_effects"),
tag.get("active_effects")
);
}
#[test]
fn a_saved_effect_without_show_icon_takes_show_particles() {
// MobEffectInstance.Details.CODEC: `show_icon` defaults to
@@ -7177,7 +7289,11 @@ mod tests {
effect(vec![("show_icon", Tag::Byte(0))]),
]),
)]));
let flags: Vec<_> = level.active_effects.iter().map(|e| (e.4, e.5)).collect();
let flags: Vec<_> = level
.active_effects
.iter()
.map(|e| (e.particles, e.icon))
.collect();
assert_eq!(flags, [(false, false), (true, true), (true, false)]);
}
@@ -7186,7 +7302,7 @@ mod tests {
// A player loaded with an effect that has since run out: the loaded
// compound still holds it, but the save writes only what is active.
let mut level = Level {
active_effects: vec![("minecraft:speed".into(), 200, 1, false, true, true)],
active_effects: vec![eff("minecraft:speed", 200, 1, false, true, true)],
..Level::default()
};
let loaded = level.player_tag();
@@ -7199,10 +7315,10 @@ mod tests {
#[test]
fn effect_amplifier_default_is_omitted_from_saved_players() {
let effects = vec![
("minecraft:luck".into(), -1, 0, false, false, false),
("minecraft:speed".into(), 200, 1, true, false, true),
("minecraft:regeneration".into(), 40, 128, false, true, false),
("minecraft:strength".into(), -1, 255, false, true, true),
eff("minecraft:luck", -1, 0, false, false, false),
eff("minecraft:speed", 200, 1, true, false, true),
eff("minecraft:regeneration", 40, 128, false, true, false),
eff("minecraft:strength", -1, 255, false, true, true),
];
for raw in [
None,
@@ -7239,17 +7355,16 @@ mod tests {
Some(Tag::Byte(-1))
],
);
for (entry, (id, duration, _, ambient, particles, icon)) in entries.iter().zip(&effects)
{
assert_eq!(entry.get("id"), Some(&string(id)));
assert_eq!(entry.get("duration"), Some(&Tag::Int(*duration)));
for (entry, e) in entries.iter().zip(&effects) {
assert_eq!(entry.get("id"), Some(&string(&e.id)));
assert_eq!(entry.get("duration"), Some(&Tag::Int(e.duration)));
// MobEffectInstance.Details.MAP_CODEC leaves out ambient false
// and show_particles true.
let ambient_tag = ambient.then_some(Tag::Byte(1));
let ambient_tag = e.ambient.then_some(Tag::Byte(1));
assert_eq!(entry.get("ambient"), ambient_tag.as_ref());
let particles_tag = (!*particles).then_some(Tag::Byte(0));
let particles_tag = (!e.particles).then_some(Tag::Byte(0));
assert_eq!(entry.get("show_particles"), particles_tag.as_ref());
assert_eq!(entry.get("show_icon"), Some(&Tag::Byte(i8::from(*icon))));
assert_eq!(entry.get("show_icon"), Some(&Tag::Byte(i8::from(e.icon))));
}
assert_eq!(
level.active_effects, effects,
@@ -7268,9 +7383,9 @@ mod tests {
// it with `ExtraCodecs.UNSIGNED_BYTE`: `255.byteValue()` is -1 and
// `128.byteValue()` is -128; `UnsignedBytes.toInt` restores both.
let effects = vec![
("minecraft:strength".into(), -1, 255, false, true, true),
("minecraft:regeneration".into(), -1, 128, false, true, true),
("minecraft:speed".into(), 40, 1, false, true, true),
eff("minecraft:strength", -1, 255, false, true, true),
eff("minecraft:regeneration", -1, 128, false, true, true),
eff("minecraft:speed", 40, 1, false, true, true),
];
let level = Level {
active_effects: effects.clone(),
+8
View File
@@ -3964,6 +3964,14 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
true
}
/// The ordinary focused caret field for native character transactions.
/// Preparing screens and game-rule editors retain their specialized validators.
pub fn edit_focused_text_field(&mut self, f: impl FnOnce(&mut crate::ui::EditBox)) -> bool {
if self.preparing || self.rules.get_mut().is_some() {
return false;
}
self.edit_field(f)
}
/// EditBox.charTyped / insertText at the cursor.
pub fn type_text(&mut self, text: &str) {
// `GenericMessageScreen`: no field takes input during the read.
+26
View File
@@ -2598,3 +2598,29 @@ fn the_multiplayer_screens_have_their_own_ids() {
"disconnected"
);
}
#[test]
fn native_field_visitor_excludes_preparing_and_game_rule_editors() {
let mut f = Fixture::new();
f.title.mode = Mode::Multiplayer(crate::multiplayer::Screen::Direct {
address: "safe".into(),
});
f.title.preparing = true;
assert!(
!f.title
.edit_focused_text_field(|_| panic!("preparing has no editable native field"))
);
f.title.preparing = false;
*f.title.rules.get_mut() = Some(RulesScreen::for_creation(
&crate::gamerules::GameRules::default(),
));
assert!(
!f.title
.edit_focused_text_field(|_| panic!("rule editors retain their own validation"))
);
*f.title.rules.get_mut() = None;
assert!(
f.title
.edit_focused_text_field(|field| assert_eq!(field.value, "safe"))
);
}
+173 -3
View File
@@ -170,6 +170,8 @@ struct Server {
world: World,
next_entity: u32,
placed: Vec<(u32, [i32; 3], u32, i32)>,
/// Every packet the hub handed to the game, in order.
packets: Vec<S>,
}
impl Server {
fn tick(&mut self, entities: &[EntityState]) {
@@ -193,6 +195,7 @@ impl Server {
match event {
HostEvent::Joined(conn) => self.hub.place_new_player(conn, &mut ctx),
HostEvent::Packet(conn, packet) => {
self.packets.push((*packet).clone());
if let S::UseItemOn(p) = *packet {
let at = [p.pos[0], p.pos[1] + 1, p.pos[2]];
ctx.world
@@ -260,6 +263,7 @@ fn server_sync(sync: Arc<SyncData>, net: NetConfig) -> Server {
world: flat_world(),
next_entity: 100,
placed: Vec::new(),
packets: Vec::new(),
}
}
@@ -1759,8 +1763,9 @@ fn a_boat_floating_too_long_kicks_its_driver() {
}
/// A client that numbers its packets as `version` does, from the protocol adapter's own tables: for 26.2 its
/// handshake states 776 and every id it sends and reads is 26.2's. The payloads stay the pinned ones: the
/// packets whose layout changed are a later step of `older-clients`.
/// handshake states 776 and every id it sends and reads is 26.2's. [`Self::send`]'s payloads are the pinned
/// ones; [`Self::send_raw`] sends a packet as the version lays it out. The clientbound world packets arrive
/// in 26.2's layout.
struct Numbered {
c: Connection,
version: ServerVersion,
@@ -1775,6 +1780,13 @@ impl Numbered {
.ok_or("the client's version has no such packet")?;
self.c.send(&body).map_err(|e| e.to_string())
}
/// A packet by the version's own `id`, its payload in the version's layout.
fn send_raw(&mut self, id: i32, payload: &[u8]) -> Result<(), String> {
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
w.bytes(payload);
self.c.send(&w.data).map_err(|e| e.to_string())
}
/// The next packet with its pinned id (the version's own id read off the wire).
fn recv(&mut self) -> Result<(i32, Vec<u8>), String> {
let (id, payload) = self.c.recv().map_err(|e| e.to_string())?;
@@ -1975,6 +1987,28 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() {
if id == horizon_protocol::ids::play::clientbound::COMMANDS {
continue;
}
// World packets come in 26.2's own layout, which the pinned readers don't read (the adapter's
// unit tests read them back with translate's 26.2 readers).
{
use horizon_protocol::ids::play::clientbound as cb;
match id {
cb::LEVEL_CHUNK_WITH_LIGHT => {
chunks += 1;
continue;
}
cb::LIGHT_UPDATE
| cb::LEVEL_PARTICLES
| cb::EXPLODE
| cb::MOVE_ENTITY_POS
| cb::MOVE_ENTITY_POS_ROT
| cb::MOVE_ENTITY_ROT
| cb::ENTITY_POSITION_SYNC
| cb::ANIMATE
// Its tag displays are 26.2's bare tag ids.
| cb::RECIPE_BOOK_ADD => continue,
_ => {}
}
}
match C::decode(id, &payload).map_err(|e| format!("packet {id}: {e}"))? {
C::Login(_) => login = true,
C::PlayerPosition(p) => bot.send(
@@ -1988,7 +2022,6 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() {
})
.encode(),
)?,
C::LevelChunkWithLight(_) => chunks += 1,
C::ChunkBatchFinished(_) => {
bot.send(
S::ChunkBatchReceived(play::ChunkBatchReceived {
@@ -2037,3 +2070,140 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() {
assert_eq!(placed, Some(([9, MIN_Y + 5, 9], STONE as i32)));
assert_eq!(play.placed.len(), 1);
}
/// The serverbound packets whose layout 26.3 changed reach the game from a 26.2 client as 26.3's: its
/// teleport confirmations (the id alone) are taken, so its walk counts; its digging actions past 0 are one
/// up (start 0 stays 0, stop 2 is 3); its main-hand swing is a punch and its off-hand swing nothing.
#[test]
fn a_26_2_clients_walk_dig_swing_and_teleport_confirmations_read_as_26_3s() {
let Some(resources) = resources() else {
return;
};
// 26.2's serverbound ids (play) of the packets whose layout changed.
const ACCEPT_TELEPORTATION_26_2: i32 = 0;
const PLAYER_ACTION_26_2: i32 = 41;
const SWING_26_2: i32 = 63;
let net = NetConfig {
accept_older_clients: true,
older_clients_play: true,
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let mut s = server_with(&resources, net);
let port = s.hub.lan.port;
let below = [9, MIN_Y + 4, 9];
let walked = [9.5, f64::from(MIN_Y + 5), 9.5];
let (_bot, confirmed) = run(&mut s, move || {
let (mut bot, _) =
join_as(ServerVersion::V26_2, port, "Old")?.map_err(|r| format!("refused: {r}"))?;
let (mut confirmed, mut chunks, mut sent) = (0, 0, false);
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
let (id, payload) = bot.recv()?;
// The command tree has argument nodes the client side does not decode.
if id == horizon_protocol::ids::play::clientbound::COMMANDS {
continue;
}
// The packets written in 26.2's layout, which the pinned readers don't read.
{
use horizon_protocol::ids::play::clientbound as cb;
if id == cb::LEVEL_CHUNK_WITH_LIGHT {
chunks += 1;
}
if matches!(
id,
cb::LEVEL_CHUNK_WITH_LIGHT
| cb::LIGHT_UPDATE
| cb::LEVEL_PARTICLES
| cb::EXPLODE
| cb::MOVE_ENTITY_POS
| cb::MOVE_ENTITY_POS_ROT
| cb::MOVE_ENTITY_ROT
| cb::ENTITY_POSITION_SYNC
| cb::ANIMATE
| cb::RECIPE_BOOK_ADD
) {
continue;
}
}
match C::decode(id, &payload).map_err(|e| format!("packet {id}: {e}"))? {
C::PlayerPosition(p) => {
let mut w = horizon_protocol::buf::Writer::new();
w.varint(p.teleport);
bot.send_raw(ACCEPT_TELEPORTATION_26_2, &w.data)?;
confirmed += 1;
}
C::ChunkBatchFinished(_) => {
bot.send(
S::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 20.0,
})
.encode(),
)?;
}
C::BlockChangedAck(a) if a.sequence == 77 => break,
_ => {}
}
if chunks >= 9 && confirmed > 0 && !sent {
sent = true;
bot.send(S::PlayerLoaded(Empty).encode())?;
// A walk from the spawn (8.5, 8.5), on the stone: one move, as vanilla kicks a second in a tick.
bot.send(
S::MovePlayerPos(play::MovePlayerPos {
x: walked[0],
y: walked[1],
z: walked[2],
flags: 1,
})
.encode(),
)?;
// Start and stop destroying the block below, as 26.2 numbers the actions.
for (action, sequence) in [(0, 5), (2, 6)] {
let mut w = horizon_protocol::buf::Writer::new();
w.varint(action);
w.i64(horizon_protocol::buf::pack_block_pos(below));
w.u8(1);
w.varint(sequence);
bot.send_raw(PLAYER_ACTION_26_2, &w.data)?;
}
// A main-hand swing, then an off-hand one.
bot.send_raw(SWING_26_2, &[0])?;
bot.send_raw(SWING_26_2, &[1])?;
// Its acknowledgement says the hub has handled everything before it.
bot.send(
S::UseItemOn(play::UseItemOn {
hand: 0,
pos: [12, MIN_Y + 4, 12],
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence: 77,
})
.encode(),
)?;
}
}
assert!(sent, "the client never got its chunks");
// The bot is kept, so its guest stays to be looked at.
Ok((bot, confirmed))
});
assert!(confirmed > 0, "no teleport to confirm");
let g = &s.hub.guests[0];
assert!(g.left_reason.is_empty(), "{}", g.left_reason);
assert_eq!(g.position, walked, "the walk was not taken");
let actions: Vec<i32> = s
.packets
.iter()
.filter_map(|p| match p {
S::PlayerAction(a) if a.pos == below => Some(a.action),
_ => None,
})
.collect();
assert_eq!(actions, [0, 3]);
let punches = s
.packets
.iter()
.filter(|p| matches!(p, S::Punch(_)))
.count();
assert_eq!(punches, 1);
}
@@ -35,6 +35,7 @@ mod tests {
pub(super) struct NativeLootDrops<'a>(pub &'a ());
pub(super) struct NativeNoteBlockChange<'a>(pub &'a ());
pub(super) struct NativeScreenView<'a>(pub &'a ());
pub(super) struct NativeTextField;
pub(super) struct ClientboundPlayPacket;
pub(super) struct EntityDataValue;
pub(super) struct NativeGenerate;
+1
View File
@@ -8,6 +8,7 @@
//! ([`ClientSession`], roadmap `older-clients`), when its owner lets such clients join.
pub mod client_session;
pub mod ids;
pub mod layout_26_2;
pub mod session;
mod translate;
pub mod v1_21_10;
@@ -3,18 +3,29 @@
//! reads it; what the server sends is renumbered to the client's, registry ids included, with a stand-in
//! ([`ids::Substitutes`]) for a block, item, entity, particle or sound the client's version lacks.
//!
//! This layer maps ids only: a packet whose layout changed between the client's release and the pinned one
//! passes in the pinned layout (the mirror of `translate`'s readers comes next).
//! This layer maps ids; the world packets whose layout changed between 26.2 and the pinned release are
//! written in 26.2's layout by [`world`]. Serverbound, 26.2's `accept_teleportation`, `player_action` and
//! `swing` are read as 26.3's (the mirror of `translate`'s writers); other packets whose layout changed
//! still pass in the pinned layout.
//!
//! The states change where vanilla's server-side `Connection` switches its codecs, after the handshake the
//! server read itself: `login_acknowledged` and `login_finished` move each direction to configuration,
//! `finish_configuration` to play, and `configuration_acknowledged` / `start_configuration` back.
use std::sync::atomic::{AtomicU8, Ordering};
use std::{
collections::VecDeque,
sync::{
Mutex,
atomic::{AtomicU8, Ordering},
},
};
mod world;
use super::{
ServerVersion,
ids::{self, Ids, Way, down_or_substitute},
translate::packet_ids_entity::FALLING_BLOCK,
layout_26_2,
translate::{packet_ids_entity::FALLING_BLOCK, swing_id},
};
use crate::{
Result, State,
@@ -50,6 +61,58 @@ pub struct ClientSession {
reading: AtomicU8,
/// What the server sends.
writing: AtomicU8,
/// Where the client last said or was told it is, for the place 26.3's `accept_teleportation` states and
/// 26.2's leaves out.
place: Mutex<Place>,
/// Teleports (`player_position`) sent and not yet accepted, oldest first.
teleports: Mutex<VecDeque<Teleport>>,
}
/// A position, yaw and pitch.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Place {
position: [f64; 3],
yaw: f32,
pitch: f32,
}
/// A `player_position` the server sent.
#[derive(Clone, Copy, Debug)]
struct Teleport {
id: i32,
to: Place,
relatives: i32,
}
impl Teleport {
/// Where a client at `from` is once it took this teleport, as `LocalPlayer` applies
/// `ClientboundPlayerPositionPacket`'s relative flags.
fn applied(&self, from: Place) -> Place {
use play::relative as rel;
let pick = |bit: i32, base: f64, v: f64| {
if self.relatives & bit != 0 {
base + v
} else {
v
}
};
let angle = |bit: i32, base: f32, v: f32| {
if self.relatives & bit != 0 {
base + v
} else {
v
}
};
Place {
position: [
pick(rel::X, from.position[0], self.to.position[0]),
pick(rel::Y, from.position[1], self.to.position[1]),
pick(rel::Z, from.position[2], self.to.position[2]),
],
yaw: angle(rel::Y_ROT, from.yaw, self.to.yaw),
pitch: angle(rel::X_ROT, from.pitch, self.to.pitch).clamp(-90.0, 90.0),
}
}
}
impl ClientSession {
@@ -59,6 +122,8 @@ impl ClientSession {
version,
reading: AtomicU8::new(to_u8(State::Login)),
writing: AtomicU8::new(to_u8(State::Login)),
place: Mutex::new(Place::default()),
teleports: Mutex::new(VecDeque::new()),
}
}
pub fn reading(&self) -> State {
@@ -69,9 +134,14 @@ impl ClientSession {
}
/// A packet the client sent, with the id the pinned version knows it by. `None` drops it: the pinned
/// version has no such packet (26.2's `swing`).
/// version has no such packet.
pub fn serverbound(&self, id: i32, payload: Vec<u8>) -> Result<Option<(i32, Vec<u8>)>> {
let state = self.reading();
// 26.3's punch took over the older swing of the main hand; an off-hand swing has no packet there.
if state == State::Play && id == swing_id(self.version) {
let hand = Reader::new(&payload).varint()?;
return Ok((hand == 0).then_some((packet_ids::play::serverbound::PUNCH, Vec::new())));
}
let Some(pinned) = self.version.serverbound_id_up(state, id) else {
return Ok(None);
};
@@ -85,10 +155,27 @@ impl ClientSession {
_ => state,
};
self.reading.store(to_u8(next), Ordering::Release);
use packet_ids::play::serverbound as sb;
let payload = match (state, pinned) {
(State::Play, sb::ACCEPT_TELEPORTATION) => self.accept_teleport(&payload)?,
// 26.3 inserted CHANGE_DESTROY_DIRECTION at 1: 26.2's actions past 0 are one up.
(State::Play, sb::PLAYER_ACTION) => {
let mut r = Reader::new(&payload);
let action = r.varint()?;
let mut w = Writer::new();
w.varint(if action == 0 { 0 } else { action + 1 });
w.bytes(r.rest());
w.data
}
(State::Play, sb::MOVE_PLAYER_POS | sb::MOVE_PLAYER_POS_ROT | sb::MOVE_PLAYER_ROT) => {
self.moved(pinned, &payload)?;
payload
}
_ => payload,
};
let Some(ids) = self.version.ids() else {
return Ok(Some((pinned, payload)));
};
use packet_ids::play::serverbound as sb;
let payload = match (state, pinned) {
// The stacks a client names carry its item and component ids.
(State::Play, sb::SET_CREATIVE_MODE_SLOT | sb::CONTAINER_CLICK) => {
@@ -120,11 +207,32 @@ impl ClientSession {
_ => state,
};
self.writing.store(to_u8(next), Ordering::Release);
// 26.2 has no swing_animation: its hands swing through animate (world.rs).
if state == State::Play
&& id == packet_ids::play::clientbound::SWING_ANIMATION
&& self.version.ids().is_some()
{
let animate = packet_ids::play::clientbound::ANIMATE;
let Some(mapped) = self.version.clientbound_id_down(state, animate) else {
return Ok(None);
};
let mut w = Writer::new();
w.varint(mapped);
w.bytes(&world::swing(payload)?);
return Ok(Some(w.data));
}
if (state, id) == (State::Play, packet_ids::play::clientbound::PLAYER_POSITION) {
self.teleporting(payload)?;
}
let Some(mapped) = self.version.clientbound_id_down(state, id) else {
return Ok(None);
};
// A packet the renumbering can't read goes as it is, rather than the connection dropping here.
let rewritten = match self.version.ids().map(|ids| down(ids, state, id, payload)) {
let rewritten = match self
.version
.ids()
.map(|ids| layout_then_ids(self.version, ids, state, id, payload))
{
None | Some(Ok(Sent::AsIs)) => None,
Some(Ok(Sent::Rewritten(payload))) => Some(payload),
Some(Ok(Sent::Dropped)) => return Ok(None),
@@ -143,6 +251,82 @@ impl ClientSession {
}
}
/// A 26.2 client's packets whose layout changed are written in its layout ([`layout_26_2`], when enabled),
/// the others renumbered by [`down`].
fn layout_then_ids(
version: ServerVersion,
ids: &'static Ids,
state: State,
id: i32,
payload: &[u8],
) -> Result<Sent> {
if version == ServerVersion::V26_2 && layout_26_2::enabled() {
match layout_26_2::clientbound(ids, state, id, payload)? {
layout_26_2::Out::Untouched => {}
layout_26_2::Out::Rewritten(p) => return Ok(Sent::Rewritten(p)),
layout_26_2::Out::Dropped => return Ok(Sent::Dropped),
}
}
down(ids, state, id, payload)
}
impl ClientSession {
/// A `player_position` on its way: the client accepts it later by its id alone.
fn teleporting(&self, payload: &[u8]) -> Result<()> {
let mut r = Reader::new(payload);
let id = r.varint()?;
let position = [r.f64()?, r.f64()?, r.f64()?];
let _delta = [r.f64()?, r.f64()?, r.f64()?];
let to = Place {
position,
yaw: r.f32()?,
pitch: r.f32()?,
};
let relatives = r.i32()?;
self.teleports
.lock()
.unwrap()
.push_back(Teleport { id, to, relatives });
Ok(())
}
/// A move the client reports (`move_player_pos`, `_pos_rot`, `_rot`): where it now is.
fn moved(&self, pinned: i32, payload: &[u8]) -> Result<()> {
use packet_ids::play::serverbound as sb;
let mut r = Reader::new(payload);
let mut place = self.place.lock().unwrap();
if pinned != sb::MOVE_PLAYER_ROT {
place.position = [r.f64()?, r.f64()?, r.f64()?];
}
if pinned != sb::MOVE_PLAYER_POS {
place.yaw = r.f32()?;
place.pitch = r.f32()?;
}
Ok(())
}
/// 26.2's `accept_teleportation` (the teleport's id) as 26.3's, which also states where the client
/// now is: the teleport it accepts, and any sent before it, applied in order to where it was.
fn accept_teleport(&self, payload: &[u8]) -> Result<Vec<u8>> {
let id = Reader::new(payload).varint()?;
let mut place = self.place.lock().unwrap();
let mut teleports = self.teleports.lock().unwrap();
if let Some(i) = teleports.iter().position(|t| t.id == id) {
for t in teleports.drain(..=i) {
*place = t.applied(*place);
}
}
let mut w = Writer::new();
w.varint(id);
for v in place.position {
w.f64(v);
}
w.f32(place.yaw);
w.f32(place.pitch);
Ok(w.data)
}
}
/// What becomes of a clientbound payload on its way to an older client.
enum Sent {
AsIs,
@@ -196,6 +380,10 @@ fn down(ids: &'static Ids, state: State, id: i32, payload: &[u8]) -> Result<Sent
if state != State::Play {
return Ok(Sent::AsIs);
}
// Packets whose layout 26.2 has differently (world.rs).
if let Some(sent) = world::down(ids, id, payload)? {
return Ok(sent);
}
match id {
cb::UPDATE_TAGS
| cb::ADD_ENTITY
@@ -386,12 +574,97 @@ mod tests {
// add_transient_block (37) is new in 26.3: not sent.
let transient = packet_ids::play::clientbound::ADD_TRANSIENT_BLOCK;
assert_eq!(s.clientbound(&body(transient, &[0; 9])).unwrap(), None);
// 26.2's resource_pack (49) is 26.3's 50; its swing (63) has no pinned packet.
// 26.2's resource_pack (49) is 26.3's 50.
assert_eq!(
s.serverbound(49, vec![7]).unwrap(),
Some((packet_ids::play::serverbound::RESOURCE_PACK, vec![7]))
);
assert_eq!(s.serverbound(63, vec![0]).unwrap(), None);
}
#[test]
fn a_26_2_main_hand_swing_is_a_punch_and_an_off_hand_one_nothing() {
let s = in_play();
// swing: 63 in 26.2, its hand a VarInt (0 main, 1 off); 26.3's punch has no payload.
assert_eq!(
s.serverbound(63, vec![0]).unwrap(),
Some((packet_ids::play::serverbound::PUNCH, Vec::new()))
);
assert_eq!(s.serverbound(63, vec![1]).unwrap(), None);
}
#[test]
fn a_26_2_digging_action_past_0_is_one_up() {
let s = in_play();
let action = |n: i32| {
let mut w = Writer::new();
w.varint(n);
w.i64(crate::buf::pack_block_pos([1, 2, 3]));
w.u8(1);
w.varint(9);
// player_action: 41 in both.
let (id, payload) = s.serverbound(41, w.data).unwrap().unwrap();
assert_eq!(id, packet_ids::play::serverbound::PLAYER_ACTION);
match play::Serverbound::decode(id, &payload).unwrap() {
play::Serverbound::PlayerAction(p) => {
assert_eq!((p.pos, p.direction, p.sequence), ([1, 2, 3], 1, 9));
p.action
}
other => panic!("{other:?}"),
}
};
assert_eq!(action(0), 0); // start destroying
assert_eq!(action(1), 2); // abort: 1 in 26.2, 2 in 26.3
assert_eq!(action(2), 3); // stop destroying
assert_eq!(action(6), 7); // swap held items
}
#[test]
fn a_26_2_teleport_confirmation_states_where_the_teleport_put_the_client() {
use play::relative as rel;
let s = in_play();
let teleport = |id: i32, position: [f64; 3], yaw: f32, relatives: i32| {
let p = play::Clientbound::PlayerPosition(play::PlayerPosition {
teleport: id,
position,
delta: [0.0; 3],
yaw,
pitch: 30.0,
relatives,
});
s.clientbound(&p.encode()).unwrap().unwrap();
};
let accept = |id: i32| {
let mut w = Writer::new();
w.varint(id);
// accept_teleportation: 0 in both.
let (pinned, payload) = s.serverbound(0, w.data).unwrap().unwrap();
assert_eq!(pinned, packet_ids::play::serverbound::ACCEPT_TELEPORTATION);
match play::Serverbound::decode(pinned, &payload).unwrap() {
play::Serverbound::AcceptTeleportation(p) => {
assert_eq!(p.id, id);
([p.x, p.y, p.z], p.yaw, p.pitch)
}
other => panic!("{other:?}"),
}
};
teleport(1, [8.5, -60.0, 8.5], 90.0, 0);
assert_eq!(accept(1), ([8.5, -60.0, 8.5], 90.0, 30.0));
// The client walks (move_player_pos: 30 in both), then is moved by (1, 2, 3) and turned by 10.
let mut w = Writer::new();
for v in [10.0, -60.0, 10.0] {
w.f64(v);
}
w.u8(1);
s.serverbound(30, w.data).unwrap().unwrap();
teleport(
2,
[1.0, 2.0, 3.0],
10.0,
rel::X | rel::Y | rel::Z | rel::Y_ROT,
);
assert_eq!(accept(2), ([11.0, -58.0, 13.0], 100.0, 30.0));
// An unknown id: where the client last was.
assert_eq!(accept(7), ([11.0, -58.0, 13.0], 100.0, 30.0));
}
#[test]
@@ -0,0 +1,757 @@
//! Clientbound world packets in 26.2's layout for a 26.2 client (#10404, the mirror of `translate`'s
//! hand-written 26.2 readers): chunk columns and light, particles, explosions, entity moves and hand
//! swings. Everything else in a packet keeps its pinned layout; ids go down as the rest of
//! [`super`] renumbers them, with stand-ins where 26.2 lacks an entry.
use super::{Sent, state_down};
use crate::{
Result,
adapter::ids::{self, Ids, Way, down_or_substitute},
buf::{Codec, Reader, Writer},
chunk::{self, Paletted, Section, Strategy},
entity_data::{Particle, ParticleData},
ids as packet_ids,
packets::play,
};
/// `SoundEvents.EMPTY`: what a 26.2 client is told to play for a blast 26.3 keeps silent.
const SILENT: &str = "minecraft:intentionally_empty";
/// The pinned play packet `id` in 26.2's layout; `None` when 26.2 lays it out as the pinned version does.
pub(super) fn down(ids: &'static Ids, id: i32, payload: &[u8]) -> Result<Option<Sent>> {
use packet_ids::play::clientbound as cb;
Ok(Some(match id {
cb::LEVEL_CHUNK_WITH_LIGHT => Sent::Rewritten(level_chunk(ids, payload)?),
cb::LIGHT_UPDATE => Sent::Rewritten(light_update(payload)?),
cb::LEVEL_PARTICLES => particles(ids, payload)?,
cb::EXPLODE => explode(ids, payload)?,
cb::MOVE_ENTITY_POS | cb::MOVE_ENTITY_POS_ROT | cb::MOVE_ENTITY_ROT => {
Sent::Rewritten(move_entity(id, payload)?)
}
cb::ENTITY_POSITION_SYNC => Sent::Rewritten(position_sync(payload)?),
cb::ANIMATE => animate(payload)?,
_ => return Ok(None),
}))
}
/// The column with its block states renumbered (stand-ins for states 26.2 lacks) and packed for 26.2's
/// narrower global palette, the block entities 26.2 has, and the light masks as 26.2 sends them. Biomes are
/// the server's own registry and stay as they are.
fn level_chunk(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut p = play::LevelChunkWithLight::read(&mut Reader::new(payload))?;
let (old_bits, new_bits) = ids.state_bits();
let mut r = Reader::new(&p.chunk.buffer);
let mut sections = Vec::new();
while r.remaining() > 0 {
let mut s = Section::read(&mut r, new_bits, 0)?;
s.states = states_down(ids, &s.states, old_bits);
sections.push(s);
}
p.chunk.buffer = chunk::ChunkData::from_sections(&sections);
// A block entity type 26.2 lacks goes; its block still draws.
p.chunk.block_entities.retain_mut(|b| {
ids.block_entity_type
.down(b.kind)
.map(|kind| b.kind = kind)
.is_some()
});
let mut w = Writer::new();
w.i32(p.x);
w.i32(p.z);
chunk::ChunkData::write(&p.chunk, &mut w);
light_down(&p.light, &mut w);
Ok(w.data)
}
/// A section's states in 26.2's numbering: a palette's entries renumbered, or the global palette repacked
/// at 26.2's width (`old_bits`).
fn states_down(ids: &Ids, states: &Paletted, old_bits: u8) -> Paletted {
if !states.palette.is_empty() {
let mut out = states.clone();
out.palette
.iter_mut()
.for_each(|p| *p = state_down(ids, *p));
return out;
}
let values: Vec<i32> = (0..chunk::BLOCKS)
.map(|i| state_down(ids, states.get(i)))
.collect();
Paletted::from_values(&values, Strategy::Blocks, old_bits)
}
/// 26.2's ClientboundLightUpdatePacketData: each mask as `BitSet.toLongArray` (a VarInt count of longs),
/// where 26.3 sends `toByteArray` (the same bits as little-endian bytes); the layers as they are.
fn light_down(light: &chunk::LightData, w: &mut Writer) {
for mask in [
&light.sky_mask,
&light.block_mask,
&light.empty_sky_mask,
&light.empty_block_mask,
] {
w.long_array(&mask_longs(mask));
}
chunk::Layers::write(&light.sky, w);
chunk::Layers::write(&light.block, w);
}
/// A mask's bytes as the longs `BitSet.toLongArray` gives: none past the last set bit.
fn mask_longs(bytes: &[u8]) -> Vec<i64> {
let mut longs: Vec<i64> = bytes
.chunks(8)
.map(|c| {
let mut b = [0u8; 8];
b[..c.len()].copy_from_slice(c);
i64::from_le_bytes(b)
})
.collect();
while longs.last() == Some(&0) {
longs.pop();
}
longs
}
fn light_update(payload: &[u8]) -> Result<Vec<u8>> {
let p = play::LightUpdate::read(&mut Reader::new(payload))?;
let mut w = Writer::new();
w.varint(p.x);
w.varint(p.z);
light_down(&p.light, &mut w);
Ok(w.data)
}
/// A particle in 26.2's numbering, its block state too, or its stand-in; `None` when 26.2 has neither (a
/// native mod's particle). The options stay: the stand-ins in the 26.2 tables (pinned 43..=45 as 41)
/// carry none, as the particles they stand for; a stand-in with other options would need them rebuilt.
fn particle_down(ids: &Ids, p: &Particle) -> Option<Particle> {
let mut p = p.clone();
p.id = down_or_substitute(ids.particle_type, ids.substitutes.particle_type, p.id)?;
if let Some(ParticleData::State(s)) = &mut p.data {
*s = state_down(ids, *s);
}
Some(p)
}
/// A particle written for 26.2: an item in its options takes 26.2's item and component ids.
fn write_particle(ids: &'static Ids, p: &Particle, w: &mut Writer) {
let _down = ids::renumbering(ids, Way::Down);
p.write(w);
}
/// 26.2: flags, position, spread, one max speed, an Int count, then the particle. 26.3 sends the particle
/// first, a speed per axis, a VarInt count and how the spread is drawn, which 26.2 can't be told (it always
/// draws a gaussian spread). Of the three speeds the largest goes.
fn particles(ids: &'static Ids, payload: &[u8]) -> Result<Sent> {
let p = play::LevelParticles::read(&mut Reader::new(payload))?;
let Some(particle) = particle_down(ids, &p.particle) else {
return Ok(Sent::Dropped);
};
let speed = p
.max_speed
.into_iter()
.fold(0.0f32, |a, v| if v.abs() > a.abs() { v } else { a });
let mut w = Writer::new();
w.bool(p.override_limiter);
w.bool(p.always_show);
p.pos.iter().for_each(|v| w.f64(*v));
p.dist.iter().for_each(|v| w.f32(*v));
w.f32(speed);
w.i32(p.count);
write_particle(ids, &particle, &mut w);
Ok(Sent::Rewritten(w.data))
}
/// 26.2's explode is 26.3's without the trailing "sound plays" flag: 26.2 always plays the sound, so a
/// silent blast names `SoundEvents.EMPTY`. Its particles and sound take 26.2's ids; a scattered particle
/// 26.2 has nothing for is left out.
fn explode(ids: &'static Ids, payload: &[u8]) -> Result<Sent> {
let mut e = play::Explode::read(&mut Reader::new(payload))?;
let Some(particle) = particle_down(ids, &e.particle) else {
return Ok(Sent::Dropped);
};
e.particle = particle;
e.block_particles = std::mem::take(&mut e.block_particles)
.into_iter()
.filter_map(|mut b| {
b.particle = particle_down(ids, &b.particle)?;
Some(b)
})
.collect();
let silent = || (-1, Some((SILENT.to_string(), None)));
if !e.play_sound {
e.sound = silent();
} else if e.sound.1.is_none() {
// An inline sound is named, not numbered.
match down_or_substitute(ids.sound_event, ids.substitutes.sound_event, e.sound.0) {
Some(id) => e.sound.0 = id,
None => e.sound = silent(),
}
}
let mut w = Writer::new();
{
let _down = ids::renumbering(ids, Way::Down);
play::Explode::write(&e, &mut w);
}
w.data.pop();
Ok(Sent::Rewritten(w.data))
}
/// 26.2 sends a move as three Short deltas (1/4096 of a block) then on-ground, and a turn as the two angles
/// then on-ground; 26.3 packs on-ground with the delta's step count and sends it before the angles. A
/// stepped delta goes as its sum (the next position sync corrects a sum past a Short).
fn move_entity(id: i32, payload: &[u8]) -> Result<Vec<u8>> {
use packet_ids::play::clientbound as cb;
let mut r = Reader::new(payload);
let m = match id {
cb::MOVE_ENTITY_POS => play::MoveEntityPos::read(&mut r)?.0,
cb::MOVE_ENTITY_POS_ROT => play::MoveEntityPosRot::read(&mut r)?.0,
_ => play::MoveEntityRot::read(&mut r)?.0,
};
let mut w = Writer::new();
w.varint(m.id);
if id != cb::MOVE_ENTITY_ROT {
delta_sum(m.delta.as_ref()).iter().for_each(|v| w.i16(*v));
}
if id != cb::MOVE_ENTITY_POS {
let (yaw, pitch) = m.rotation.unwrap_or((0, 0));
w.i8(yaw);
w.i8(pitch);
}
w.bool(m.on_ground);
Ok(w.data)
}
/// A delta as one linear step, clamped to a Short per axis.
fn delta_sum(delta: Option<&play::VecDelta>) -> [i16; 3] {
match delta {
None => [0; 3],
Some(play::VecDelta::Linear(d)) => *d,
Some(play::VecDelta::Stepped(steps)) => {
let mut sum = [0i32; 3];
for (d, _) in steps {
for (s, v) in sum.iter_mut().zip(d) {
*s += i32::from(*v);
}
}
sum.map(|v| v.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16)
}
}
}
/// 26.2: the entity, PositionMoveRotation (position, motion, the two angles), on-ground. 26.3 sends where it
/// goes as a path, which 26.2 is told the end of, and no motion (zero).
fn position_sync(payload: &[u8]) -> Result<Vec<u8>> {
let p = play::EntityPositionSync::read(&mut Reader::new(payload))?;
let mut w = Writer::new();
w.varint(p.id);
p.path.end().iter().for_each(|v| w.f64(*v));
(0..3).for_each(|_| w.f64(0.0));
w.f32(p.yaw);
w.f32(p.pitch);
w.bool(p.on_ground);
Ok(w.data)
}
/// 26.3's animate actions as 26.2 numbers them (wake up 0 -> 2, critical hits 1, 2 -> 4, 5); 26.2's own 0
/// and 3 swing hands, which 26.3 sends as `swing_animation` ([`swing`]).
fn animate(payload: &[u8]) -> Result<Sent> {
let p = play::Animate::read(&mut Reader::new(payload))?;
let action = match p.action {
0 => 2,
1 => 4,
2 => 5,
_ => return Ok(Sent::Dropped),
};
let mut w = Writer::new();
w.varint(p.id);
w.u8(action);
Ok(Sent::Rewritten(w.data))
}
/// 26.3's `swing_animation` as the payload of 26.2's `animate`: the main hand's swing (0) or the off hand's
/// (3). 26.2 has one kind of swing at its own pace, so the kind and duration go.
pub(super) fn swing(payload: &[u8]) -> Result<Vec<u8>> {
let s = play::SwingAnimation::read(&mut Reader::new(payload))?;
let mut w = Writer::new();
w.varint(s.id);
w.u8(if s.hand == 1 { 3 } else { 0 });
Ok(w.data)
}
#[cfg(test)]
mod tests {
//! Each packet written in 26.2's layout here is read back with `translate`'s 26.2 reader, which brings
//! it to the pinned layout again.
use super::super::super::{ServerVersion, translate};
use super::*;
use crate::State;
use horizon_world::nbt::Tag;
fn ids() -> &'static Ids {
ServerVersion::V26_2.ids().unwrap()
}
fn in_play() -> super::super::ClientSession {
use std::sync::atomic::Ordering;
let s = super::super::ClientSession::new(ServerVersion::V26_2);
let play = super::super::to_u8(State::Play);
s.reading.store(play, Ordering::Release);
s.writing.store(play, Ordering::Release);
s
}
fn rewritten(sent: Option<Sent>) -> Vec<u8> {
match sent {
Some(Sent::Rewritten(body)) => body,
Some(Sent::AsIs) => panic!("sent as is"),
Some(Sent::Dropped) => panic!("dropped"),
None => panic!("not a world packet"),
}
}
fn encode<T: Codec<T>>(p: &T) -> Vec<u8> {
let mut w = Writer::new();
T::write(p, &mut w);
w.data
}
fn light() -> chunk::LightData {
chunk::LightData {
// Sections 0 and 2; section 9 (past the first byte); section 64 (past the first long).
sky_mask: vec![0b101],
block_mask: vec![0, 0b10],
empty_sky_mask: vec![0, 0, 0, 0, 0, 0, 0, 0, 1],
empty_block_mask: Vec::new(),
sky: vec![vec![0x11; 2048], vec![0xf0; 2048]],
block: vec![vec![0x07; 2048]],
}
}
#[test]
fn a_mask_becomes_the_longs_of_bitset_to_long_array() {
assert_eq!(mask_longs(&[0b101]), vec![0b101]);
assert_eq!(mask_longs(&[0, 0b10]), vec![1 << 9]);
assert_eq!(mask_longs(&[0, 0, 0, 0, 0, 0, 0, 0, 1]), vec![0, 1]);
assert!(mask_longs(&[]).is_empty());
}
#[test]
fn a_chunk_goes_down_and_back_up() {
let ids = ids();
// A section of one state (chest's first: 5543 pinned, 3987 in 26.2), and one on the global palette:
// 4096 states that 26.2 has too (its 13000.., oak_slab's 13330 among them).
let one = Paletted::from_values(&[5543; chunk::BLOCKS], Strategy::Blocks, 16);
let many: Vec<i32> = (0..4096)
.map(|i| ids.block_state.up(13000 + i).unwrap_or(1))
.collect();
let global = Paletted::from_values(&many, Strategy::Blocks, 16);
assert_eq!(global.bits, 16);
let plains = Paletted::from_values(&[1; chunk::BIOMES], Strategy::Biomes, 0);
let section = |states: Paletted| Section {
non_empty: 4096,
fluids: 0,
states,
biomes: plains.clone(),
};
let sections = vec![section(one), section(global)];
// A block entity of the first type 26.2 has.
let kind = ids.block_entity_type.up(0).unwrap();
let p = play::LevelChunkWithLight {
x: 3,
z: -7,
chunk: chunk::ChunkData {
heightmaps: vec![(1, vec![0x0101; 37])],
buffer: chunk::ChunkData::from_sections(&sections),
block_entities: vec![chunk::BlockEntityInfo {
packed_xz: 0x12,
y: 64,
kind,
tag: Tag::Compound(Default::default()),
}],
},
light: light(),
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT,
&encode(&p),
)
.unwrap(),
);
// In 26.2's own numbering: the chest is 3987, the global palette 15 bits wide, 26.2's ids.
{
let mut r = Reader::new(&old);
r.i32().unwrap();
r.i32().unwrap();
let c = chunk::ChunkData::read(&mut r).unwrap();
let s = c.sections(2, ids.state_bits().0, 0).unwrap();
assert_eq!(s[0].states.get(0), 3987);
assert_eq!(s[1].states.bits, 15);
assert_eq!(s[1].states.get(330), 13330);
assert_eq!(c.block_entities[0].kind, 0);
}
let back = translate::chunk(ids, &old).unwrap();
let q = play::LevelChunkWithLight::read(&mut Reader::new(&back)).unwrap();
assert_eq!((q.x, q.z), (3, -7));
assert_eq!(q.chunk.heightmaps, p.chunk.heightmaps);
assert_eq!(q.chunk.block_entities, p.chunk.block_entities);
assert_eq!(q.light, p.light);
let s = q.chunk.sections(2, ids.state_bits().1, 0).unwrap();
for (a, b) in s.iter().zip(&sections) {
for i in 0..chunk::BLOCKS {
assert_eq!(a.states.get(i), b.states.get(i));
}
assert_eq!(a.biomes, b.biomes);
}
}
#[test]
fn a_state_26_2_lacks_goes_down_as_its_stand_in() {
let ids = ids();
// poplar_log axis=y (167, new in 26.3) shows as birch_log axis=y (143 in 26.2).
let p = Paletted::from_values(&[167; chunk::BLOCKS], Strategy::Blocks, 16);
assert_eq!(states_down(ids, &p, 15).get(0), 143);
}
#[test]
fn a_light_update_goes_down_and_back_up() {
let ids = ids();
let p = play::LightUpdate {
x: -1,
z: 9,
light: light(),
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::LIGHT_UPDATE,
&encode(&p),
)
.unwrap(),
);
let back = translate::light_update(&old).unwrap();
assert_eq!(play::LightUpdate::read(&mut Reader::new(&back)).unwrap(), p);
}
#[test]
fn particles_go_down_and_back_up() {
let ids = ids();
let send = |particle: Particle| {
let p = play::LevelParticles {
particle,
override_limiter: true,
always_show: false,
pos: [1.5, 64.0, -3.25],
dist: [0.5, 0.25, 0.5],
max_speed: [0.1; 3],
count: 12,
randomization: 0,
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::LEVEL_PARTICLES,
&encode(&p),
)
.unwrap(),
);
let back = translate::particles(ids, &old).unwrap();
let q = play::LevelParticles::read(&mut Reader::new(&back)).unwrap();
assert_eq!(
(
q.override_limiter,
q.always_show,
q.pos,
q.dist,
q.max_speed,
q.count
),
(
p.override_limiter,
p.always_show,
p.pos,
p.dist,
p.max_speed,
p.count
)
);
q.particle
};
// entity_effect (28) carries a colour, alike in both numberings.
let colour = Particle {
id: 28,
data: Some(ParticleData::Color(0x00ff00ff)),
};
assert_eq!(send(colour.clone()), colour);
// block (1) carries a state: chest's 5543 goes down to 3987 and comes back.
let block = Particle {
id: 1,
data: Some(ParticleData::State(5543)),
};
assert_eq!(send(block.clone()), block);
// 44 is new in 26.3: it shows as its stand-in, 41.
assert_eq!(
send(Particle { id: 44, data: None }),
Particle { id: 41, data: None }
);
}
#[test]
fn of_three_speeds_the_largest_goes() {
let ids = ids();
let p = play::LevelParticles {
particle: Particle {
id: 28,
data: Some(ParticleData::Color(0)),
},
override_limiter: false,
always_show: false,
pos: [0.0; 3],
dist: [0.0; 3],
max_speed: [0.1, -0.4, 0.2],
count: 0,
randomization: 1,
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::LEVEL_PARTICLES,
&encode(&p),
)
.unwrap(),
);
// Two flags, three doubles, three floats, then the speed.
let mut r = Reader::new(&old[2 + 24 + 12..]);
assert_eq!(r.f32().unwrap(), -0.4);
assert_eq!(r.i32().unwrap(), 0);
}
fn blast(play_sound: bool, sound: i32) -> play::Explode {
play::Explode {
center: [10.0, 70.0, -4.0],
radius: 4.0,
block_count: 17,
knockback: Some([0.1, 0.2, 0.3]),
particle: Particle {
id: 28,
data: Some(ParticleData::Color(-1)),
},
sound: (sound, None),
block_particles: vec![
play::ExplosionParticle {
particle: Particle {
id: 28,
data: Some(ParticleData::Color(7)),
},
scaling: 1.0,
speed: 0.5,
weight: 3,
},
// A native mod's particle: 26.2 has nothing for it.
play::ExplosionParticle {
particle: Particle {
id: crate::entity_data::MOD_PARTICLE_ID,
data: Some(ParticleData::Mod("mymod:spark".into())),
},
scaling: 1.0,
speed: 0.5,
weight: 1,
},
],
play_sound,
}
}
/// 26.2's explode read back as `translate` reads it: the flag 26.2 lacks appended, ids renumbered up.
fn explode_up(old: &[u8]) -> play::Explode {
let mut body = old.to_vec();
body.push(1);
let _up = ids::renumbering(ids(), Way::Up);
play::Explode::read(&mut Reader::new(&body)).unwrap()
}
#[test]
fn an_explosion_goes_down_without_the_sound_flag() {
let ids = ids();
// A sound event both versions number alike.
let sound = (0..400)
.find(|&s| ids.sound_event.down(s) == Some(s))
.unwrap();
let e = blast(true, sound);
let old =
rewritten(down(ids, packet_ids::play::clientbound::EXPLODE, &encode(&e)).unwrap());
assert_eq!(
old.len() + 1,
encode(&{
let mut kept = e.clone();
kept.block_particles.truncate(1);
kept
})
.len()
);
let back = explode_up(&old);
let mut expected = e;
expected.block_particles.truncate(1);
assert_eq!(back, expected);
}
#[test]
fn a_silent_explosion_names_the_empty_sound() {
let ids = ids();
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::EXPLODE,
&encode(&blast(false, 0)),
)
.unwrap(),
);
assert_eq!(
explode_up(&old).sound,
(-1, Some((SILENT.to_string(), None)))
);
}
#[test]
fn moves_go_down_and_back_up() {
use packet_ids::play::clientbound as cb;
let ids = ids();
let m = |delta: Option<play::VecDelta>, rotation: Option<(i8, i8)>| play::MoveEntity {
id: 7,
delta,
rotation,
on_ground: true,
};
let pos = play::MoveEntityPos(m(Some(play::VecDelta::Linear([4096, 0, -2048])), None));
let old = rewritten(down(ids, cb::MOVE_ENTITY_POS, &encode(&pos)).unwrap());
assert_eq!(old.len(), 1 + 6 + 1);
let back = translate::move_entity(&old, true, false).unwrap();
assert_eq!(
play::MoveEntityPos::read(&mut Reader::new(&back)).unwrap(),
pos
);
let pos_rot =
play::MoveEntityPosRot(m(Some(play::VecDelta::Linear([1, 2, 3])), Some((10, -20))));
let old = rewritten(down(ids, cb::MOVE_ENTITY_POS_ROT, &encode(&pos_rot)).unwrap());
let back = translate::move_entity(&old, true, true).unwrap();
assert_eq!(
play::MoveEntityPosRot::read(&mut Reader::new(&back)).unwrap(),
pos_rot
);
let rot = play::MoveEntityRot(m(None, Some((-5, 90))));
let old = rewritten(down(ids, cb::MOVE_ENTITY_ROT, &encode(&rot)).unwrap());
assert_eq!(old, vec![7, -5i8 as u8, 90, 1]);
let back = translate::move_entity(&old, false, true).unwrap();
assert_eq!(
play::MoveEntityRot::read(&mut Reader::new(&back)).unwrap(),
rot
);
}
#[test]
fn a_stepped_move_goes_down_as_its_sum() {
let stepped = play::VecDelta::Stepped(vec![
([100, 0, -5], 1),
([200, 30000, -5], 2),
([0, 30000, 0], 3),
]);
assert_eq!(delta_sum(Some(&stepped)), [300, i16::MAX, -10]);
}
#[test]
fn a_position_sync_goes_down_and_back_up() {
let ids = ids();
let p = play::EntityPositionSync {
id: 42,
path: play::PositionPath::Linear([100.5, 64.0, -20.25]),
yaw: 90.0,
pitch: -10.0,
on_ground: false,
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::ENTITY_POSITION_SYNC,
&encode(&p),
)
.unwrap(),
);
assert_eq!(old.len(), 1 + 48 + 8 + 1);
let back = translate::position_sync(&old).unwrap();
assert_eq!(
play::EntityPositionSync::read(&mut Reader::new(&back)).unwrap(),
p
);
// A stepped path goes as where it ends.
let stepped = play::EntityPositionSync {
path: play::PositionPath::Stepped(vec![([1.0, 2.0, 3.0], 1), ([4.0, 5.0, 6.0], 2)]),
..p
};
let old = rewritten(
down(
ids,
packet_ids::play::clientbound::ENTITY_POSITION_SYNC,
&encode(&stepped),
)
.unwrap(),
);
let back = translate::position_sync(&old).unwrap();
let q = play::EntityPositionSync::read(&mut Reader::new(&back)).unwrap();
assert_eq!(q.path, play::PositionPath::Linear([4.0, 5.0, 6.0]));
}
#[test]
fn animate_actions_take_26_2_s_numbers() {
let ids = ids();
let send =
|action: u8| down(ids, packet_ids::play::clientbound::ANIMATE, &[5, action]).unwrap();
assert_eq!(rewritten(send(0)), vec![5, 2]);
assert_eq!(rewritten(send(1)), vec![5, 4]);
assert_eq!(rewritten(send(2)), vec![5, 5]);
// And back as translate reads them.
assert_eq!(
translate::animate(&[5, 4]).unwrap(),
(packet_ids::play::clientbound::ANIMATE, vec![5, 1])
);
}
#[test]
fn a_swing_reaches_a_26_2_client_as_animate() {
let s = in_play();
let swing = play::Clientbound::SwingAnimation(play::SwingAnimation {
id: 5,
hand: 1,
kind: 1,
duration: 6,
});
let sent = s.clientbound(&swing.encode()).unwrap().unwrap();
let mut r = Reader::new(&sent);
let animate = ServerVersion::V26_2
.clientbound_id_down(State::Play, packet_ids::play::clientbound::ANIMATE)
.unwrap();
assert_eq!(r.varint().unwrap(), animate);
// translate reads 26.2's off-hand swing as the swing sent.
let (id, body) = translate::animate(r.rest()).unwrap();
assert_eq!(id, packet_ids::play::clientbound::SWING_ANIMATION);
assert_eq!(play::Clientbound::decode(id, &body).unwrap(), swing);
}
#[test]
fn a_light_update_through_the_session_is_in_26_2_s_layout() {
let s = in_play();
let p = play::Clientbound::LightUpdate(play::LightUpdate {
x: 0,
z: 0,
light: light(),
});
let sent = s.clientbound(&p.encode()).unwrap().unwrap();
let mut r = Reader::new(&sent);
r.varint().unwrap();
let back = translate::light_update(r.rest()).unwrap();
assert_eq!(
play::Clientbound::decode(packet_ids::play::clientbound::LIGHT_UPDATE, &back).unwrap(),
p
);
}
}
@@ -158,6 +158,15 @@ pub fn quirks() -> Quirks {
}
}
/// The older version's quirks while packets are encoded for it on this thread (an older client, or what is
/// sent to an older server); none otherwise. The writers then lay those structures out as it reads them.
pub fn quirks_written() -> Quirks {
match ACTIVE.get() {
Some((ids, Way::Down)) => ids.quirks,
_ => Quirks::NONE,
}
}
impl Ids {
/// Bits per entry of a chunk section's global block palette: the older version's, then the pinned one's
/// (`Mth.ceillog2` of the state count).
@@ -0,0 +1,506 @@
//! The pinned (26.3) clientbound packets a 26.2 client reads in another layout, written in 26.2's: the
//! mirror of `translate`'s 26.2 readers for a [`super::ClientSession`] (roadmap `older-clients`). Registry ids
//! inside these packets are renumbered here too, since they never reach the id layer's generic rewrite.
//!
//! Off by default ([`set_enabled`]): until a client has played through every packet here, the id layer
//! alone keeps the pinned layout.
use std::sync::atomic::{AtomicBool, Ordering};
use super::ids::{self, Ids, Way, down_or_substitute};
use crate::{
Result, State,
buf::{Codec, Reader, Writer},
ids as packet_ids,
packets::{configuration, play},
};
static ENABLED: AtomicBool = AtomicBool::new(false);
/// Whether 26.2 clients get these packets in their own layout.
pub fn set_enabled(on: bool) {
ENABLED.store(on, Ordering::Release);
}
pub fn enabled() -> bool {
ENABLED.load(Ordering::Acquire)
}
/// What becomes of a pinned clientbound payload rewritten here.
#[derive(Debug, PartialEq)]
pub enum Out {
/// Not one of the packets this layer rewrites.
Untouched,
Rewritten(Vec<u8>),
/// The client has nothing to read it as.
Dropped,
}
/// The pinned `payload` of packet `id` in `state`, as a 26.2 client (`ids`) reads it.
pub fn clientbound(ids: &'static Ids, state: State, id: i32, payload: &[u8]) -> Result<Out> {
use packet_ids::play::clientbound as cb;
Ok(match (state, id) {
(State::Configuration, packet_ids::configuration::clientbound::REGISTRY_DATA) => {
registry_data(payload)?
}
(State::Play, cb::LOGIN) => Out::Rewritten(login(payload)?),
(State::Play, cb::RESPAWN) => Out::Rewritten(respawn(payload)?),
(State::Play, cb::UPDATE_ADVANCEMENTS) => Out::Rewritten(advancements(ids, payload)?),
(State::Play, cb::COMMANDS) => Out::Rewritten(commands(ids, payload)?),
(State::Play, cb::MAP_ITEM_DATA) => Out::Rewritten(map_item_data(ids, payload)?),
(State::Play, cb::AWARD_STATS) => Out::Rewritten(award_stats(ids, payload)?),
_ => Out::Untouched,
})
}
/// Synchronized registries 26.3 added (RegistryDataLoader.SYNCHRONIZED_REGISTRIES of both jars compared):
/// 26.2 has no codec for their entries.
const NEW_REGISTRIES: [&str; 3] = [
"minecraft:decorated_pot_pattern",
"minecraft:block_transformer",
"minecraft:worldgen/block_state_provider",
];
/// A registry 26.2 doesn't synchronize is not sent; the others pass as they are (unknown fields in an
/// entry's NBT are left to 26.2's record codecs, which read past them).
fn registry_data(payload: &[u8]) -> Result<Out> {
let p = configuration::RegistryData::read(&mut Reader::new(payload))?;
Ok(if NEW_REGISTRIES.contains(&p.registry.as_str()) {
Out::Dropped
} else {
Out::Untouched
})
}
/// 26.2's CommonPlayerSpawnInfo: the game types as bytes, -1 for no previous one (26.3: VarInts, the
/// optional one as id + 1).
fn spawn_info(s: &play::SpawnInfo, w: &mut Writer) {
w.varint(s.dimension_type);
w.string(&s.dimension);
w.i64(s.seed);
w.i8(s.game_type as i8);
w.i8(s.previous_game_type.map_or(-1, |g| g as i8));
w.bool(s.debug);
w.bool(s.flat);
<crate::buf::Opt<play::GlobalPos>>::write(&s.last_death, w);
w.varint(s.portal_cooldown);
w.varint(s.sea_level);
}
fn login(payload: &[u8]) -> Result<Vec<u8>> {
let l = play::Login::read(&mut Reader::new(payload))?;
let mut w = Writer::new();
w.i32(l.player_id);
w.bool(l.hardcore);
w.varint(l.levels.len() as i32);
l.levels.iter().for_each(|s| w.string(s));
w.varint(l.max_players);
w.varint(l.chunk_radius);
w.varint(l.simulation_distance);
w.bool(l.reduced_debug_info);
w.bool(l.show_death_screen);
w.bool(l.limited_crafting);
spawn_info(&l.spawn, &mut w);
w.bool(l.online_mode);
w.bool(l.enforces_secure_chat);
Ok(w.data)
}
fn respawn(payload: &[u8]) -> Result<Vec<u8>> {
let p = play::Respawn::read(&mut Reader::new(payload))?;
let mut w = Writer::new();
spawn_info(&p.spawn, &mut w);
w.i8(p.keep);
Ok(w.data)
}
/// 26.3 sends an advancement's tab position after the whole advancement; 26.2 inside its display, after
/// the background (an advancement without a display has none). The icon takes the client's item ids.
fn advancements(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let mut w = Writer::new();
w.bool(r.bool()?);
let n = r.count(r.remaining())?;
w.varint(n as i32);
for _ in 0..n {
w.string(&r.string(32767)?);
let parent = r.bool()?;
w.bool(parent);
if parent {
w.string(&r.string(32767)?);
}
// The display waits for the position, which comes last in 26.3.
let mut display = None;
if r.bool()? {
let mut d = Writer::new();
d.nbt(&r.nbt()?);
d.nbt(&r.nbt()?);
let icon = crate::item::Template::read(&mut r)?;
{
let _down = ids::renumbering(ids, Way::Down);
crate::item::Template::write(&icon, &mut d);
}
d.varint(r.varint()?);
let flags = r.i32()?;
d.i32(flags);
if flags & 1 != 0 {
d.string(&r.string(32767)?);
}
display = Some(d.data);
}
let mut rest = Writer::new();
let groups = r.count(r.remaining())?;
rest.varint(groups as i32);
for _ in 0..groups {
let k = r.count(r.remaining())?;
rest.varint(k as i32);
for _ in 0..k {
rest.string(&r.string(32767)?);
}
}
rest.bool(r.bool()?);
let (x, y) = (r.f32()?, r.f32()?);
w.bool(display.is_some());
if let Some(d) = display {
w.bytes(&d);
w.f32(x);
w.f32(y);
}
w.bytes(&rest.data);
}
w.bytes(r.rest());
Ok(w.data)
}
/// `brigadier:string`, the same id in 26.2 and 26.3.
const STRING_PARSER: i32 = 5;
/// Each argument node's parser in 26.2's numbering. A parser 26.2 lacks becomes a string (a greedy one on
/// a node nothing follows, a word otherwise) whose suggestions the client asks the server for, so the
/// command still parses on the client and the server checks the argument.
fn commands(ids: &Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut tree = play::Commands::read(&mut Reader::new(payload))?;
for node in &mut tree.nodes {
let Some((parser, _)) = &node.parser else {
continue;
};
match ids.command_argument_type.down(*parser) {
Some(old) => {
if let Some(p) = &mut node.parser {
p.0 = old;
}
}
None => {
let kind = if node.children.is_empty() { 2 } else { 0 };
node.parser = Some((STRING_PARSER, vec![kind]));
node.suggestions = Some("minecraft:ask_server".into());
}
}
}
let mut w = Writer::new();
play::Commands::write(&tree, &mut w);
Ok(w.data)
}
/// Map decorations in 26.2's `map_decoration_type` numbering; one it lacks isn't drawn.
fn map_item_data(ids: &Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut p = play::MapItemData::read(&mut Reader::new(payload))?;
if let Some(decorations) = &mut p.decorations {
decorations.retain_mut(|d| {
ids.map_decoration_type
.down(d.kind)
.map(|k| d.kind = k)
.is_some()
});
}
let mut w = Writer::new();
play::MapItemData::write(&p, &mut w);
Ok(w.data)
}
/// `minecraft:stat_type` in vanilla's registration order (Stats): what each type's value is an id of.
const STAT_MINED: i32 = 0;
const STAT_KILLED: i32 = 6;
const STAT_KILLED_BY: i32 = 7;
const STAT_CUSTOM: i32 = 8;
/// Stats with their type and value in 26.2's numbering; one naming what 26.2 lacks isn't sent.
fn award_stats(ids: &Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut p = play::AwardStats::read(&mut Reader::new(payload))?;
p.stats.retain_mut(|(kind, value, _)| {
let renumbered = match *kind {
STAT_MINED => ids.block.down(*value),
STAT_KILLED | STAT_KILLED_BY => ids.entity_type.down(*value),
STAT_CUSTOM => ids.custom_stat.down(*value),
// crafted, used, broken, picked_up, dropped
_ => down_or_substitute(ids.item, ids.substitutes.item, *value),
};
match (ids.stat_type.down(*kind), renumbered) {
(Some(k), Some(v)) => {
(*kind, *value) = (k, v);
true
}
_ => false,
}
});
let mut w = Writer::new();
play::AwardStats::write(&p, &mut w);
Ok(w.data)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::ServerVersion;
fn ids() -> &'static Ids {
ServerVersion::V26_2.ids().unwrap()
}
fn spawn(previous: Option<i32>) -> play::SpawnInfo {
play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 42,
game_type: 1,
previous_game_type: previous,
debug: false,
flat: true,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
}
}
#[test]
fn respawn_game_types_are_bytes_again() {
for previous in [None, Some(3)] {
let mut w = Writer::new();
play::Respawn::write(
&play::Respawn {
spawn: spawn(previous),
keep: 3,
},
&mut w,
);
let body = respawn(&w.data).unwrap();
// 26.2's own reader (translate's spawn_info) brings it back.
let back = super::super::translate::tests_support::respawn_26_2(&body).unwrap();
let p = play::Respawn::read(&mut Reader::new(&back)).unwrap();
assert_eq!(p.spawn, spawn(previous));
assert_eq!(p.keep, 3);
}
}
#[test]
fn login_reads_back_with_26_2_s_reader() {
let login_packet = play::Login {
player_id: 7,
hardcore: false,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: 10,
simulation_distance: 8,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: spawn(None),
online_mode: true,
enforces_secure_chat: false,
};
let mut w = Writer::new();
play::Login::write(&login_packet, &mut w);
let body = login(&w.data).unwrap();
let back = super::super::translate::tests_support::login_26_2(&body).unwrap();
assert_eq!(
play::Login::read(&mut Reader::new(&back)).unwrap(),
login_packet
);
}
#[test]
fn an_advancement_s_position_goes_back_into_its_display() {
// One advancement with a display (diamond icon, no background) at (2.5, -1), one without at (0, 0).
let mut w = Writer::new();
w.bool(true);
w.varint(2);
for (id, display) in [("minecraft:a", true), ("minecraft:b", false)] {
w.string(id);
w.bool(false);
w.bool(display);
if display {
w.nbt(&horizon_world::nbt::Tag::String("T".into()));
w.nbt(&horizon_world::nbt::Tag::String("D".into()));
crate::item::Template::write(&crate::item::Stack::new(1012, 1), &mut w);
w.varint(0);
w.i32(2);
}
w.varint(1);
w.varint(1);
w.string("done");
w.bool(false);
w.f32(if display { 2.5 } else { 0.0 });
w.f32(if display { -1.0 } else { 0.0 });
}
w.varint(0);
w.varint(0);
w.bool(true);
let body = advancements(ids(), &w.data).unwrap();
let back = super::super::translate::tests_support::advancements_26_2(ids(), &body).unwrap();
// Read back with 26.2's reader the icon is the pinned diamond again (926 in 26.2).
assert_eq!(back, w.data);
}
#[test]
fn a_26_3_only_parser_becomes_a_string_and_others_shift_down() {
let node =
|kind: u8, children: Vec<i32>, parser: Option<(i32, Vec<u8>)>| play::CommandNode {
kind,
executable: true,
restricted: false,
children,
redirect: None,
name: if kind == 0 { String::new() } else { "x".into() },
parser,
suggestions: None,
};
let uuid = play::ARGUMENT_TYPES
.iter()
.position(|&n| n == "minecraft:uuid")
.unwrap() as i32;
let tree = play::Commands {
nodes: vec![
node(0, vec![1, 2], None),
node(2, vec![], Some((uuid, vec![]))),
node(2, vec![], Some((3, vec![0]))), // brigadier:integer, no bounds
],
root: 0,
};
let mut w = Writer::new();
play::Commands::write(&tree, &mut w);
let body = commands(ids(), &w.data).unwrap();
let out = play::Commands::read(&mut Reader::new(&body)).unwrap();
let uuid_down = ids().command_argument_type.down(uuid);
match uuid_down {
// uuid is new in 26.3: a greedy string asking the server.
None => {
assert_eq!(out.nodes[1].parser, Some((STRING_PARSER, vec![2])));
assert_eq!(
out.nodes[1].suggestions.as_deref(),
Some("minecraft:ask_server")
);
}
Some(old) => assert_eq!(out.nodes[1].parser.as_ref().unwrap().0, old),
}
assert_eq!(out.nodes[2].parser, Some((3, vec![0])));
}
#[test]
fn registries_26_2_lacks_are_not_sent() {
let data = |registry: &str| {
let mut w = Writer::new();
configuration::RegistryData::write(
&configuration::RegistryData {
registry: registry.into(),
entries: vec![],
},
&mut w,
);
registry_data(&w.data).unwrap()
};
assert_eq!(data("minecraft:block_transformer"), Out::Dropped);
assert_eq!(data("minecraft:worldgen/biome"), Out::Untouched);
}
#[test]
fn stats_and_map_decorations_take_26_2_s_ids() {
let ids = ids();
// The last pinned custom stat and map decoration are the newest; whatever 26.2 lacks goes.
let mut w = Writer::new();
play::AwardStats::write(
&play::AwardStats {
stats: vec![(STAT_CUSTOM, 0, 5), (STAT_CUSTOM, 77, 1), (1, 1012, 2)],
},
&mut w,
);
let out = award_stats(ids, &w.data).unwrap();
let p = play::AwardStats::read(&mut Reader::new(&out)).unwrap();
let expected: Vec<_> = [(STAT_CUSTOM, 0, 5), (STAT_CUSTOM, 77, 1), (1, 1012, 2)]
.into_iter()
.filter_map(|(k, v, n)| {
let v = match k {
STAT_CUSTOM => ids.custom_stat.down(v),
_ => ids.item.down(v),
}?;
Some((ids.stat_type.down(k)?, v, n))
})
.collect();
assert_eq!(p.stats, expected);
// diamond: 1012 pinned, 926 in 26.2.
assert!(p.stats.contains(&(1, 926, 2)));
}
#[test]
fn pot_sides_and_container_slots_take_26_2_s_layout_and_ids() {
use crate::item::{Component, Stack, Template, component};
let v26_2 = ids();
// A pot with a diamond (1012 pinned, 926 in 26.2) at the back, the rest undecorated, and a
// container holding three diamonds and an empty slot: their pinned bytes.
let mut pot = Writer::new();
pot.bool(true);
Template::write(&Stack::new(1012, 1), &mut pot);
(0..3).for_each(|_| pot.bool(false));
let mut container = Writer::new();
container.varint(2);
container.bool(true);
Template::write(&Stack::new(1012, 3), &mut container);
container.bool(false);
let mut stack = Stack::new(1012, 1);
for (kind, bytes) in [
(component::POT_DECORATIONS, &pot.data),
(component::CONTAINER, &container.data),
] {
let c = Component::read(kind, &mut Reader::new(bytes)).unwrap();
stack.added.push((kind, c));
}
let mut w = Writer::new();
{
let _down = ids::renumbering(v26_2, Way::Down);
Template::write(&stack, &mut w);
}
// Read with 26.2's reader (its quirks, its ids) both come back as they were.
let back = {
let _up = ids::renumbering(v26_2, Way::Up);
Template::read(&mut Reader::new(&w.data)).unwrap()
};
assert_eq!(back.item, 1012);
for (kind, bytes) in [
(component::POT_DECORATIONS, &pot.data),
(component::CONTAINER, &container.data),
] {
let mut b = Writer::new();
back.component(kind).unwrap().write(kind, &mut b);
assert_eq!(&b.data, bytes, "component {kind}");
}
}
#[test]
fn a_tag_slot_display_is_a_bare_tag_id_for_26_2() {
use crate::packets::recipes::{HolderSet, SlotDisplay};
let round = |d: &SlotDisplay| {
let mut w = Writer::new();
{
let _down = ids::renumbering(ids(), Way::Down);
d.write(&mut w);
}
let _up = ids::renumbering(ids(), Way::Up);
SlotDisplay::read(&mut Reader::new(&w.data)).unwrap()
};
let tag = SlotDisplay::Tag(HolderSet::Tag("minecraft:planks".into()));
assert_eq!(round(&tag), tag);
// Items have no TagSlotDisplay form in 26.2: a composite of the items.
let items = SlotDisplay::Tag(HolderSet::Items(vec![1012]));
assert_eq!(
round(&items),
SlotDisplay::Composite(vec![SlotDisplay::Item(1012)])
);
}
}
@@ -274,12 +274,7 @@ pub fn serverbound(
(State::Play, sb::PUNCH) => {
let mut w = Writer::new();
w.varint(0);
let swing = if session.version.at_most(ServerVersion::V1_21_11) {
v1_21_11::SWING
} else {
V26_2_SWING
};
Ok(Some((swing, w.data)))
Ok(Some((swing_id(session.version), w.data)))
}
(State::Play, sb::SET_CREATIVE_MODE_SLOT | sb::CONTAINER_CLICK) => {
let p = play::Serverbound::decode(id, payload)?;
@@ -296,6 +291,15 @@ pub fn serverbound(
/// 26.2's serverbound `swing` (play), which 26.3 replaced with `punch`.
const V26_2_SWING: i32 = 63;
/// The id an older `version` numbers its serverbound `swing` (play) by.
pub(super) fn swing_id(version: ServerVersion) -> i32 {
if version.at_most(ServerVersion::V1_21_11) {
v1_21_11::SWING
} else {
V26_2_SWING
}
}
/// A play packet decoded with the older version's ids renumbered as they are read.
fn decode_play(ids: &'static Ids, id: i32, payload: &[u8]) -> Result<play::Clientbound> {
let _up = ids::renumbering(ids, Way::Up);
@@ -382,7 +386,7 @@ fn respawn(payload: &[u8]) -> Result<Vec<u8>> {
/// The column's sections with their block states renumbered and packed for the pinned palette width (a
/// section on the older global palette uses fewer bits per entry), and its light masks as 26.3 sends them.
/// Biomes are the server's own registry and stay as they are.
fn chunk(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
pub(super) fn chunk(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let x = r.i32()?;
let z = r.i32()?;
@@ -447,7 +451,7 @@ fn light(r: &mut Reader) -> Result<crate::chunk::LightData> {
})
}
fn light_update(payload: &[u8]) -> Result<Vec<u8>> {
pub(super) fn light_update(payload: &[u8]) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let x = r.varint()?;
let z = r.varint()?;
@@ -524,7 +528,7 @@ fn advancements(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
/// 26.2: flags, position, spread, one max speed, an Int count, then the particle. 26.3 sends the particle
/// first, a speed per axis, a VarInt count and how the spread is drawn (0, gaussian, as 26.2 did).
fn particles(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
pub(super) fn particles(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let override_limiter = r.bool()?;
let always_show = r.bool()?;
@@ -555,7 +559,7 @@ fn particles(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
/// 26.2 sends a move as three Short deltas (1/4096 of a block) then on-ground, and a turn as the two angles
/// then on-ground; 26.3 packs on-ground with the delta's step count, and sends it before the angles.
fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result<Vec<u8>> {
pub(super) fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let id = r.varint()?;
let delta = if pos {
@@ -582,7 +586,7 @@ fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result<Vec<u8>> {
/// 26.2: the entity, PositionMoveRotation (position, motion, the two angles), on-ground. 26.3 sends where it
/// goes as a path, then the angles; the motion is no longer sent.
fn position_sync(payload: &[u8]) -> Result<Vec<u8>> {
pub(super) fn position_sync(payload: &[u8]) -> Result<Vec<u8>> {
let mut r = Reader::new(payload);
let id = r.varint()?;
let position = [r.f64()?, r.f64()?, r.f64()?];
@@ -606,7 +610,7 @@ fn position_sync(payload: &[u8]) -> Result<Vec<u8>> {
/// 26.2's animate swings hands too (0 main, 3 off), which 26.3 sends as `swing_animation`; the others were
/// renumbered (wake up 2 -> 0, critical hits 4, 5 -> 1, 2).
fn animate(payload: &[u8]) -> Result<(i32, Vec<u8>)> {
pub(super) fn animate(payload: &[u8]) -> Result<(i32, Vec<u8>)> {
let mut r = Reader::new(payload);
let id = r.varint()?;
let action = r.u8()?;
@@ -1288,3 +1292,19 @@ mod tests {
assert_eq!(action(3), Some(2)); // stop destroying: 3 in 26.3, 2 in 26.2
}
}
/// 26.2's readers, for the tests of the layout written for a 26.2 client (`layout_26_2`).
#[cfg(test)]
pub(super) mod tests_support {
use super::*;
pub fn login_26_2(payload: &[u8]) -> Result<Vec<u8>> {
login(payload)
}
pub fn respawn_26_2(payload: &[u8]) -> Result<Vec<u8>> {
respawn(payload)
}
pub fn advancements_26_2(ids: &'static Ids, payload: &[u8]) -> Result<Vec<u8>> {
advancements(ids, payload)
}
}
+84 -2
View File
@@ -993,6 +993,88 @@ impl Codec<Option<Stack>> for Slot {
}
}
/// A component value as the version being written for reads it. Values kept as their pinned bytes that hold
/// items (a pot's sides, a container's slots, a use remainder) are read again and written with that
/// version's item ids, and a pot's sides as 26.2's item ids when it reads them so
/// ([`ids::Quirks::pot_decorations_are_items`]). Written as they are when nothing is renumbered.
fn write_value(kind: i32, c: &Component, w: &mut Writer) {
use component::*;
let quirks = ids::quirks_written();
let renumbered = matches!(ids::active(), Some((_, ids::Way::Down)));
let held = match kind {
POT_DECORATIONS => true,
// Before 26.1 these hold ItemStacks, written by another codec.
USE_REMAINDER | SULFUR_CUBE_CONTENT | CONTAINER => !quirks.stacks_in_components,
_ => false,
};
if !renumbered || !held {
return c.write(kind, w);
}
let pinned = {
let _pinned = ids::pinned();
let mut b = Writer::new();
c.write(kind, &mut b);
b.data
};
match value_down(kind, quirks, &pinned) {
Ok(bytes) => w.bytes(&bytes),
Err(_) => w.bytes(&pinned),
}
}
/// A stack template as the pinned version wrote it.
fn read_pinned(r: &mut Reader) -> Result<Stack> {
let _pinned = ids::pinned();
Template::read(r)
}
/// The pinned bytes of an item-holding component (see [`write_value`]) in the numbering written now.
fn value_down(kind: i32, quirks: ids::Quirks, pinned: &[u8]) -> Result<Vec<u8>> {
use component::*;
let mut r = Reader::new(pinned);
let mut w = Writer::new();
match kind {
// Back, left, right, front: each an optional template of one item.
POT_DECORATIONS => {
let mut sides = Vec::with_capacity(4);
for _ in 0..4 {
sides.push(if r.bool()? {
Some(read_pinned(&mut r)?)
} else {
None
});
}
if quirks.pot_decorations_are_items {
// ByteBufCodecs.registry(ITEM).apply(list(4)): a brick for an undecorated side.
w.varint(4);
for side in &sides {
w.varint(ids::item(side.as_ref().map_or(BRICK, |s| s.item)));
}
} else {
for side in &sides {
w.bool(side.is_some());
if let Some(s) = side {
Template::write(s, &mut w);
}
}
}
}
CONTAINER => {
let n = r.count(256)?;
w.varint(n as i32);
for _ in 0..n {
let slot = r.bool()?;
w.bool(slot);
if slot {
Template::write(&read_pinned(&mut r)?, &mut w);
}
}
}
_ => Template::write(&read_pinned(&mut r)?, &mut w),
}
Ok(w.data)
}
/// A stack's components, without those an older client being written for lacks.
fn write_patch(s: &Stack, w: &mut Writer) {
let has = |kind: i32| ids::component(kind) >= 0;
@@ -1000,7 +1082,7 @@ fn write_patch(s: &Stack, w: &mut Writer) {
w.varint(s.removed.iter().filter(|k| has(**k)).count() as i32);
for (kind, c) in s.added.iter().filter(|(k, _)| has(*k)) {
w.varint(ids::component(*kind));
c.write(*kind, w);
write_value(*kind, c, w);
}
for kind in s.removed.iter().filter(|k| has(**k)) {
w.varint(ids::component(*kind));
@@ -1089,7 +1171,7 @@ impl Codec<Option<Stack>> for DelimitedSlot {
for (kind, c) in &s.added {
w.varint(ids::component(*kind));
let mut value = Writer::new();
c.write(*kind, &mut value);
write_value(*kind, c, &mut value);
w.byte_array(&value.data);
}
for kind in &s.removed {
@@ -166,6 +166,20 @@ impl SlotDisplay {
w.varint(5);
Template::write(stack, w);
}
Self::Tag(set) if crate::adapter::ids::quirks_written().tag_slot_display_is_id => {
match set {
// TagSlotDisplay of 26.2: the tag's bare id.
HolderSet::Tag(name) => {
w.varint(6);
w.string(name);
}
// A list of items has no such form: a composite of item displays shows the same.
HolderSet::Items(items) => {
Self::Composite(items.iter().map(|&item| Self::Item(item)).collect())
.write(w)
}
}
}
Self::Tag(set) => {
w.varint(6);
set.write(w);
+67 -21
View File
@@ -240,29 +240,39 @@ pub fn buffer_index(name: &str) -> Option<usize> {
impl Config {
/// Reads one preprocessed fragment stage file.
pub fn read(&mut self, pp: &Preprocessed) {
self.read_stage(pp, true, true);
self.read_stage(pp, true, true, true);
}
/// Reads only the colortex formats of one preprocessed fragment stage file, as [`Self::read`]
/// reads them.
pub fn read_formats(&mut self, pp: &Preprocessed) {
self.read_stage(pp, true, false);
self.read_stage(pp, true, false, false);
}
/// Reads the colortex formats and the shadow settings of one preprocessed fragment stage
/// file: `shadow.fsh` of a pack without a shadow pass. Iris 1.11.7 applied `shadowDistance`
/// and `sunPathRotation` declared only there (its shadow projection and sun position), with a
/// probe pack of ours whose shadow buffers nothing read (#10490).
pub fn read_formats_and_shadow(&mut self, pp: &Preprocessed) {
self.read_stage(pp, true, true, false);
}
/// Reads one preprocessed vertex stage file. Its colortex formats don't count: Iris 1.11.7
/// ignored a `colortexNFormat` declared only in `composite.vsh`, `final.vsh`, `deferred.vsh`,
/// `gbuffers_terrain.vsh` or `shadow.vsh` of probe packs p9063a, p9063c and p9063s (ours),
/// while it honoured the same line in each program's fragment shader.
/// while it honoured the same line in each program's fragment shader. Nor do the shadow
/// settings of [`VERTEX_IGNORED`].
pub fn read_vertex(&mut self, pp: &Preprocessed) {
self.read_stage(pp, false, true);
self.read_stage(pp, false, false, true);
}
/// `formats`: whether colortex formats count; `others`: whether every other setting does.
fn read_stage(&mut self, pp: &Preprocessed, formats: bool, others: bool) {
/// `formats`: whether colortex formats count; `shadow`: whether the shadow settings do (those
/// of [`VERTEX_IGNORED`] only with it); `others`: whether every other setting does.
fn read_stage(&mut self, pp: &Preprocessed, formats: bool, shadow: bool, others: bool) {
let line = |at: usize| pp.locate(code_line(&pp.code, at));
for (at, ty, name, value) in constants(&pp.code) {
let place = line(at);
self.constant(&place, ty, name, value, formats, others);
self.constant(&place, ty, name, value, [formats, shadow, others]);
}
for (origin, comment) in &pp.comments {
for (at, ty, name, value) in constants(comment) {
@@ -278,22 +288,16 @@ impl Config {
// honoured one indented, one followed by text or by `*/`, and the first of two.
let starts_line = (comment[..at].rfind('\n'))
.is_some_and(|n| comment[n + 1..at].trim().is_empty());
self.constant(&place, ty, name, value, formats && starts_line, others);
let reads = [formats && starts_line, shadow, others];
self.constant(&place, ty, name, value, reads);
}
}
}
/// `formats`: whether a colortex format declared here counts; `others`: whether any other
/// setting does.
fn constant(
&mut self,
place: &str,
ty: &str,
name: &str,
value: &str,
formats: bool,
others: bool,
) {
/// `reads`: whether a colortex format declared here counts, whether a shadow setting does
/// (those of [`VERTEX_IGNORED`] only with it), and whether any other setting does.
fn constant(&mut self, place: &str, ty: &str, name: &str, value: &str, reads: [bool; 3]) {
let [formats, shadow, others] = reads;
// Like a colortex format, it counts from fragment shaders only, those of shadow programs
// with no shadow pass too: Iris 1.11.7 made `noisetex` 64 wide from a declaration in our
// probes' `composite.fsh`, `deferred.fsh`, `gbuffers_textured.fsh`, `final.fsh` or
@@ -305,10 +309,17 @@ impl Config {
return;
}
let format = ty == "int" && name.strip_suffix("Format").and_then(buffer_index).is_some();
if !others && !format {
if !shadow && VERTEX_IGNORED.contains(&name) {
return;
}
if self.shadow_constant(ty, name, value) {
// Any other shadow setting (`shadow_constant` knows the names) where `shadow` or `others`.
if Config::default().shadow_constant(ty, name, value) {
if shadow || others {
self.shadow_constant(ty, name, value);
}
return;
}
if !others && !format {
return;
}
let settings = [
@@ -357,6 +368,17 @@ impl Config {
}
}
/// Shadow settings a vertex stage's `const` doesn't set: Iris 1.11.7 ignored each declared only in
/// `final.vsh`, `gbuffers_textured.vsh` or `shadow.vsh` of probe packs of ours (#10490), with
/// and without a shadow pass. The other shadow settings were not probed there.
const VERTEX_IGNORED: [&str; 5] = [
"shadowDistance",
"sunPathRotation",
"shadowIntervalSize",
"shadowNearPlane",
"shadowFarPlane",
];
impl Config {
/// A shadow setting of 6.3 or 7.1; true when `name` is one (read or not).
fn shadow_constant(&mut self, ty: &str, name: &str, value: &str) -> bool {
@@ -588,6 +610,30 @@ mod tests {
assert!(config.notes.is_empty(), "{:?}", config.notes);
}
/// Iris 1.11.7 with probe packs of ours (#10490): these five set in a vertex stage didn't
/// change its shadow projection, shadow camera or sun; in a fragment stage they did.
#[test]
fn some_shadow_settings_count_only_in_fragment_shaders() {
let text = "const float shadowDistance = 48.0;\nconst float sunPathRotation = 20.0;\n\
const float shadowIntervalSize = 8.0;\nconst float shadowNearPlane = 0.5;\n\
const float shadowFarPlane = 64.0;\nconst int shadowMapResolution = 1024;\n";
let settings = |s: &Shadow| (s.distance, s.sun_path_rotation, s.interval, s.near, s.far);
let mut vertex = Config::default();
vertex.read_vertex(&stage(text));
assert_eq!(
settings(&vertex.shadow),
settings(&Config::default().shadow)
);
// One not probed in a vertex stage still counts there, as before.
assert_eq!(vertex.shadow.resolution, 1024);
let mut fragment = Config::default();
fragment.read(&stage(text));
assert_eq!(
settings(&fragment.shadow),
(48.0, 20.0, 8.0, Some(0.5), Some(64.0))
);
}
#[test]
fn shadow_settings() {
let mut c = Config::default();
+11
View File
@@ -260,6 +260,13 @@ impl Custom {
}
}
/// Forgets the world's frames, as a pack just loaded has seen none: `smooth` restarts every
/// frame again until one knows the camera's biome (`Renderer::reset_shader_pack_history`).
pub fn forget_world(&mut self) {
self.restart_smoothing();
self.world_seen = false;
}
/// Restarts `smooth` and `random` for a frame of the world's tick `tick`, so a frame drawn
/// again at that tick evaluates the same (`HORIZON_STILL`).
pub fn restart_at(&mut self, tick: u64) {
@@ -1299,6 +1306,10 @@ mod tests {
let (cat, y) = (0..60).fold((0.0, 0.0), |_, _| frame(&mut c, true, 10.0, -5.38));
assert!(cat > 9.9, "the biome's category from the start: {cat}");
assert!(y > 290.0, "still fading from the spawn's 300: {y}");
// Forgotten, as by a pack history reset: it starts again from the first known frame.
c.forget_world();
frame(&mut c, false, 0.0, 100.0);
assert_eq!(frame(&mut c, true, 10.0, 100.0), (10.0, 300.0));
}
#[test]
+52 -6
View File
@@ -1091,15 +1091,19 @@ fn load_shadow(
// Observed in probe packs of ours (p9063a, p9063c), not a rule Iris states: Iris 1.11.7
// honoured a `colortexNFormat` declared only in `shadow.fsh` or `shadowcomp.fsh` of a
// pack Horizon runs without a shadow pass. So the colortex formats of the shadow
// programs' fragment shaders are read here, and nothing else of them (nothing else was
// probed). The other shadow programs' files were not probed; they are read as `shadow`.
// programs' fragment shaders are read here, with `shadow.fsh`'s shadow settings
// (`shadowDistance` and `sunPathRotation` probed, #10490), and nothing else of them
// (nothing else was probed). The other shadow programs' files were not probed; they are
// read as `shadow`, formats only.
let names = ["shadow", "shadow_water", "shadow_entities", "shadow_block"]
.map(str::to_owned)
.into_iter()
.chain(std::iter::once("shadowcomp".to_owned()))
.chain((1..=99).map(|n| format!("shadowcomp{n}")));
for name in names {
loader.read_formats(&name);
// `shadow.fsh`'s shadow settings count too (`Config::read_formats_and_shadow`).
let shadow = name == "shadow";
loader.read_formats(&name, shadow);
}
return;
}
@@ -1356,8 +1360,9 @@ impl Loader<'_> {
}
/// Reads the colortex formats of program `name` without loading it, when it is there and
/// enabled: those of its fragment shader, as [`Self::load`] reads them.
fn read_formats(&mut self, name: &str) {
/// enabled: those of its fragment shader, as [`Self::load`] reads them; with `shadow`, its
/// shadow settings too.
fn read_formats(&mut self, name: &str, shadow: bool) {
let path = |ext: &str| match self.folder {
"" => format!("{name}.{ext}"),
f => format!("{f}/{name}.{ext}"),
@@ -1374,7 +1379,11 @@ impl Loader<'_> {
return;
}
let fragment = self.preprocess(&fsh);
self.post.config.read_formats(&fragment);
if shadow {
self.post.config.read_formats_and_shadow(&fragment);
} else {
self.post.config.read_formats(&fragment);
}
}
/// Stage file `path` through the preprocessor; the pack's images declared without a format
@@ -2664,6 +2673,43 @@ blend.composite1.colortex3 = ONE ONE ONE ONE
assert_eq!(buffers(&post), (2, Some(vec![0, 1]), vec![vec![]]));
}
/// A pack without a shadow pass still takes `shadow.fsh`'s shadow settings, not
/// `shadow.vsh`'s: Iris 1.11.7 applied `shadowDistance` and `sunPathRotation` from the first
/// (shadow projection, sun position) and not from the second, with probe packs of ours whose
/// shadow buffers nothing read (#10490).
#[test]
fn shadow_fsh_settings_count_without_a_shadow_pass() {
let settings = "const float shadowDistance = 48.0;\nconst float sunPathRotation = 20.0;\n";
let load = |vsh: &str, fsh: &str| {
let files = [
("shaders/final.vsh", VSH),
("shaders/final.fsh", FSH),
("shaders/shadow.vsh", vsh),
("shaders/shadow.fsh", fsh),
];
let pack = Pack::from_zip_bytes("t", zip(&files)).unwrap();
let properties = Properties::parse("", &Macros::default());
load_post(
&pack,
"",
&properties,
&Macros::standard(),
&GameInputs::default(),
)
};
let with = |text: &str, file: &str| format!("#version 120\n{text}{}", &file[13..]);
let post = load(VSH, &with(settings, FSH));
assert!(!post.shadow_pass);
let s = &post.config.shadow;
assert_eq!((s.distance, s.sun_path_rotation), (48.0, 20.0));
let post = load(&with(settings, VSH), FSH);
let (s, d) = (&post.config.shadow, crate::config::Shadow::default());
assert_eq!(
(s.distance, s.sun_path_rotation),
(d.distance, d.sun_path_rotation)
);
}
/// A shadow program with a geometry stage draws terrain, entities and block entities, each
/// read vertex by vertex as the game says; a draw whose vertices can't be read so skips it.
#[test]
+4 -3
View File
@@ -166,10 +166,11 @@ static CONFIGURED: AtomicUsize = AtomicUsize::new(0);
static POOL: OnceLock<Pool> = OnceLock::new();
/// The number of workers the machine gets by default: one thread per core
/// but one, which the render and server threads share with them (they run
/// ahead of the workers: [`crate::favour_current`], [`crate::behind_server`]).
/// but one ([`crate::default_workers`]: per performance core on Apple silicon), which the
/// render and server threads share with them (they run ahead of the workers:
/// [`crate::favour_current`], [`crate::behind_server`]).
pub fn default_threads() -> usize {
crate::worker_count().saturating_sub(1).max(1)
crate::default_workers()
}
/// Size the process's pool before its first use (later calls change
+72
View File
@@ -29,6 +29,48 @@ pub fn worker_count() -> usize {
})
}
/// The chunk worker pool's automatic size (`jobs::default_threads`, Graphics > Worker
/// threads on Auto): every core but one, as vanilla's `Util.maxAllowedExecutorThreads`,
/// counting on Apple silicon only its performance cores. Its efficiency cores are slower,
/// and workers there kept the render and server threads waiting for a performance core: on
/// a base M4 (4 performance and 6 efficiency cores) a fast flight's p99 frame went from
/// 20 ms with 9 workers to 7 ms with 3 (#11294).
pub fn default_workers() -> usize {
workers_for(worker_count(), performance_cores)
}
/// [`default_workers`] for `cores` cores, of which `performance` (asked only here, the
/// sysctl) are performance cores where the machine tells them apart (`None` elsewhere):
/// one less than the smaller count, at least 1.
fn workers_for(cores: usize, performance: impl FnOnce() -> Option<usize>) -> usize {
let fast = performance().filter(|&n| n > 0).unwrap_or(cores);
cores.min(fast).saturating_sub(1).max(1)
}
/// The machine's performance cores (`hw.perflevel0.physicalcpu`), where it has more than one
/// kind of core: Apple silicon. `None` where the OS doesn't say (an Intel Mac).
#[cfg(target_vendor = "apple")]
fn performance_cores() -> Option<usize> {
let mut count: libc::c_int = 0;
let mut size = std::mem::size_of::<libc::c_int>();
// SAFETY: a NUL-terminated name; `count` and `size` are live locals of the size passed.
let read = unsafe {
libc::sysctlbyname(
c"hw.perflevel0.physicalcpu".as_ptr(),
(&raw mut count).cast(),
&mut size,
std::ptr::null_mut(),
0,
)
};
(read == 0 && count > 0).then_some(count as usize)
}
#[cfg(not(target_vendor = "apple"))]
fn performance_cores() -> Option<usize> {
None
}
/// `Some(n)` when `value` names a positive count (`" 4 "` counts; `"0"`,
/// `"lots"`, a missing variable don't).
fn parse_worker_count(value: Option<String>) -> Option<usize> {
@@ -508,6 +550,36 @@ mod tests {
assert_eq!(super::worker_count(), machine);
}
/// The pool leaves a core, counting only the performance cores where the machine has them.
#[test]
fn default_workers_count_performance_cores() {
use super::workers_for;
// Apple silicon: base M4 (4 + 6), M1 (4 + 4), M4 Pro (10 + 4).
assert_eq!(workers_for(10, || Some(4)), 3);
assert_eq!(workers_for(8, || Some(4)), 3);
assert_eq!(workers_for(14, || Some(10)), 9);
// HORIZON_WORKER_THREADS below the performance cores still caps the pool.
assert_eq!(workers_for(4, || Some(10)), 3);
// Everywhere else (and a sysctl that answers nothing): every core but one, as before.
assert_eq!(workers_for(12, || None), 11);
assert_eq!(workers_for(14, || Some(0)), 13);
// Never fewer than one worker.
assert_eq!(workers_for(1, || None), 1);
assert_eq!(workers_for(2, || Some(1)), 1);
}
/// Off Apple the machine is never asked: the count is the one the pool always had.
#[test]
fn default_workers_are_unchanged_off_apple() {
let before = super::worker_count().saturating_sub(1).max(1);
if cfg!(target_vendor = "apple") {
assert!((1..=before).contains(&super::default_workers()));
} else {
assert_eq!(super::performance_cores(), None);
assert_eq!(super::default_workers(), before);
}
}
/// Only a positive number overrides the machine's count.
#[test]
fn worker_count_override_parses() {
+3
View File
@@ -394,6 +394,9 @@ of the frame ([#8750](https://github.com/horizon-rust/horizon/issues/8750)).
40`): the server sends its time once a second, which can leave the client a tick ahead until the
next, and the last tick still eases for a client tick. `/tick unfreeze` plays on as without the
switch.
- While frozen, rain and thunder sit at their levels (1 while it rains or thunders, else 0), and
the rain fog too, as a shader pack's smoothed values do: `/weather rain` shows full rain without
a step. A step or `/tick unfreeze` fades them as without the switch.
- The server's own randoms and entity ids are its own: a summoned mob's facing and variant, and
mob AI during a step, can differ when other server work drew on them first, and ids follow the
order the server loads and creates entities in, which loading changes from run to run. What an
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and Auto takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, Line truncated
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and Auto takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` and no shader pack, a float one (`Rgba16Float`) in its place whatever Smooth Gradients is, which keeps light past white: the last pass shows it where the world shows without a light mark (as shaders will write it), up to the peak, and clips it at white on marked pixels (light sources, the sun, HDR lighting, which it lifts as before), under the HUD and menus and with HDR Lighting off ([hdr-linear-light.md](design/hdr-linear-light.md), step 3)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the Line truncated
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
+2 -1
View File
@@ -54,7 +54,8 @@ irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server
The script picks the machine's x64 or ARM64 archive, including when PowerShell runs under x64 emulation. It downloads
the current Horizon Nightly from GitHub (the only build Horizon ships;
`--version` picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
`--version` picks another), checks it against the release's signature when OpenSSL 3 is installed (on Windows, Git
for Windows includes it), else against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
- the release in `~/.local/share/horizon/releases/<version>` (Windows: `%LOCALAPPDATA%\Horizon`), with
`current` pointing at the one in use;
+4 -4
View File
@@ -71,10 +71,10 @@ Recipes mod and a matching `horizon-ctl`. This opt-in check fails if Recipes is
LAN runs do not require it.
The runner installs the same original `tools/fixtures/recipes_hmod` package in both scratch game
folders. It finds the output of an original recipe (flint to two echo shards), checks unknown recipes
and missing ingredients, fills the
crafting grid through Recipes' `+` button, takes the result, and checks authoritative inventory counts
before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`.
folders. It finds the output of an original recipe (flint to two echo shards) in Survival and Creative.
With the recipe book empty, it verifies that missing ingredients show a warning and preserve the
occupied grid. It then fills the grid through Recipes' `+`, takes the result, and checks authoritative
inventory counts before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`.
As an assertion canary, changing only the fixture's result count from two to one must fail the custom
result check; restore it before running the passing case. This is a content scenario, not general
Java-mod compatibility or custom-item registration coverage.
+54
View File
@@ -0,0 +1,54 @@
# Transient client update fields
A mod block entity can own a batch of fields that belongs in the next client
update, but not in its saved contents. Examples include the items used by a
processing particle effect. Repeated block-change notifications may coalesce;
the batch is consumed when the update tag is constructed, not when an update
is requested.
`ModBlockEntity` provides three methods:
- `set_client_update_fields(&mut self, Compound)` replaces the pending batch.
- `clear_client_update_fields(&mut self)` discards it.
- `take_client_update_fields(&self) -> Compound` atomically drains it.
The block entity's owner explicitly extends its API 45
`native_block_entity_update_tag` result with the drained fields. The engine does
not add them automatically. If clients need an explicit empty value on a later
update, the owner supplies that default in its tag. Setting fields does not
schedule an update: use the ordinary block-entity synchronization path for that.
The private `OnceLock<Mutex<Compound>>` is not initialized by an idle entity,
an empty replacement, a clear, or a take. It is safe to consume through the
shared reference available to API 45, including saved chunks encoded by worker
threads. A poisoned lock is recovered with its pending data intact. The fields
are not part of `ModBlockEntityFields`: they are neither saved nor included in
equality, debug output, or revision changes. A clone is a different entity and
starts with no pending batch; a save does not consume the live entity's batch,
and cold decoding starts empty.
Loaded chunks use `ChunkEncoder::body_in` and the live entity map. The block
broadcast path constructs each entity's tag once and sends the encoded packet
to all viewers; an update with no viewers does not construct or consume a tag.
Saved chunks beyond view distance are loaded independently and do not inherit
a live entity's pending effects. These existing boundaries are unchanged.
Validation covers replacement and cancellation, concurrent drains, poison
recovery, clone identity, cold save/decode, loaded-chunk encoding, and a Hub
broadcast to two viewers after a skipped broadcast with no viewers. The tests
exercise the ordinary encoder and broadcaster; the API does not introduce an
additional packet or timing model.
Run the focused tests and strict checks from the workspace root:
```sh
cargo test -p horizon-client-render --lib client_update
cargo test -p horizon-client-lib --lib transient_client
cargo clippy -p horizon-client-render -p horizon-client-lib --all-targets -- -D warnings
```
The first command runs three entity tests; the second runs three save/chunk/Hub
tests. They need no prepared game resources. The Hub test opens an ephemeral
loopback listener. The reusable crate is `horizon-client-lib`, whose source lives
under `crates/horizon-client/src`; selecting the startup `horizon-client` package
does not run these reusable tests.
+6 -5
View File
@@ -115,11 +115,12 @@ and some are waiting for Ludovic:
(#1024), which removes anything a pull request left in the work folder by accident, but
not a deliberate attack. The protection is review: only agents and maintainers push to this repository, and a
maintainer reads a fork's change before labelling it.
- **The first install** with `install.sh` checks the signature when OpenSSL 3 is installed: it verifies
`SHA256SUMS.sig` (a Nightly's `SHA256SUMS.v2.sig`, for its own version) against the release key built into the
script, then the archive against `SHA256SUMS`. With
an older OpenSSL, LibreSSL (what macOS ships) or none, it says so and checks only the SHA-256 GitHub
publishes. `install.ps1` still checks only that SHA-256. Every update after that checks the signature.
- **The first install** with `install.sh` or `install.ps1` checks the signature when OpenSSL 3 is installed: it
verifies `SHA256SUMS.sig` (a Nightly's `SHA256SUMS.v2.sig`, for its own version) against the release key built
into the script, then the archive against `SHA256SUMS`. `install.ps1` looks for OpenSSL on the PATH and in Git
for Windows, which ships it; Windows itself can't check Ed25519. With an older OpenSSL, LibreSSL (what macOS
ships) or none, the script says so and checks only the SHA-256 GitHub publishes. Every update after that checks
the signature.
## Reporting a security problem
+26 -9
View File
@@ -181,23 +181,32 @@ dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). A hand-over id is
`transfer-status` is what a proxy reads before it sends players to a server. It names no player and changes nothing:
```json
{"ok": true, "players": 12, "held": 1, "expected": 2, "frozen": 0, "online": 13, "max_players": 20, "full": false}
{"ok": true, "players": 12, "held": 1, "expected": 2, "frozen": 0, "online": 13, "max_players": 20, "full": false, "stopping": false}
```
`players` are the players placed in the world (a player a `moved` release took out is no longer counted), `held`
the logins held for a hand-over, `expected` the expectations still alive, and `frozen` the players this server is
handing over. `online` is what the player limit counts: the placed and the held players and the logins still
joining. `full` is `online` at `max_players` or above: an ordinary login, held or not, is then refused as
`multiplayer.disconnect.server_full`, unless the player is an operator with `bypassesPlayerLimit`. A proxy asks it
often, so it writes nothing to the log.
`multiplayer.disconnect.server_full`, unless the player is an operator with `bypassesPlayerLimit`. `player-expect`
counts the expected players too: it is refused once `online` and `expected` reach `max_players`. `stopping` is true
once the server has begun to stop, and no player is taken in any more. A proxy asks it often, so it writes nothing to
the log.
`player-expect <uuid> [seconds]` tells the target server that the player is about to log in: within
`expect_seconds` by default, `max_expect_seconds` at most (a longer time is cut to it), and at most
`max_expected` players are expected at once. The reply is `{"ok": true, "renewed": false, "ttl_seconds": 10}`;
asking again for the same player renews the time and says `"renewed": true`. It is refused for a missing or
malformed id, a time that is not a whole number of seconds above zero, a player already connected, and a full
table. The player's next login uses the expectation up as it starts, once the bans, the whitelist and the player
limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A
malformed id, a time that is not a whole number of seconds above zero, a player already connected, a full table,
a full server (`{"error": "the server is full"}`) and a server that is stopping (`{"error": "the server is
stopping"}`). Each expectation holds a place until it is used up or runs out: once the places the player limit
counts and those of the other expected players reach `max-players`, a new one is refused as a login would be, so two
proxies sending players at once do not fill the server past its limit. As at login, an operator with
`bypassesPlayerLimit` is never refused as full. The places are held against other expectations only: an ordinary
login (one nobody expected) sees the vanilla limit alone, so it can take a place an expected player was counting on.
That player's login is then refused as `server_full`, and the proxy aborts the hand-over on the source, where the
player goes on as it was. `full` in `transfer-status` is likewise the vanilla condition alone. The player's next
login uses the expectation up as it starts, once the bans, the whitelist and the player limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A
login that then fails before it reaches play (its connection drops in configuration, or the client refuses a
required pack) gives the expectation back with its own deadline, so the proxy's next attempt within that time is
still expected. An expectation no login used is reported in the log when its time runs out.
@@ -423,6 +432,12 @@ Every step that is not taken in time is undone by itself: an expectation after `
`max_hold_seconds`, a freeze after `max_freeze_seconds`, each with a warning in the log. A `moved` that comes after
the freeze ran out is refused, so the proxy must confirm and release within `max_freeze_seconds` of the export.
**A server that stops.** From the `/stop` command (the console's, a player's or the control endpoint's) or a stop
signal on, `player-expect`, `player-import` and `player-confirm` are refused `{"error": "the server is stopping"}`: a
player must not arrive on a server that is about to close. The proxy drops the held player there and aborts on the
source. A retry after the commit is still answered, and `player-export`, `player-release` and `player-drop` stay
open: they are the way out.
**In the log.** Each `player-*` request writes one line with what became of it: `INFO` and what it did
(`player-import Alex (<uuid>): ok, parts inventory,experience, placed false, announced true, recentered false,
hand-over <id>`), or `WARN` and the reason it was refused (`player-export Alex (<uuid>): refused: that player is
@@ -487,8 +502,9 @@ comes out of the headless-engine work.
- Linux and macOS:
`curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh`
The script picks the release archive for the OS and architecture (on Linux x86_64 the server archive,
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the SHA-256 GitHub
publishes for each asset. It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the release's signed
`SHA256SUMS` when OpenSSL 3 is installed, else against the SHA-256 GitHub publishes for each asset
(docs/security.md). It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
`~/.local/share/horizon`), `--bin` (default `~/.local/bin`), and `--archive FILE` to install an archive
already on the machine (a build of your own; no download and no checksum).
A Linux release from before the server archive installs the full game, which links ALSA's and udev's
@@ -499,7 +515,8 @@ comes out of the headless-engine work.
- Windows:
`irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex`
The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of
links (no admin rights), and adds `bin` to the user's PATH.
links (no admin rights), and adds `bin` to the user's PATH. It finds OpenSSL 3 on the PATH or in Git for
Windows to check the signature.
- **Layout.** Each release goes to `<dir>/releases/<tag>` and `<dir>/current` points at the one in use, so an
update or a rollback is one link. `<bin>/horizon-server` and `<bin>/horizon-prepare` are two-line launchers
that run `current`. The prepared game files live in `<dir>/game-files/horizon-client` and each release's
+103 -9
View File
@@ -1,6 +1,7 @@
# Installs horizon-server (docs/server.md) on Windows: the current Horizon Nightly (the only build Horizon ships;
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the SHA-256
# GitHub publishes for it.
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the release's
# signed SHA256SUMS when OpenSSL 3 is found (docs/release-flow.md, "Signed checksums"), else against the SHA-256
# GitHub publishes for it. OpenSSL is looked for on the PATH and in Git for Windows, which ships it.
#
# irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
# & ([scriptblock]::Create((irm .../install.ps1))) -Version v0.5.0 -Dir D:\Horizon
@@ -16,6 +17,9 @@ $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Repo = "horizon-rust/horizon"
# Horizon's release public keys, base64: copy of crates/horizon-release-sign/release-keys.txt (rotation adds a key
# there first; keep the two in step, and install.sh's RELEASE_KEYS too).
$ReleaseKeys = @("MU4oiIQcCxkZvbwSjjXs7SySCuM6ywRQ3nGaJYZG0gw=")
$Fallback = $null
$Architecture = $env:PROCESSOR_ARCHITEW6432
if (-not $Architecture) { $Architecture = $env:PROCESSOR_ARCHITECTURE }
@@ -35,11 +39,18 @@ if ($Version -eq "latest") {
$Release = Get-Release "tags/$Version"
}
$Tag = $Release.tag_name
$RawTag = $Tag
# A Nightly installs under its date and commit, as `horizon-server update` names it:
# "Nightly 2026-10-28 <middle dot> 0c02067" is nightly-2026-10-28-0c02067 ([char]0xB7: this file stays ASCII).
if (($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) -and
$Release.name -match "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{1,40})$") {
$Tag = "nightly-$($Matches[1])-$($Matches[2])"
# Its signature is checked for exactly that version, so the title must name it (as
# horizon_release_sign::nightly_title_version reads it, strictly).
$Nightly = $null
if ($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) {
if ($Release.name -cnotmatch "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{7,40})$") {
throw "This Nightly's title doesn't name its date and commit as Horizon signs them, so it can't be checked: not installed"
}
$Nightly = "nightly-$($Matches[1])-$($Matches[2])"
$Tag = $Nightly
}
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
if (-not $Item -and $Fallback) {
@@ -48,18 +59,101 @@ if (-not $Item -and $Fallback) {
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
}
if (-not $Item) { throw "Release $Tag has no $Asset" }
if (-not $Item.digest -or -not $Item.digest.StartsWith("sha256:")) { throw "Release $Tag publishes no SHA-256 for $Asset" }
$Digest = $Item.digest.Substring(7)
$Digest = $null
if ($Item.digest -and $Item.digest.StartsWith("sha256:")) { $Digest = $Item.digest.Substring(7) }
# The first OpenSSL 3 on the PATH or in Git for Windows (its usr\bin and mingw64\bin), else what was found instead.
function Find-OpenSsl {
$Candidates = @()
$OnPath = Get-Command openssl -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($OnPath) { $Candidates += $OnPath.Source }
$Git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($Git) {
# git.exe is in Git\cmd, Git\bin or Git\mingw64\bin.
$Folder = Split-Path $Git.Source
foreach ($Up in 1, 2) {
$Folder = Split-Path $Folder
if (-not $Folder) { break }
foreach ($Sub in "usr\bin\openssl.exe", "mingw64\bin\openssl.exe") {
$Path = Join-Path $Folder $Sub
if (Test-Path -LiteralPath $Path -PathType Leaf) { $Candidates += $Path }
}
}
}
$Other = $null
foreach ($Path in $Candidates) {
$ErrorActionPreference = "Continue"
$Said = (& $Path version 2>$null | Select-Object -First 1)
if ("$Said" -match "^OpenSSL ([3-9]|[1-9][0-9])\.") { return @{ Path = $Path } }
if (-not $Other -and $Said) { $Other = ("$Said" -split " ")[0..1] -join " " }
}
return @{ Path = $null; Other = $Other }
}
$Tmp = Join-Path ([IO.Path]::GetTempPath()) ("horizon-install-" + [Guid]::NewGuid())
New-Item -ItemType Directory -Path $Tmp | Out-Null
try {
# The signature (docs/release-flow.md, "Signed checksums"): SHA256SUMS.sig holds one base64 Ed25519 signature
# per line over "horizon release <tag>\n" + SHA256SUMS. A Nightly is checked against SHA256SUMS.v2.sig instead,
# signed for its own version (nightly-<date>-<commit>, from its title), never the channel's SHA256SUMS.sig: an
# older Nightly can't be passed off as the current one (docs/security.md, "Nightly signatures"). Windows can't
# check Ed25519 by itself, so this needs OpenSSL 3; without it the install says so and keeps GitHub's SHA-256.
$Expect = $Digest
if ($Nightly) { $SigTag = $Nightly; $SigFile = "SHA256SUMS.v2.sig" } else { $SigTag = $RawTag; $SigFile = "SHA256SUMS.sig" }
$OpenSsl = Find-OpenSsl
if (-not $OpenSsl.Path -and $OpenSsl.Other) {
Write-Host "Note: $($OpenSsl.Other) can't check Ed25519 signatures (OpenSSL 3 is needed), so the release signature was not checked; checking GitHub's SHA-256 only."
} elseif (-not $OpenSsl.Path) {
Write-Host "Note: OpenSSL isn't installed (Git for Windows includes it), so the release signature was not checked; checking GitHub's SHA-256 only."
} else {
$SumsItem = $Release.assets | Where-Object { $_.name -eq "SHA256SUMS" } | Select-Object -First 1
$SigItem = $Release.assets | Where-Object { $_.name -eq $SigFile } | Select-Object -First 1
if ($Nightly -and (-not $SumsItem -or -not $SigItem)) {
throw "$Tag has no $SigFile (its signature for this exact build): it was published before Nightlies were signed for their own version, so it can't be checked. Install the current Nightly (-Version latest) instead."
} elseif (-not $SumsItem -or -not $SigItem) {
Write-Host "Note: release $Tag has no signed checksums (it was published before signing began), so the signature was not checked; checking GitHub's SHA-256 only."
} else {
$Sums = Join-Path $Tmp "SHA256SUMS"
$Sigs = Join-Path $Tmp "SHA256SUMS.sig"
Invoke-WebRequest -UseBasicParsing -Uri $SumsItem.browser_download_url -OutFile $Sums
Invoke-WebRequest -UseBasicParsing -Uri $SigItem.browser_download_url -OutFile $Sigs
$Message = Join-Path $Tmp "message"
[IO.File]::WriteAllBytes($Message, [byte[]]([Text.Encoding]::ASCII.GetBytes("horizon release $SigTag`n") + [IO.File]::ReadAllBytes($Sums)))
$KeyDer = Join-Path $Tmp "key.der"
$Signature = Join-Path $Tmp "signature"
$Signed = $false
foreach ($Key in $ReleaseKeys) {
# The raw 32-byte key in an Ed25519 SubjectPublicKeyInfo, the form OpenSSL reads.
$Prefix = [byte[]](0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00)
[IO.File]::WriteAllBytes($KeyDer, [byte[]]($Prefix + [Convert]::FromBase64String($Key)))
foreach ($Line in [IO.File]::ReadAllLines($Sigs)) {
if (-not $Line.Trim()) { continue }
try { [IO.File]::WriteAllBytes($Signature, [Convert]::FromBase64String($Line.Trim())) } catch { continue }
$ErrorActionPreference = "Continue"
& $OpenSsl.Path pkeyutl -verify -rawin -pubin -keyform DER -inkey $KeyDer -in $Message -sigfile $Signature 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $Signed = $true }
$ErrorActionPreference = "Stop"
}
}
if (-not $Signed) {
throw "$Tag's SHA256SUMS signature doesn't match Horizon's release key: not installed. The download may have been tampered with; please report it on Discord or GitHub."
}
$Expect = $null
foreach ($Line in [IO.File]::ReadAllLines($Sums)) {
if ($Line -match "^([0-9a-fA-F]{64}) [ *](.+)$" -and $Matches[2] -eq $Asset) { $Expect = $Matches[1]; break }
}
if (-not $Expect) { throw "The signed SHA256SUMS doesn't list ${Asset}: not installed" }
Write-Host "Release signature OK (Horizon release key)."
}
}
if (-not $Expect) { throw "Release $Tag publishes no SHA-256 for $Asset" }
Write-Host "Downloading Horizon $Tag ($Asset)..."
$Zip = Join-Path $Tmp $Asset
Invoke-WebRequest -Uri $Item.browser_download_url -OutFile $Zip
$Got = (Get-FileHash -Algorithm SHA256 $Zip).Hash.ToLowerInvariant()
if ($Got -ne $Digest.ToLowerInvariant()) {
throw "$Asset doesn't match its SHA-256 (got $Got, expected $Digest): not installed"
if ($Got -ne $Expect.ToLowerInvariant()) {
throw "$Asset doesn't match its SHA-256 (got $Got, expected $Expect): not installed"
}
$Unpacked = Join-Path $Tmp "x"
Expand-Archive -Path $Zip -DestinationPath $Unpacked
+228
View File
@@ -0,0 +1,228 @@
# PURPOSE: proves packaging/server/install.ps1 checks the release signature (docs/release-flow.md, "Signed checksums").
# WHY: the first install on Windows used to trust only the SHA-256 GitHub publishes (docs/security.md).
# REFS: tests/install_sh_signature.sh (the same cases for install.sh), tools/ci/sign_release.sh (the format signed here).
# LOGS: prints one ok/FAIL line per case; exit 1 when any case fails.
# READS-BY: tests/test_install_ps1_signature.py (unittest wrapper), by hand: pwsh -File tests/install_ps1_signature.ps1
# ENDS-WHEN: it ends by itself; leaves nothing outside its own temporary folder.
# Windows only, Windows PowerShell 5.1 or PowerShell 7. Needs OpenSSL 3 (to sign the throwaway releases); skips
# (exit 77) without it. Invoke-RestMethod and Invoke-WebRequest are replaced by fakes that serve local files, and the
# user's PATH is never written, so nothing touches the network or the account.
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Root = Split-Path -Parent $PSScriptRoot
$Installer = Join-Path $Root "packaging\server\install.ps1"
function Find-TestOpenSsl {
$Paths = @()
$OnPath = Get-Command openssl -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($OnPath) { $Paths += $OnPath.Source }
$Git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($Git) { $Paths += Join-Path (Split-Path (Split-Path $Git.Source)) "usr\bin\openssl.exe" }
foreach ($Path in $Paths) {
if (-not (Test-Path -LiteralPath $Path)) { continue }
if ("$(& $Path version 2>$null)" -match "^OpenSSL [3-9]\.") { return $Path }
}
return $null
}
$Ssl = Find-TestOpenSsl
if (-not $Ssl) { Write-Output "skip: OpenSSL 3 needed"; exit 77 }
$W = Join-Path ([IO.Path]::GetTempPath()) ("horizon-ps1-test-" + [Guid]::NewGuid())
New-Item -ItemType Directory -Path $W | Out-Null
# The installer always picks the x64 archive, the one these releases hold, whatever this machine is.
$env:PROCESSOR_ARCHITECTURE = "AMD64"
$env:PROCESSOR_ARCHITEW6432 = $null
$SavedPath = $env:PATH
$Asset = "horizon-windows-x64.zip"
$RealKey = "MU4oiIQcCxkZvbwSjjXs7SySCuM6ywRQ3nGaJYZG0gw="
$script:Fails = 0
$global:HorizonTestRel = $null
# The user's PATH stays as it is: the copies of install.ps1 write through this class instead of [Environment].
Add-Type -TypeDefinition @"
public static class FakeEnvironment {
public static string GetEnvironmentVariable(string name, string scope) { return ""; }
public static void SetEnvironmentVariable(string name, string value, string scope) { }
}
"@
# The fakes: the API answers with the case's release, any download with the case's file of that name.
function Invoke-RestMethod { param($Uri, $Headers) $global:HorizonTestRel.Release }
function Invoke-WebRequest {
param($Uri, $OutFile, [switch]$UseBasicParsing)
$Source = Join-Path $global:HorizonTestRel.Files ($Uri -split "/")[-1]
if (-not (Test-Path -LiteralPath $Source)) { throw "404 $Uri" }
Copy-Item -LiteralPath $Source -Destination $OutFile
}
function Write-Ascii([string]$Path, [string]$Text) { [IO.File]::WriteAllText($Path, $Text, [Text.Encoding]::ASCII) }
function Get-Sha([string]$Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() }
function Get-Title([string]$Tag, [string]$Also) {
if ($Also) { "Nightly $($Also.Substring(8, 10)) $([char]0xB7) $($Also.Substring(19))" } else { $Tag }
}
# One base64 signature line over "horizon release <tag>\n" + SHA256SUMS, as tools/ci/sign_release.sh writes it.
function New-SignatureLine([string]$Dir, [string]$Tag) {
$Message = Join-Path $Dir "message"
[IO.File]::WriteAllBytes($Message, [byte[]]([Text.Encoding]::ASCII.GetBytes("horizon release $Tag`n") + [IO.File]::ReadAllBytes((Join-Path $Dir "files\SHA256SUMS"))))
& $Ssl pkeyutl -sign -rawin -inkey (Join-Path $Dir "key") -keyform DER -in $Message -out (Join-Path $Dir "sig.bin")
[Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $Dir "sig.bin"))) + "`n"
}
# A fake release: a zip holding horizon-server.exe, signed for Tag with a throwaway key. A Nightly (Tag nightly) is
# also signed for its own version Also (nightly-<date>-<commit>) in SHA256SUMS.v2.sig, titled as nightly-build.yml
# titles it.
function New-Release([string]$Dir, [string]$Tag, [string]$Also = "") {
New-Item -ItemType Directory -Force -Path (Join-Path $Dir "src\horizon-server-x"), (Join-Path $Dir "files") | Out-Null
Write-Ascii (Join-Path $Dir "src\horizon-server-x\horizon-server.exe") "fake"
Compress-Archive -Path (Join-Path $Dir "src\horizon-server-x") -DestinationPath (Join-Path $Dir "files\$Asset")
Write-Ascii (Join-Path $Dir "files\SHA256SUMS") "$(Get-Sha (Join-Path $Dir "files\$Asset")) $Asset`n"
& $Ssl genpkey -algorithm ed25519 -outform DER -out (Join-Path $Dir "key")
& $Ssl pkey -inform DER -in (Join-Path $Dir "key") -pubout -outform DER -out (Join-Path $Dir "pub.der")
$Pub = [IO.File]::ReadAllBytes((Join-Path $Dir "pub.der"))
$script:Key = [Convert]::ToBase64String($Pub[($Pub.Length - 32)..($Pub.Length - 1)])
Write-Ascii (Join-Path $Dir "files\SHA256SUMS.sig") (New-SignatureLine $Dir $Tag)
if ($Also) { Write-Ascii (Join-Path $Dir "files\SHA256SUMS.v2.sig") (New-SignatureLine $Dir $Also) }
Set-Release $Dir $Tag $true (Get-Title $Tag $Also)
Set-Installer $Dir $script:Key
}
# GitHub's release, as Invoke-RestMethod returns it: the archive with its digest, and the checksum files.
function Set-Release([string]$Dir, [string]$Tag, [bool]$Sums, [string]$Title = $Tag) {
$Assets = @([pscustomobject]@{
name = $Asset; digest = "sha256:$(Get-Sha (Join-Path $Dir "files\$Asset"))"
browser_download_url = "https://dl.test/$Asset"
})
if ($Sums) {
foreach ($Name in "SHA256SUMS", "SHA256SUMS.sig", "SHA256SUMS.v2.sig") {
if (Test-Path -LiteralPath (Join-Path $Dir "files\$Name")) {
$Assets += [pscustomobject]@{ name = $Name; browser_download_url = "https://dl.test/$Name" }
}
}
}
$script:Releases[$Dir] = [pscustomobject]@{ tag_name = $Tag; name = $Title; assets = $Assets }
}
$script:Releases = @{}
# install.ps1 with the throwaway public key in place of the release key, and the fake environment.
function Set-Installer([string]$Dir, [string]$Key) {
$Text = [IO.File]::ReadAllText($Installer)
$Patched = $Text -replace '(?m)^\$ReleaseKeys = .*$', "`$ReleaseKeys = @(`"$Key`")" -replace '\[Environment\]::', '[FakeEnvironment]::'
if (-not $Patched.Contains($Key) -or $Patched.Contains("[Environment]::")) { throw "could not patch install.ps1" }
Write-Ascii (Join-Path $Dir "install.ps1") $Patched
}
# Runs the case's install.ps1 with PATH set to Path; returns @{ Ok; Out }.
function Invoke-Case([string]$Dir, [string]$Path, [string[]]$Arguments = @()) {
$global:HorizonTestRel = [pscustomobject]@{ Release = $script:Releases[$Dir]; Files = Join-Path $Dir "files" }
$env:PATH = $Path
$Ok = $true
$Out = try {
& (Join-Path $Dir "install.ps1") -Dir (Join-Path $Dir "inst") @Arguments 6>&1 | Out-String
} catch { $Ok = $false; $_.Exception.Message }
$env:PATH = $SavedPath
@{ Ok = $Ok; Out = "$Out" }
}
function Test-Case([string]$Name, [bool]$WantOk, [string]$Pattern, $Result) {
if ($Result.Ok -eq $WantOk -and (-not $Pattern -or $Result.Out.Contains($Pattern))) {
Write-Output "ok $Name"
} else {
Write-Output "FAIL $Name (installed: $($Result.Ok), wanted $WantOk, pattern '$Pattern')"
Write-Output (($Result.Out -split "`n" | ForEach-Object { " $_" }) -join "`n")
$script:Fails++
}
}
function Test-That([string]$Name, [bool]$Condition) {
if (-not $Condition) { Write-Output "FAIL $Name"; $script:Fails++ }
}
try {
# The cases get the OpenSSL 3 found above, alone on their PATH.
$P = Split-Path $Ssl
$Empty = Join-Path $W "empty"; New-Item -ItemType Directory -Path $Empty | Out-Null
$Nightly = "nightly-2026-11-03-0c02067"
# 1. Good signature, a Nightly: SHA256SUMS.v2.sig signed for the version its title names.
$d = Join-Path $W "good"; New-Release $d "nightly" $Nightly
$r = Invoke-Case $d $P; Test-Case "good signature installs (Nightly, signed for its own version)" $true "Release signature OK" $r
Test-That "nothing installed" (Test-Path (Join-Path $d "inst\current\horizon-server.exe"))
Test-That "not installed as $Nightly" ($r.Out.Contains("Downloading Horizon $Nightly "))
# 1b. A Nightly with only the channel's signature (published before v2): refused, never the channel's signature.
$d = Join-Path $W "channel"; New-Release $d "nightly"; Set-Release $d "nightly" $true (Get-Title "nightly" $Nightly)
$r = Invoke-Case $d $P; Test-Case "Nightly without SHA256SUMS.v2.sig refused" $false "has no SHA256SUMS.v2.sig" $r
Test-That "a Nightly without v2 was installed" (-not (Test-Path (Join-Path $d "inst\current")))
# 1c. An older Nightly's files and signature retitled as a newer one: its v2 signature names the old version.
$d = Join-Path $W "rollback"; New-Release $d "nightly" "nightly-2026-11-01-1111111"
Set-Release $d "nightly" $true (Get-Title "nightly" $Nightly)
$r = Invoke-Case $d $P; Test-Case "older Nightly passed off as a newer one refused" $false "signature doesn't match" $r
# 1d. A Nightly whose title doesn't name its date and commit: refused, not dated some other way.
$d = Join-Path $W "title"; New-Release $d "nightly" $Nightly; Set-Release $d "nightly" $true "Nightly"
$r = Invoke-Case $d $P; Test-Case "Nightly with an unreadable title refused" $false "title doesn't name its date and commit" $r
# 2. Good signature, a normal tag.
$d = Join-Path $W "tag"; New-Release $d "v1.2.3"
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "good signature installs (release tag)" $true "Release signature OK" $r
# 3. Tampered archive: GitHub's digest is made to agree with the swapped archive, only SHA256SUMS catches it.
$d = Join-Path $W "arc"; New-Release $d "v1.2.3"
Add-Content -LiteralPath (Join-Path $d "files\$Asset") -Value "x"; Set-Release $d "v1.2.3" $true
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "tampered archive refused" $false "doesn't match its SHA-256" $r
Test-That "tampered archive was installed" (-not (Test-Path (Join-Path $d "inst\current")))
# 4. Tampered SHA256SUMS (the archive's line swapped for the hash of the tampered archive).
$d = Join-Path $W "sums"; New-Release $d "v1.2.3"
Add-Content -LiteralPath (Join-Path $d "files\$Asset") -Value "x"
Write-Ascii (Join-Path $d "files\SHA256SUMS") "$(Get-Sha (Join-Path $d "files\$Asset")) $Asset`n"; Set-Release $d "v1.2.3" $true
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "tampered SHA256SUMS refused" $false "signature doesn't match" $r
# 5. A signature made for another tag does not pass (a release's list replayed under another tag).
$d = Join-Path $W "replay"; New-Release $d "v1.2.3"; Set-Release $d "v9.9.9" $true
$r = Invoke-Case $d $P @("-Version", "v9.9.9"); Test-Case "signature replayed under another tag refused" $false "signature doesn't match" $r
# 6. Signed by a key that is not the release key.
$d = Join-Path $W "wrong"; New-Release $d "v1.2.3"; Set-Installer $d $RealKey
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "wrong key refused" $false "signature doesn't match" $r
# 7. No OpenSSL: says so, keeps the SHA-256 check.
$d = Join-Path $W "none"; New-Release $d "v1.2.3"
$r = Invoke-Case $d $Empty @("-Version", "v1.2.3"); Test-Case "no OpenSSL: installs, says it was not checked" $true "OpenSSL isn't installed" $r
$d = Join-Path $W "none2"; New-Release $d "v1.2.3"; $script:Releases[$d].assets[0].digest = "sha256:0000"
$r = Invoke-Case $d $Empty @("-Version", "v1.2.3"); Test-Case "no OpenSSL: a wrong GitHub SHA-256 still refused" $false "doesn't match its SHA-256" $r
# 8. Old OpenSSL and LibreSSL: says so, keeps the SHA-256 check.
foreach ($v in "OpenSSL 1.1.1w 11 Sep 2023", "LibreSSL 3.3.6") {
$d = Join-Path $W ("old-" + $v.Split(" ")[0]); New-Release $d "v1.2.3"
$Bin = Join-Path $d "old-bin"; New-Item -ItemType Directory -Path $Bin | Out-Null
Write-Ascii (Join-Path $Bin "openssl.cmd") "@echo $v`r`n"
$r = Invoke-Case $d $Bin @("-Version", "v1.2.3"); Test-Case "$($v.Split(' ')[0]): installs, says it can't check signatures" $true "can't check Ed25519" $r
}
# 9. A release published before signing (no SHA256SUMS): says so, keeps the SHA-256 check.
$d = Join-Path $W "legacy"; New-Release $d "v1.2.3"; Set-Release $d "v1.2.3" $false
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "unsigned release: installs, says so" $true "no signed checksums" $r
# 10. OpenSSL found in Git for Windows, not on the PATH: git.exe in Git\cmd, OpenSSL in Git\usr\bin (or
# Git\mingw64\bin). Only when this machine's OpenSSL is such a copy, which the fake Git folder links to.
$Up = Split-Path $P
if ((Split-Path $P -Leaf) -eq "bin" -and (Split-Path $Up -Leaf) -in "usr", "mingw64") {
$d = Join-Path $W "git"; New-Release $d "v1.2.3"
$GitDir = Join-Path $d "Git"; New-Item -ItemType Directory -Path (Join-Path $GitDir "cmd") | Out-Null
Write-Ascii (Join-Path $GitDir "cmd\git.exe") ""
New-Item -ItemType Junction -Path (Join-Path $GitDir (Split-Path $Up -Leaf)) -Target $Up | Out-Null
$r = Invoke-Case $d (Join-Path $GitDir "cmd") @("-Version", "v1.2.3"); Test-Case "OpenSSL from Git for Windows checks the signature" $true "Release signature OK" $r
(Get-Item (Join-Path $GitDir (Split-Path $Up -Leaf))).Delete()
} else {
Write-Output "skip OpenSSL from Git for Windows (this OpenSSL isn't Git's)"
}
} finally {
$env:PATH = $SavedPath
Remove-Item -Recurse -Force $W -ErrorAction SilentlyContinue
}
Write-Output "$($script:Fails) case(s) failed"
if ($script:Fails -ne 0) { exit 1 }
exit 0
+35
View File
@@ -0,0 +1,35 @@
"""Runs tests/install_ps1_signature.ps1: packaging/server/install.ps1 checks the release signature.
PURPOSE: keep install.ps1's Ed25519 check from regressing (docs/release-flow.md, "Signed checksums").
REFS: tests/install_ps1_signature.ps1, tests/test_install_sh_signature.py (the same for install.sh).
Skips quietly off Windows and without PowerShell or OpenSSL 3. Headless, no network.
"""
import os
import shutil
import subprocess
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("install_ps1_signature.ps1")
class InstallPs1Signature(unittest.TestCase):
# install.ps1 installs the Windows server: junctions, %LOCALAPPDATA%, the user's PATH.
@unittest.skipUnless(os.name == "nt", "install.ps1 is for Windows")
@unittest.skipUnless(shutil.which("pwsh") or shutil.which("powershell"), "PowerShell needed")
def test_signature_cases(self):
shell = shutil.which("pwsh") or shutil.which("powershell")
run = subprocess.run(
[shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(SCRIPT)],
capture_output=True,
text=True,
timeout=300,
)
if run.returncode == 77:
self.skipTest(run.stdout.strip())
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+71 -58
View File
@@ -13215,15 +13215,9 @@ def prepare_recipes_hmod(*games):
(settings / 'give').write_text('false')
def check_recipes_hmod(host, guest, out):
"""Recipes' real panel -> normal server menu -> custom output -> save/rejoin (#9647).
Opt in with --check recipes-hmod and --bin-dir pointing at a game compiled with the official
native Recipes mod. The fixture is ours; no Java mod, copied assets or synthetic game replies.
"""
def exercise_recipes_hmod(game, out, mode, supplied):
"""Use the real Recipes panel and the authoritative crafting table."""
item = 'minecraft:echo_shard'
recipe = 'recipe_pilot:flint_to_echo_shard'
seen = {}
def widget(game, predicate):
matches = [w for w in game.raw('ui')['widgets'] if predicate(w)]
@@ -13256,70 +13250,89 @@ def check_recipes_hmod(host, guest, out):
def contents(game):
return {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, item)}
for game, supplied in ((host, 4), (guest, 5)):
game('command', command='clear @s')
server_reply(game, 'recipe take @s *', 'recipe')
game('command', command=f'gamemode {mode}')
game.wait(10)
game.until(lambda: game('state')['gamemode'], lambda current: current == mode)
game('command', command='clear @s')
game('command', command='give @s minecraft:dirt 1')
table = None
if mode == 'creative':
x, y, z = (math.floor(v) for v in pos_of(game))
table = (x, y, z - 2)
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
game.wait(10)
game.raw('interact', *table)
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
assert game('screen').get('craft_size') == 3
else:
game.raw('key', 'e', 'tap')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
assert game('screen').get('craft_size') == (3 if table else 2)
# The output exists in vanilla; its flint recipe is original .hmod content.
search(game, 'recipe_pilot:no_such_item')
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
search(game, item)
game.shot(out, 'recipes-hmod-search')
unknown = page(game)
assert unknown['enabled'] is False, f'{game.name}: unknown recipe can be placed: {unknown}'
game.shot(out, 'recipes-hmod-unknown')
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0]
# Put an unrelated item in the grid so a refused transfer cannot pass by
# merely clearing an already empty grid.
game.raw('click-slot', hotbar_slot(game, 'minecraft:dirt'))
game.raw('click-slot', 100)
game.wait(10)
before = grid_of(game, 100, 109)
assert before[100] == ('minecraft:dirt', 1), before
assert not result_of(game)[0], f'{mode}: control ingredient must have no output'
server_reply(game, 'recipe take @s *', 'recipe')
game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0)
server_reply(game, f'recipe give @s {recipe}', 'recipe')
game.wait(10)
back = widget(game, lambda w: w.get('label') == 'Back')
game.raw('click', back['id'])
game.wait(5)
known = page(game)
assert known['enabled'] is True, f'{game.name}: learned custom recipe unavailable: {known}'
game.raw('click', known['id'])
game.wait(10)
missing = contents(game)
assert missing == {'flint': 0, 'echo_shard': 0}, missing
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0], f'{game.name}: missing ingredients fabricated a result'
game.shot(out, 'recipes-hmod-missing')
search(game, 'recipe_pilot:no_such_item')
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
search(game, item)
transfer = page(game)
assert transfer['enabled'], f'{mode}: + must be available before unlock'
game.raw('click', transfer['id'])
game.until(lambda: game.raw('ui')['widgets'],
lambda widgets: any(w.get('label') == 'Missing Items' for w in widgets))
assert grid_of(game, 100, 109) == before, f'{mode}: missing ingredients changed the grid'
assert not result_of(game)[0], f'{mode}: missing ingredients fabricated a result'
missing = contents(game)
assert missing == {'flint': 0, 'echo_shard': 0}, missing
game.shot(out, f'{mode}-missing')
# Supply the input through the server, then use only the panel's normal + action.
game('command', command=f'give @s minecraft:flint {supplied}')
game.wait(10)
known = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
game.raw('click', known['id'])
game.until(lambda: result_of(game), lambda r: bool(r[0]))
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
result = result_of(game)
assert grid == [('minecraft:flint', 1)], f'{game.name}: filled grid {grid}'
# Independent expected yield: changing the fixture to yield one must fail here.
assert result == (item, 2), f'{game.name}: custom result {result}'
game.shot(out, 'recipes-hmod-filled')
game.raw('click-slot', 200, 'shift')
game.wait(10)
assert not result_of(game)[0], f'{game.name}: consumed input left another result'
back_to_game(game)
crafted = contents(game)
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
seen[game.name] = {'missing': missing, 'grid': grid, 'result': result, 'crafted': crafted}
game('command', command=f'give @s minecraft:flint {supplied}')
server_reply(game, 'recipe take @s *', 'recipe')
game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0)
transfer = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
game.raw('click', transfer['id'])
game.until(lambda: result_of(game), lambda r: bool(r[0]))
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
result = result_of(game)
assert grid == [('minecraft:flint', 1)], f'{mode}: filled grid {grid}'
assert result == (item, 2), f'{mode}: custom result {result}'
assert game('screen')['recipe_book']['known'] == 0, f'{mode}: transfer learned a recipe'
assert server_count(game, 'dirt') == 1, f'{mode}: displaced ingredient was lost'
assert server_count(game, 'flint') == supplied - 1, f'{mode}: transfer fabricated an input'
assert not any(w.get('label') == 'Missing Items' for w in game.raw('ui')['widgets'])
game.wait(140)
game.shot(out, f'{mode}-filled')
game.raw('click-slot', 200, 'shift')
game.wait(10)
assert not result_of(game)[0], f'{mode}: consumed input left another result'
back_to_game(game)
crafted = contents(game)
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
if table:
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
return {'mode': mode, 'missing': missing, 'warning': 'Missing Items',
'grid_unchanged_on_failure': True, 'known_before_and_after_fill': 0,
'grid': grid, 'result': result, 'crafted': crafted}
def check_recipes_hmod(host, guest, out):
"""Exercise Survival and Creative through the real host/guest server."""
seen = {
host.name: exercise_recipes_hmod(host, out, 'survival', 4),
guest.name: exercise_recipes_hmod(guest, out, 'creative', 5),
}
save_and_reopen(host, guest)
for game in (host, guest):
persisted = contents(game)
persisted = {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, 'echo_shard')}
assert persisted == seen[game.name]['crafted'], f'{game.name}: after rejoin {persisted}'
seen[game.name]['rejoined'] = persisted
game.shot(out, 'recipes-hmod-rejoined')
game.shot(out, f"{seen[game.name]['mode']}-rejoined")
(out / 'recipes-hmod.json').write_text(json.dumps(seen, indent=2) + '\n')
return seen
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "screen_text_field_char_typed_pre",
"target": "horizon_client::game::native_hooks::Game<'_>::native_screen_text_field_char_typed_pre",
"side": "client",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
}
],
"rust_summary": "the current focused text field, before each character is inserted, with an editable value and caret.",
"doc_summary": "`native_screen_text_field_char_typed_pre(screen, field, character, modifiers, canceled)` exposes NativeTextField before each surviving character is inserted. Edit value/cursor/highlight and cancel to replace the character yourself. Indices count Unicode scalar characters; max_length() and multiline() are read-only host limits. Actual text-box filters, limits and responders still apply. API104 runs exactly once before this hook for each character, including title keyboard/control input, which previously bypassed that filter. Supported: ordinary title fields, chat/command inputs, creative/recipe/mod-browser searches, anvil rename, social and debug search. Options, preparing screens, game-rule/numeric editors, books, signs and dialogs keep their existing specialized paths. Chat suggestions are separate. Bundles need \"hooks\": @API:screen_text_field@."
}
+14 -4
View File
@@ -24,7 +24,8 @@ Steps, in docs/server.md's order ("A hand-over, in order"), each checked:
writes no file for it, before or after it stops.
`transfer-status` is read on the way: after 2 (B expects one player), after 4 (B holds it, A has it placed and
frozen) and after 7 (the player is on B, A has nobody).
frozen) and after 7 (the player is on B, A has nobody). B runs with `max-players=1`: a second player is refused
as full after 2 (the expectation holds the place) and after 7 (the player takes it).
Then the cancels: a hand-over from B back to A is aborted (`player-drop` on A, `player-release ... abort` on B)
and the player plays on on B; a late `moved` for it is refused.
@@ -254,12 +255,12 @@ class Relay:
# ---- an engine and its control socket ------------------------------------------------------------
class Engine:
def __init__(self, bins, resources, folder, out, label):
def __init__(self, bins, resources, folder, out, label, **extra):
self.label = label
self.folder = folder
folder.mkdir(parents=True)
self.port = lab.free_port()
props = dict(PROPERTIES, **{'server-port': str(self.port), 'motd': f'transfer e2e {label}'})
props = dict(PROPERTIES, **extra, **{'server-port': str(self.port), 'motd': f'transfer e2e {label}'})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
(folder / 'forwarding.secret').write_bytes(SECRET + b'\n')
@@ -426,7 +427,9 @@ def run(bins, resources, scratch, out):
relay_log.flush()
a = Engine(bins, resources, scratch / 'a', out, 'a')
b = Engine(bins, resources, scratch / 'b', out, 'b')
# One place on B: the player moved there takes it.
b = Engine(bins, resources, scratch / 'b', out, 'b', **{'max-players': '1'})
stranger = str(offline_uuid('Stranger'))
relays, bots = [], []
try:
a.ready()
@@ -474,6 +477,9 @@ def run(bins, resources, scratch, out):
steps.check('2. transfer-status on B counts the player as expected, and B is not full',
status.get('expected') == 1 and status.get('players') == 0 and status.get('full') is False,
status=status)
reply = b.ask(f'player-expect {stranger}')
steps.check("2. B's one place is the expected player's: a second player-expect is refused as full",
reply == {'error': 'the server is full'}, reply=reply)
# 3. A exports it, frozen; a repeat answers the same bytes.
reply = a.ask(f'player-export {player_uuid} freeze')
@@ -570,6 +576,10 @@ def run(bins, resources, scratch, out):
status_b.get('players') == 1 and status_b.get('held') == 0
and status_a.get('players') == 0 and status_a.get('frozen') == 0,
a=status_a, b=status_b)
reply = b.ask(f'player-expect {stranger}')
steps.check('7. B is full with the player on it: player-expect is refused, and transfer-status says full',
reply == {'error': 'the server is full'} and status_b.get('full') is True
and status_b.get('stopping') is False, reply=reply)
# The cancel: from B back to A, aborted.
reply = a.ask(f'player-expect {player_uuid}')