Compare commits

...
Author SHA1 Message Date
Ludovic Marie db5591ac7f Merge commit '6da84cf1951426a6b5543feadaccfad79fb0ead0' into union/pt3-w2 2026-10-29 21:52:28 +01:00
Ludovic Marie 581de7038e Merge commit 'refs/pr/3441' into union/pt3-w2 2026-10-29 20:35:05 +01:00
Ludovic Marie 8145434642 Merge commit 'd3c6f0d93752fded9985ff2300f6b034f9ce92f9' into union/pt3-w2 2026-10-29 20:35:05 +01:00
Ludovic Marie 39b2275f71 Merge commit '5fb90920730b14b4d977731c392f31e36add9ced' into union/pt3-w2 2026-10-29 20:35:04 +01:00
Ludovic MarieandClaude Opus 5.5 d3c6f0d937 Merge main into #2987 (lan_entities.rs: lan_display_data beside main's lan_entity_uuids)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:34:23 +01:00
Ludovic Marie baa379d717 Merge pull request #3578 from d3grando/fix-3432
🤖 Agent: proven on union/pt1-w3 by pr-triage-1: every vanilla harness this branch reaches passes (mob_ai and the equine ones included), clippy -D warnings and the horizon-client tests pass (the one failing test is main's own, fixed by #3851), and the last commit only reorders an import for rustfmt. Thank you!
2026-10-29 20:31:31 +01:00
Ludovic Marie 28a65af5fc Merge pull request #3848 from horizon-rust/perf/block-light-check-node
🤖 Agent: merging. Block light follows vanilla BlockLightEngine.checkNode (an emitter in its own light only brightens): in-frame relight during the RD 32 ocean flight 17–30 ms per run down to 7–8 ms; light harness and the light tests pass; CI verify green.
2026-10-29 20:26:44 +01:00
Ludovic MarieandClaude Opus 5.5 d22aecd526 Format the equine tests' imports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:20:19 +01:00
Ludovic MarieandClaude Opus 5.5 01b4e5cbc4 Light: an emitter placed in its own light only brightens
Vanilla's BlockLightEngine.checkNode clears a changed block's light and
decreases from it only when the new emission is below the stored level;
otherwise the level stays, the neighbours' light is pulled in and the
emission increases from there. Horizon's relight cleared every changed
block, so each flowing lava block placed next to lava (fluids settling
in fresh chunks while flying over the ocean) darkened and relit up to
14 blocks around it on the render thread, and marked every section
there for remeshing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:13:11 +01:00
xKumorioandClaude Opus 5.5 a91952a8c0 Merge main: keep only what #3085 doesn't have
#3085 already moved the bow, trident, throws, potions and wind charge to
known_player_movement(), and main has the Windows test fixes (#3088, and
#3587 for port_publish.py). This branch takes main's version of all of
those. What it keeps: Guest::known_movement, set on every accepted move
and zeroed by a ClientTickEnd without one (handleClientTickEnd), and the
save and restore of Game::known_movement in enter_guest/leave_guest.
The moves.last() copies in UseItem, the spear's jab and the per-tick
using turn go, since enter_guest sets the value for every turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:58:55 +05:00
Ludovic MarieandClaude Opus 5.5 558904775f Merge main into this branch
Text displays: main's text_display::draw and Lines (#2812) replace this
branch's inline drawing, with this branch's display_light (the same light);
this branch's display_data and transformation are kept beside them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:10:43 +01:00
Ludovic MarieandClaude Opus 5.5 62c0834880 Merge main into this branch (rearing beside the horse finalize tests, one tests module)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:57:27 +01:00
d3grandoandClaude Opus 5.5 a30b9525d1 Horses rear when hurt
AbstractHorse.hurtServer had no port: after a hurt that went through,
vanilla draws random.nextInt(3) and on 0 calls standIfPossible, which
rears the horse for 20 ticks unless canPerformRearing is false (llamas,
camels). Mob::damage_with now ends with that roll for every equine, and
make_mad shares the new stand_if_possible.

The stand counter runs down in AbstractHorse.tick, with or without AI,
but Horizon ran it down only in the AI step, so a NoAI horse that reared
from a hit stayed reared for good. A mob with no AI now runs it down too.

The mob_ai harness gets a llama_hurt scenario (the draw without the
rear), which matched for 10 ticks before and all 201 after; the probe
names MountPanic and LlamaHurtBy as vanilla does.

Fixes #3432

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:45:42 +07:00
xKumorioandClaude Opus 5.5 f9494ef93e Arrows shot mid-jump carry the jump: projectiles add the known movement
Projectile.shootFromRotation adds source.getKnownMovement() to the
projectile, the vertical part only when the shooter is off the ground. For
a ServerPlayer that is lastKnownClientMovement: the move the client last
sent (handlePlayerKnownMovement), reset to zero by handleClientTickEnd when
no move came that tick.

Horizon added the player's PlayerMotion velocity instead. On the server
(single player's server thread, LAN, dedicated) a guest's velocity only
holds knockback, not the player's own movement, so an arrow shot while
jumping or running lost the shooter's motion and flew lower and shorter
than vanilla's.

Guests now keep known_movement: set on each accepted move, zeroed at a
ClientTickEnd without one. enter_guest puts it in Game::known_movement for
the turn and leave_guest restores the outer turn's, so nested turns keep
their own. Bows, tridents, snowballs/eggs/ender pearls, thrown potions
and wind charges take known_player_movement(), which the spear already
used. The spear and the per-tick using/digging turn no longer copy the
last of the tick's moves themselves, which was zero once those moves had
been consumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:18:46 +05:00
xKumorioandClaude Opus 5.5 ad644778bc Three Windows-only test failures in pr_ready
session_lock_is_exclusive read session.lock while its lock was held.
Windows file locks are mandatory, so that read failed with OS error 33.
It now reads the file once the first lock is dropped.

port_publish wrote the store path as str(path) with the home replaced by
"~", which kept Windows backslashes (~\code\...). The path under the home
is now written as ~/ plus its POSIX form, as on Linux. The temporary body
file is opened as UTF-8, so the "→" in a facts line no longer fails to
encode in the Windows code page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:01:17 +05:00
Kacpherek 1944dd45e4 Fix display light borrow and format
display_light took &self while text displays were borrowed mutably; it now takes the world. Also rustfmt.
2026-10-27 22:35:25 +01:00
Kacpherek b910cdd257 Draw block and item display entities on clients
A client only had an arm for text displays. DisplayRenderer draws a block display's model or an item display's item under the same billboard and transformation, with the brightness override or the light at the entity.
2026-10-27 22:25:37 +01:00
Kacpherek 3934833369 Send text, block and item display data to clients
Summoned displays reached clients as bare entities, so a text display showed nothing. Vanilla syncs Display's entries 8 to 22 and each type's own (text 23 to 27, block state 23, item and context 23 and 24) from the saved compound.
2026-10-27 22:25:37 +01:00
Kacpherek 621473f60e Send the ominous item spawner to guests and draw its floating item
The spawner existed only on the server, so a client never saw the item grow and turn. Vanilla tracks it with DATA_ITEM; the client draws OminousItemSpawnerRenderer and runs tickClient's particles.
2026-10-27 22:21:29 +01:00
Kacpherek 2b2dc1929f Show tipped and spectral arrows to guests and clients
A server sent every arrow as a plain one. Vanilla sends a spectral arrow as its own type and a tipped arrow's ID_EFFECT_COLOR, and the client draws arrow_spectral.png and sheds the effect swirls (Arrow.tick) and sparkles (SpectralArrow.tick).
2026-10-27 22:20:33 +01:00
21 changed files with 776 additions and 55 deletions

No files matched your search

+61
View File
@@ -883,6 +883,24 @@ fn relight_layer(
let Some(old) = level(world, sky, p) else {
continue;
};
// `BlockLightEngine.checkNode`: a block emitting at least the light
// already there keeps it and pulls its neighbours' light in. Clearing
// it would darken and relight everything it lit (flowing lava next to
// lava: thousands of cells per block).
if !sky && !force && old > 0 {
let emission = world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission);
if emission >= old {
if emission > old {
set_level(world, sky, p, emission);
changed.push(p);
}
decrease.push_back((p, 0));
increase.push_back(p);
continue;
}
}
if old > 0 {
set_level(world, sky, p, 0);
changed.push(p);
@@ -1089,6 +1107,49 @@ mod tests {
assert!(same(&w, (1, 0)));
assert_eq!(block(&w, 14, 0, 8), 13);
}
/// Emitters placed in light at most as bright as theirs (flowing lava
/// next to lava) only brighten: nothing they lit is darkened and lit
/// again, and the result is the full relight's.
#[test]
fn emitters_placed_in_their_own_light_only_brighten() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
});
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
};
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
let around: Vec<Pos> = (-15..=15)
.flat_map(|dx| {
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
})
.collect();
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
let mut changed = LightChanges::default();
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
for (&p, &was) in around.iter().zip(&before) {
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
}
}
// An emitter checked again with its block light unchanged changes
// nothing, so no section is remeshed.
let mut changed = LightChanges::default();
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
assert!(changed.is_empty());
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
for (si, s) in light_chunk(&region, &tables(), true).iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
}
#[test]
fn random_border_relights_match_full_relight() {
// Edits around every border of chunk (0, 0), at the top and bottom of
@@ -234,6 +234,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
"MountPanic" => "MountPanicGoal",
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
+3
View File
@@ -164,6 +164,8 @@ pub struct ArrowState {
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `Arrow.ID_EFFECT_COLOR` as a client has it from its server (-1: none).
pub effect_color: i32,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
@@ -214,6 +216,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
effect_color: -1,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
+1
View File
@@ -8322,6 +8322,7 @@ impl Game<'_> {
self.guest_model_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
+9 -1
View File
@@ -4651,7 +4651,15 @@ impl Game<'_> {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/projectiles/arrow").copied() else {
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
+7 -19
View File
@@ -174,6 +174,8 @@ pub(super) struct GuestSwap {
combat: super::combat::CombatTracker,
/// The guest whose turn this one nests in, if any.
acting: (Option<u32>, Option<String>, Option<u128>),
/// That turn's `known_movement`.
known_movement: Vec3,
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
@@ -401,6 +403,8 @@ impl Game<'_> {
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
let marks = std::mem::replace(&mut self.adv.marks, std::mem::take(&mut play.marks));
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
let [x, y, z] = g.known_movement;
let known_movement = std::mem::replace(&mut self.known_movement, Vec3::new(x, y, z));
let acting = (
self.lan_acting.replace(conn),
self.lan_acting_name.replace(g.name.clone()),
@@ -475,6 +479,7 @@ impl Game<'_> {
marks,
death_cause,
acting,
known_movement,
packets,
entity_events,
swings,
@@ -537,6 +542,7 @@ impl Game<'_> {
death_cause,
combat,
acting,
known_movement,
packets,
entity_events,
swings,
@@ -582,6 +588,7 @@ impl Game<'_> {
);
}
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
self.known_movement = known_movement;
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
// An ender chest the guest opened: the menu shows their inventory
// itself (lan_open_container), not a stand-in block entity.
@@ -1250,14 +1257,7 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
// ServerPlayer.getKnownMovement: the move their client just sent,
// which a throw adds to the projectile.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let swung = self.as_guest(host, conn, |g| {
g.known_movement = movement;
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
@@ -1784,14 +1784,8 @@ impl Game<'_> {
return;
};
let at = g.position;
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let sound = self
.guest_hit_by(host, conn, |g| {
g.known_movement = movement;
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
None
} else {
@@ -2241,13 +2235,7 @@ impl Game<'_> {
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
if digging.is_some() || using || delayed {
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = host.hub.guests[gi]
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
self.as_guest(host, conn, |g| {
g.known_movement = movement;
g.attack_ticks = g.attack_ticks.saturating_add(1);
if let Some((pos, face)) = digging {
g.forced_hit = Some(Hit {
+97 -2
View File
@@ -10,7 +10,7 @@ use horizon_client::lan::{
playerdata::ints_uuid,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
use horizon_world::{nbt::Tag, registry::BlockStateId};
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
@@ -287,18 +287,100 @@ impl Game<'_> {
}
out.push(e);
}
// DATA_ITEM, `ItemStack.EMPTY` by default.
for o in self.ominous_spawners.iter().filter(|o| o.id != 0) {
let mut e = state(
o.id,
"minecraft:ominous_item_spawner",
o.position,
0.0,
false,
);
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(o.item.clone()))),
));
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
for o in &self.others {
let p = o.position;
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
e.pitch = o.pitch;
e.head_yaw = o.yaw;
if let Some(tag) = self.persist.raw.get(&o.id) {
self.lan_display_data(tag, hub, &mut e);
}
out.push(e);
}
}
}
impl Game<'_> {
/// The synched data of a text, block or item display kept unsimulated,
/// from its compound (`Display.readAdditionalSaveData`), with the
/// defaults to send when a value goes back to one.
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
use horizon_client::text_display::{display_data, text_display_data};
let empty = Tag::Compound(Default::default());
let (meta, defaults) = match e.kind.as_str() {
"minecraft:text_display" => (
text_display_data(tag, false),
text_display_data(&empty, true),
),
"minecraft:block_display" => {
// DATA_BLOCK_STATE_ID, air by default.
let state = |t: Option<BlockStateId>| {
(
23,
serializer::BLOCK_STATE,
Value::Id(t.map_or(0, |s| s.0 as i32)),
)
};
let air = self.pack.assets.registry.default_state("minecraft:air");
let shown = tag.get("block_state").and_then(|t| self.state_from_nbt(t));
(vec![state(shown.or(air))], vec![state(air)])
}
"minecraft:item_display" => {
// DATA_ITEM_STACK and DATA_ITEM_DISPLAY_ID (`ItemDisplayContext`).
let item = tag
.get("item")
.and_then(horizon_client::save::item_from_nbt);
let context = match tag.get("item_display") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"thirdperson_lefthand" => 1,
"thirdperson_righthand" => 2,
"firstperson_lefthand" => 3,
"firstperson_righthand" => 4,
"head" => 5,
"gui" => 6,
"ground" => 7,
"fixed" => 8,
"on_shelf" => 9,
_ => 0,
},
_ => 0,
};
(
vec![
(23, serializer::ITEM_STACK, Value::Item(hub.stack(&item))),
(24, serializer::BYTE, Value::Byte(context)),
],
vec![
(23, serializer::ITEM_STACK, Value::Item(None)),
(24, serializer::BYTE, Value::Byte(0)),
],
)
}
_ => return,
};
e.meta = display_data(tag, false);
e.meta.extend(meta);
e.defaults = display_data(&empty, true);
e.defaults.extend(defaults);
}
/// `ClientboundAddEntityPacket(Entity, ServerEntity, int)`: the entity's
/// own UUID (`Entity.getUUID`), the one `/data`, saves and UUID
/// selectors use, so a client can name it back (the crosshair UUID that
@@ -347,6 +429,9 @@ impl Game<'_> {
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.ominous_spawners
.iter_mut()
.for_each(|o| give(&mut o.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
@@ -398,7 +483,12 @@ impl Game<'_> {
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
use horizon_client::projectiles::ProjectileKind as K;
let kind = p.kind.id();
// A spectral arrow is its own entity type.
let kind = if p.is_spectral_arrow() {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
@@ -417,6 +507,11 @@ impl Game<'_> {
a.map_or(0, |a| a.pierce),
a.is_some_and(|a| a.in_ground) || p.stuck,
));
// Arrow.ID_EFFECT_COLOR, -1 by default.
let color = p.arrow_effect_color();
if color != -1 {
e.meta.push((11, serializer::INT, Value::Int(color)));
}
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
@@ -21,6 +21,8 @@ const TICKS_SCALING: f32 = 50.0;
const ROTATION_SPEED: f32 = 40.0;
pub(super) struct OminousItemSpawner {
/// The entity id a server tracks it by (0 until it is tracked).
pub(super) id: u32,
pub(super) item: ItemStack,
pub(super) position: [f64; 3],
pub(super) spawn_item_after: i64,
@@ -65,6 +67,7 @@ impl Game<'_> {
delay: i32,
) {
self.ominous_spawners.push(OminousItemSpawner {
id: 0,
item,
position,
spawn_item_after: i64::from(delay),
@@ -82,6 +85,7 @@ impl Game<'_> {
return;
};
self.ominous_spawners.push(OminousItemSpawner {
id: 0,
item,
position: [at.x, at.y, at.z],
spawn_item_after,
@@ -121,6 +125,32 @@ impl Game<'_> {
kept.append(&mut self.ominous_spawners);
self.ominous_spawners = kept;
}
/// `handleAddEntity` for a spawner a server sent: its item comes with the
/// entity data (`ItemStack.EMPTY` until then, which draws nothing).
pub(super) fn add_remote_ominous_item_spawner(&mut self, id: u32, position: [f64; 3]) {
self.ominous_spawners.push(OminousItemSpawner {
id,
item: ItemStack::new("minecraft:air", 1),
position,
spawn_item_after: 0,
tick_count: 0,
uuid: [0; 4],
raw: None,
});
}
/// `OminousItemSpawner.tick` on a client: `tickCount` and `tickClient`,
/// the particles every 5 game ticks; only the server spawns the item.
pub(super) fn tick_remote_ominous_item_spawners(&mut self) {
let at: Vec<[f64; 3]> = self.ominous_spawners.iter().map(|s| s.position).collect();
for s in &mut self.ominous_spawners {
s.tick_count += 1;
}
if self.world.game_time % 5 == 0 {
for position in at {
self.ominous_spawner_particles(position);
}
}
}
/// `spawnItem`: a projectile shot down, or an item entity; level event
/// 3021 and the entity_place game event.
fn spawn_ominous_item(&mut self, s: &OminousItemSpawner) {
+1 -1
View File
@@ -631,7 +631,7 @@ impl Game<'_> {
*self.persist.uuids.entry(id).or_insert_with(random_uuid)
}
/// `BlockState.CODEC`: a block name or `{id, properties}` (or legacy `{Name, Properties}`).
fn state_from_nbt(&self, tag: &Tag) -> Option<BlockStateId> {
pub(super) fn state_from_nbt(&self, tag: &Tag) -> Option<BlockStateId> {
let registry = self.pack.assets.registry;
let state = match tag.get("Name") {
Some(Tag::String(name)) => BlockState {
+1
View File
@@ -4241,6 +4241,7 @@ impl Game<'_> {
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
self.tick_remote_ominous_item_spawners();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
@@ -35,11 +35,18 @@ impl Game<'_> {
m.extra.leash = Some(Holder::Knot(block));
}
}
} else if kind == "minecraft:text_display" {
} else if let Some(shows) = match kind {
"minecraft:text_display" => Some(super::text_displays::Shows::Text),
"minecraft:block_display" => Some(super::text_displays::Shows::Block),
"minecraft:item_display" => Some(super::text_displays::Shows::Item),
_ => None,
} {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
super::text_displays::RemoteTextDisplay::new(shows, position, yaw),
);
} else if kind == "minecraft:ominous_item_spawner" {
self.add_remote_ominous_item_spawner(id, position);
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
@@ -119,13 +126,33 @@ impl Game<'_> {
/// `handleSetEntityData` for an object: what changes how it looks.
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
if let Some(display) = self.text_displays.get_mut(&id) {
display.metadata(values);
if self.text_displays.contains_key(&id) {
// ItemDisplay.DATA_ITEM_STACK, read with the server's registries.
let item = values
.iter()
.find_map(|(index, _, value)| match (index, value) {
(23, Value::Item(stack)) => Some(self.to_stack(stack)),
_ => None,
});
if let Some(display) = self.text_displays.get_mut(&id) {
display.show(values, item);
display.metadata(values);
}
return;
}
let id = id as u32;
for (index, _, value) in values {
if self.item_frames.iter().any(|f| f.id == id) {
if let (8, Value::Item(stack)) = (index, value)
&& self.ominous_spawners.iter().any(|s| s.id == id)
{
// OminousItemSpawner.DATA_ITEM.
if let (Some(item), Some(s)) = (
self.to_stack(stack),
self.ominous_spawners.iter_mut().find(|s| s.id == id),
) {
s.item = item;
}
} else if self.item_frames.iter().any(|f| f.id == id) {
// HangingEntity.DATA_DIRECTION, ItemFrame.DATA_ITEM and DATA_ROTATION.
let item = match (index, value) {
(9, Value::Item(stack)) => Some(self.to_stack(stack)),
@@ -320,6 +347,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.ominous_spawners.clear();
self.remote_knots.clear();
self.carts.clear();
self.boats.clear();
@@ -331,6 +359,7 @@ impl Game<'_> {
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.ominous_spawners.retain(|s| s.id != id);
self.remote_knots.remove(&id);
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
@@ -3,6 +3,7 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::items::ItemStack;
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
collisions::SceneCollisions,
@@ -113,6 +114,10 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.previous = p.position;
p.age += 1;
// AbstractArrow.tick: `inGroundTime` counts the ticks in a block.
if let Some(a) = p.arrow.as_mut() {
a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 };
}
if p.stuck {
return;
}
@@ -154,6 +159,51 @@ pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
}
impl Game<'_> {
/// `Arrow.tick` and `SpectralArrow.tick` on a client: a tipped arrow
/// sheds two entity-effect swirls in its colour while flying, one every
/// fifth tick in a block; a spectral arrow an effect sparkle at its
/// position while flying.
fn arrow_effect_particles(&mut self) {
// The arrow's box: `EntityType.ARROW` is 0.5 x 0.5.
const SIZE: f64 = 0.5;
for p in &self.creatures.projectiles {
let Some(a) = p.arrow.as_ref().filter(|_| p.is_arrow()) else {
continue;
};
let at = p.position;
if p.is_spectral_arrow() {
if !p.stuck {
let options = ParticleOptions::simple(ParticleType::Effect);
self.particles.add(&options, [at.x, at.y, at.z], [0.0; 3]);
}
continue;
}
let color = a.effect_color;
let count = match (p.stuck, a.in_ground_time % 5) {
(true, 0) => 1,
(true, _) => 0,
(false, _) => 2,
};
if color == -1 {
continue;
}
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
let options = ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), channel(24)],
);
for _ in 0..count {
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
let r = self.particles.random();
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
let y = at.y + SIZE * r.next_double();
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
}
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
@@ -253,6 +303,12 @@ impl Game<'_> {
.llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]);
}
let mut p = Projectile::new(k, at, None);
// SpectralArrow: its own type; its pickup item says so.
if kind == "minecraft:spectral_arrow"
&& let Some(a) = p.arrow.as_mut()
{
a.pickup_item = ItemStack::new("minecraft:spectral_arrow", 1);
}
p.id = id;
p.velocity = velocity;
p.yaw = yaw;
@@ -370,6 +426,12 @@ impl Game<'_> {
(ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => {
p.stuck = *in_ground;
}
// Arrow.ID_EFFECT_COLOR.
(ProjectileKind::Arrow { .. }, 11, Value::Int(color)) => {
if let Some(a) = p.arrow.as_mut() {
a.effect_color = *color;
}
}
// WitherSkull.DATA_DANGEROUS.
(ProjectileKind::WitherSkull { dangerous }, 8, Value::Bool(d)) => {
*dangerous = *d;
@@ -471,6 +533,7 @@ impl Game<'_> {
}
self.creatures.projectiles = projectiles;
self.tridents = tridents;
self.arrow_effect_particles();
let trails: Vec<Vec3> = self
.creatures
.projectiles
+138 -19
View File
@@ -1,15 +1,34 @@
//! Server text displays: metadata, interpolation, billboard transforms and world text.
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
gfx::TEXT_POLYGON_OFFSET_OVERLAY,
hud::HudBatch,
items::ItemStack,
mesher::Vertex,
text_display::{self, Lines, TextDisplayData},
};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
/// What a display shows besides its transformation: its text (in `data`),
/// a block state (`BlockDisplay`) or an item and how it is displayed
/// (`ItemDisplay`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Shows {
Text,
Block,
Item,
}
pub(super) struct RemoteTextDisplay {
pub data: TextDisplayData,
shows: Shows,
/// `BlockDisplay.DATA_BLOCK_STATE_ID`.
block: Option<BlockStateId>,
/// `ItemDisplay.DATA_ITEM_STACK` and `DATA_ITEM_DISPLAY_ID`.
item: Option<ItemStack>,
item_display: u8,
from: TextDisplayData,
shown: TextDisplayData,
position: glam::DVec3,
@@ -41,9 +60,13 @@ fn renders(id: u8) -> bool {
}
impl RemoteTextDisplay {
pub fn new(position: [f64; 3], yaw: f32) -> Self {
pub fn new(shows: Shows, position: [f64; 3], yaw: f32) -> Self {
Self {
data: Default::default(),
shows,
block: None,
item: None,
item_display: 0,
from: Default::default(),
shown: Default::default(),
position: position.into(),
@@ -71,6 +94,24 @@ impl RemoteTextDisplay {
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
}
}
/// The entries after `Display`'s: a block state, or an item with its
/// display context (the stack comes converted by the caller).
pub fn show(&mut self, values: &[(u8, i32, Value)], item: Option<Option<ItemStack>>) {
for (id, _, value) in values {
match (self.shows, id, value) {
(Shows::Block, 23, Value::Id(state)) => {
self.block = Some(BlockStateId(u32::try_from(*state).unwrap_or(0)));
}
(Shows::Item, 24, Value::Byte(context)) => self.item_display = *context as u8,
_ => {}
}
}
if let Some(item) = item
&& self.shows == Shows::Item
{
self.item = item;
}
}
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
self.start_dirty |= values.iter().any(|v| v.0 == 8);
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
@@ -143,6 +184,96 @@ impl Game<'_> {
display.tick();
}
}
/// The camera's yaw and pitch as `DisplayRenderer` reads them (the
/// front view turns around).
fn display_camera_angles(&self) -> [f32; 2] {
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
}
}
/// `DisplayRenderer.getBlockLightLevel` and `getSkyLightLevel`: the
/// brightness override, else the light at the entity.
fn display_light(
world: &horizon_client::world::World,
d: &TextDisplayData,
position: glam::DVec3,
) -> u32 {
if d.brightness == -1 {
let world_light = world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8)
}
}
/// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the
/// block model or the item under the display's transformation, lit like
/// any entity and never tinted by a hurt overlay.
pub(super) fn block_item_display_geometry(
&mut self,
camera: glam::DVec3,
partial: f64,
out: &mut Vec<Vertex>,
) {
let angles = self.display_camera_angles();
let scale = self.view_scale();
let mut displays = std::mem::take(&mut self.text_displays);
for shows in [Shows::Block, Shows::Item] {
let kind = if shows == Shows::Block {
"minecraft:block_display"
} else {
"minecraft:item_display"
};
self.mark_entity(out.len(), kind);
for (id, display) in displays.iter_mut().filter(|(_, d)| d.shows == shows) {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range {
continue;
}
let rotation = std::array::from_fn(|i| {
display.old_rotation[i]
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
});
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
* d.matrix(rotation, angles);
if !matrix.is_finite() {
continue;
}
let light = Self::display_light(&self.world, &d, position);
if let Some(state) = display.block {
// BlockModelResolver.update: model seed 42.
self.block_model_vertices(state, 42, matrix, light, true, out);
} else if let Some(stack) = &display.item {
let context = match display.item_display {
1 => DisplayContext::ThirdPersonLeftHand,
2 => DisplayContext::ThirdPersonRightHand,
3 => DisplayContext::FirstPersonLeftHand,
4 => DisplayContext::FirstPersonRightHand,
5 => DisplayContext::Head,
6 => DisplayContext::Gui,
7 => DisplayContext::Ground,
8 => DisplayContext::Fixed,
9 => DisplayContext::OnShelf,
_ => DisplayContext::None,
};
// updateForNonLiving: seeded by the entity id.
let state = self.resolve_item(stack, context, None, *id);
let pose = matrix * glam::Mat4::from_rotation_y(std::f32::consts::PI);
self.submit_item_world(&state, pose, light, out);
}
}
}
self.mark_entity(out.len(), "");
self.text_displays = displays;
}
pub(super) fn text_display_geometry(
&mut self,
camera: glam::DVec3,
@@ -153,14 +284,12 @@ impl Game<'_> {
self.text_display_vertices = 0;
self.mark_translucent_entity(normal.len(), "minecraft:text_display");
let atlas = &self.gui.atlas;
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
let angles = if front {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
};
let angles = self.display_camera_angles();
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
if display.shows != Shows::Text {
continue;
}
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
@@ -189,17 +318,7 @@ impl Game<'_> {
lines,
self.settings.text_background(0.25),
);
let world_light = self.world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
let light = if d.brightness == -1 {
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16)
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
};
let light = Self::display_light(&self.world, &d, position);
// Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET.
let through = d.flags & 2 != 0;
let (out, layer) = if through {
@@ -235,7 +354,7 @@ mod tests {
use super::*;
#[test]
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
let mut d = RemoteTextDisplay::new(Shows::Text, [0.0; 3], 0.0);
d.tick();
d.metadata(&[
(8, 1, Value::Int(0)),
+4
View File
@@ -227,6 +227,9 @@ pub struct Guest {
pub moves: Vec<([f64; 3], bool)>,
/// Whether the guest was on the ground before the first of `moves`.
pub moves_from_ground: bool,
/// `ServerPlayer.lastKnownClientMovement`: the last move accepted from
/// their client, zero after a client tick without one.
pub known_movement: [f64; 3],
pub dead: bool,
/// `LivingEntity.deathTime`: ticks since the guest died.
pub death_time: u32,
@@ -357,6 +360,7 @@ impl Guest {
ticks: 0,
moves: Vec::new(),
moves_from_ground: false,
known_movement: [0.0; 3],
dead: false,
death_time: 0,
placed: false,
+9 -1
View File
@@ -2189,7 +2189,14 @@ impl Hub {
}
g.max_unacknowledged = 10;
}
S::ClientTickEnd(_) => self.guests[i].position_this_tick = false,
S::ClientTickEnd(_) => {
// handleClientTickEnd: no move this tick, no known movement.
let g = &mut self.guests[i];
if !g.position_this_tick {
g.known_movement = [0.0; 3];
}
g.position_this_tick = false;
}
// ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket.
S::RecipeBookChangeSettings(p) => {
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
@@ -2381,6 +2388,7 @@ impl Hub {
g.moves_from_ground = g.on_ground;
}
g.moves.push((moved, flags & 1 != 0));
g.known_movement = moved;
g.yaw = yaw;
g.pitch = pitch.clamp(-90.0, 90.0);
g.head_yaw = g.yaw;
+4
View File
@@ -2313,6 +2313,10 @@ impl Mob {
{
aquatic::spawn_ink(self);
}
// AbstractHorse.hurtServer: the rear roll after the hurt.
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
+7
View File
@@ -507,6 +507,13 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
&& !super::llama::is_llama(m.species())
{
super::equine::stand_tick(m);
}
} else {
ai_step(m, w, level_random, events);
}
+71 -5
View File
@@ -304,15 +304,27 @@ impl Goal for RunAroundLikeCrazy {
make_mad(m, cx.events);
}
}
/// `AbstractHorse.standIfPossible`: `setStanding(20)` (which also stops the
/// eating) when `canPerformRearing`.
fn stand_if_possible(m: &mut Mob) {
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
}
/// `AbstractHorse.hurtServer`, after the hurt went through: one hit in three
/// rears the horse. Llamas and camels make the draw too, but never rear.
pub fn after_hurt_server(m: &mut Mob) {
if m.random.next_int_bound(3).unwrap_or(1) == 0 {
stand_if_possible(m);
}
}
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
return;
}
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
stand_if_possible(m);
// getAngrySound: null (silent) for the skeleton horse.
let sound = match m.species() {
Species::SkeletonHorse => return,
@@ -663,10 +675,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.flag = false;
}
}
stand_tick(m);
animate(m);
}
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
pub fn stand_tick(m: &mut Mob) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
@@ -856,10 +873,13 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::Damage;
use crate::mobs::finalize::base_value;
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
@@ -889,4 +909,50 @@ mod tests {
finalize(&mut m);
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
}
fn equine(id: u32, species: Species, name: &str) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = name.into();
k.species = species;
k.max_health = 30.0;
Mob::new(id, kind, Vec3::new(f64::from(id), 64.0, 0.0), 0.0)
}
/// Hits `n` fresh mobs of a species once each; how many reared.
fn rears(species: Species, name: &str, n: u32) -> u32 {
(1..=n)
.filter(|&id| {
let mut m = equine(id, species, name);
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 0.0])));
m.extra.counter == 20
})
.count() as u32
}
#[test]
fn a_hurt_horse_rears_one_hit_in_three() {
// AbstractHorse.hurtServer: nextInt(3) == 0 -> standIfPossible (#3432).
let n = rears(Species::Horse, "minecraft:horse", 300);
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
}
#[test]
fn the_rear_ends_after_twenty_ticks() {
// AbstractHorse.tick: standCounter runs down, then clearStanding.
let mut m = equine(1, Species::Horse, "minecraft:horse");
stand_if_possible(&mut m);
for _ in 0..19 {
stand_tick(&mut m);
}
assert!(m.extra.counter > 0);
stand_tick(&mut m);
assert_eq!(m.extra.counter, 0);
}
#[test]
fn llamas_and_camels_never_rear_when_hurt() {
// Llama and Camel: canPerformRearing is false.
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
}
}
+32
View File
@@ -269,6 +269,38 @@ impl Projectile {
pub fn is_arrow(&self) -> bool {
matches!(self.kind, ProjectileKind::Arrow { .. })
}
/// Whether it is a `SpectralArrow` (its own entity type): a spectral
/// arrow's pickup item, or the glowing effect a dispenser shoots with.
pub fn is_spectral_arrow(&self) -> bool {
let ProjectileKind::Arrow { effect, .. } = &self.kind else {
return false;
};
effect.is_some_and(|(id, _)| id == "minecraft:glowing")
|| self
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
}
/// `Arrow.ID_EFFECT_COLOR` (`updateColor`): the opaque colour of the
/// potion the arrow carries, from its tipped pickup item or the effect a
/// stray, bogged or parched adds; -1 for none.
pub fn arrow_effect_color(&self) -> i32 {
let ProjectileKind::Arrow { effect, .. } = &self.kind else {
return -1;
};
if self.is_spectral_arrow() {
return -1;
}
let rgb = match (effect, &self.arrow) {
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.color),
(None, Some(a)) if &*a.pickup_item.item == "minecraft:tipped_arrow" => a
.pickup_item
.potion()
.map(|p| crate::brewing::potion_color(Some(p))),
_ => None,
};
rgb.map_or(-1, |rgb| (rgb | 0xFF00_0000) as i32)
}
pub fn is_fireball(&self) -> bool {
matches!(
self.kind,
+197 -2
View File
@@ -1,7 +1,11 @@
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
//! and ClientboundSetEntityDataPacket's own stream codec.
use crate::text::Text;
use horizon_protocol::entity_data::Value;
use crate::{
save::{float, number},
text::Text,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
use serde::Serialize;
use std::sync::Arc;
@@ -154,6 +158,197 @@ impl TextDisplayData {
}
}
/// One synched data entry: kept when it differs from its default, or always
/// with `all` (the defaults a client is sent when a value goes back to one).
fn entry(
out: &mut Vec<(u8, i32, Value)>,
all: bool,
id: u8,
kind: i32,
value: Value,
default: Value,
) {
if all || value != default {
out.push((id, kind, if all { default } else { value }));
}
}
/// `Display.readAdditionalSaveData`: the synched data entries (ids 8 to 22)
/// of a saved display, or with `all` their defaults. A matrix `transformation`
/// (`MATRIX4F`) is not decomposed: it stays the identity.
pub fn display_data(tag: &Tag, all: bool) -> Vec<(u8, i32, Value)> {
let int = |key: &str| number(tag.get(key)).map(|v| v as i32);
let real = |key: &str| float(tag.get(key)).map(|v| v as f32);
let mut out = Vec::new();
let mut set = |id: u8, kind: i32, value: Value, default: Value| {
entry(&mut out, all, id, kind, value, default);
};
let defaults = TextDisplayData::default();
// The start delay, the transformation duration, the teleport duration (0 to 59).
let ints = [
(8, "start_interpolation", 0, i32::MIN, i32::MAX),
(9, "interpolation_duration", 0, i32::MIN, i32::MAX),
(10, "teleport_duration", 0, 0, 59),
(22, "glow_color_override", -1, i32::MIN, i32::MAX),
];
for (id, key, default, low, high) in ints {
let value = int(key).unwrap_or(default).clamp(low, high);
set(id, serializer::INT, Value::Int(value), Value::Int(default));
}
// `Transformation.CODEC`: all four parts, else the identity.
let t = tag.get("transformation").and_then(transformation);
let (translation, left, scale, right) = t.unwrap_or((
defaults.translation,
defaults.left_rotation,
defaults.scale,
defaults.right_rotation,
));
let parts = [
(
11,
serializer::VECTOR3,
translation.to_vec(),
defaults.translation.to_vec(),
),
(
12,
serializer::VECTOR3,
scale.to_vec(),
defaults.scale.to_vec(),
),
(
13,
serializer::QUATERNION,
left.to_vec(),
defaults.left_rotation.to_vec(),
),
(
14,
serializer::QUATERNION,
right.to_vec(),
defaults.right_rotation.to_vec(),
),
];
for (id, kind, value, default) in parts {
set(id, kind, Value::Floats(value), Value::Floats(default));
}
let billboard = match tag.get("billboard") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"vertical" => 1,
"horizontal" => 2,
"center" => 3,
_ => 0,
},
_ => 0,
};
set(15, serializer::BYTE, Value::Byte(billboard), Value::Byte(0));
// `Brightness.pack`: block light in bits 4 to 7, sky light in bits 20 to 23.
let brightness = tag.get("brightness").and_then(|b| {
let light = |key: &str| number(b.get(key)).filter(|v| (0..=15).contains(v));
Some((light("block")? as i32) << 4 | (light("sky")? as i32) << 20)
});
set(
16,
serializer::INT,
Value::Int(brightness.unwrap_or(-1)),
Value::Int(-1),
);
for (id, key, default) in [
(17, "view_range", 1.0),
(18, "shadow_radius", 0.0),
(19, "shadow_strength", 1.0),
(20, "width", 0.0),
(21, "height", 0.0),
] {
let value = real(key).unwrap_or(default);
set(
id,
serializer::FLOAT,
Value::Float(value),
Value::Float(default),
);
}
out
}
/// `TextDisplay.readAdditionalSaveData`: the text, line width, background,
/// opacity and style flags (ids 23 to 27), or with `all` their defaults.
pub fn text_display_data(tag: &Tag, all: bool) -> Vec<(u8, i32, Value)> {
let int = |key: &str| number(tag.get(key)).map(|v| v as i32);
let mut out = Vec::new();
let mut set = |id: u8, kind: i32, value: Value, default: Value| {
entry(&mut out, all, id, kind, value, default);
};
let empty = Tag::String("".into());
set(
23,
serializer::COMPONENT,
Value::Text(tag.get("text").unwrap_or(&empty).clone()),
Value::Text(empty.clone()),
);
set(
24,
serializer::INT,
Value::Int(int("line_width").unwrap_or(200)),
Value::Int(200),
);
let background = Value::Int(int("background").unwrap_or(0x4000_0000));
set(25, serializer::INT, background, Value::Int(0x4000_0000));
let opacity = Value::Byte(int("text_opacity").unwrap_or(-1) as i8);
set(26, serializer::BYTE, opacity, Value::Byte(-1));
// Shadow 1, see-through 2, default background 4, then the alignment: left 8, right 16.
let flag = |key: &str, bit: i8| {
if int(key).is_some_and(|v| v != 0) {
bit
} else {
0
}
};
let align = match tag.get("alignment") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"left" => 8,
"right" => 16,
_ => 0,
},
_ => 0,
};
let flags = flag("shadow", 1) | flag("see_through", 2) | flag("default_background", 4) | align;
set(27, serializer::BYTE, Value::Byte(flags), Value::Byte(0));
out
}
/// `Transformation.CODEC`'s translation, left rotation, scale and right
/// rotation; None unless all four are there (the matrix form is not read).
fn transformation(tag: &Tag) -> Option<([f32; 3], [f32; 4], [f32; 3], [f32; 4])> {
let list = |tag: Option<&Tag>| -> Option<Vec<f32>> {
match tag? {
Tag::List(l) => l.iter().map(|v| float(Some(v)).map(|v| v as f32)).collect(),
_ => None,
}
};
let vector = |key: &str| -> Option<[f32; 3]> { list(tag.get(key))?.try_into().ok() };
// `ExtraCodecs.QUATERNIONF`: four components (normalized) or an axis and an angle.
let rotation = |key: &str| -> Option<[f32; 4]> {
let value = tag.get(key)?;
if let Some(q) = list(Some(value)) {
let [x, y, z, w]: [f32; 4] = q.try_into().ok()?;
let length = (x * x + y * y + z * z + w * w).sqrt();
return Some([x / length, y / length, z / length, w / length]);
}
let angle = float(value.get("angle"))? as f32;
let [x, y, z]: [f32; 3] = list(value.get("axis"))?.try_into().ok()?;
let (sin, cos) = (angle * 0.5).sin_cos();
let inverse = 1.0 / (x * x + y * y + z * z).sqrt();
Some([x * inverse * sin, y * inverse * sin, z * inverse * sin, cos])
};
Some((
vector("translation")?,
rotation("left_rotation")?,
vector("scale")?,
rotation("right_rotation")?,
))
}
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
pub struct Lines {
pub lines: Vec<(Text, f32)>,
+5
View File
@@ -310,6 +310,11 @@ def scenarios():
('witch', 176), ('vindicator', 177), ('zombie', 178)]:
add(f'{species}_deep_water', 160, ['fill -4 -64 -4 4 -61 4 minecraft:water',
f'mob minecraft:{species} 0.5 -62 0.5 0 {seed} persistent'])
# AbstractHorse.hurtServer (#3432): every hurt draws nextInt(3), and a 0 rears the horse
# (standIfPossible). A llama makes the draw too but never rears (canPerformRearing). A horse
# scenario waits for #2720: a rearing horse is immobile (AbstractHorse.isImmobile).
add('llama_hurt', 200, ['player 4.5 -60 0.5', 'mob minecraft:llama 0.5 -60 0.5 0 182 persistent']
+ [f'hurt {t} 0' for t in range(10, 190, 25)])
# Axolotls (#64): idle swimming, hunting a cod, and playing dead after hits, in a pool sunk into
# the ground so no water flows out of it (the probe doesn't tick fluids).
POOL = 'fill -5 -63 -5 5 -61 5 minecraft:water'