Match shulker teleport collision and attachment rules

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
voidsipandCodex committed 2026-11-04 09:41:51 -06:00
1 parent 2e5a2629a0
commit 5150ff66bf
4 files changed
+484 -37

No files matched your search

+131 -37
View File
@@ -14,7 +14,7 @@ use super::{
use crate::projectiles::{Projectile, ProjectileWorld, STEPS};
use horizon_core::geometry::trig::Mth;
use horizon_core::{
geometry::{Aabb, Vec3},
geometry::{Aabb, Direction, ShapeOp, Vec3, VoxelShape},
random::{LegacyRandom, RandomSource},
};
use std::f32::consts::PI;
@@ -110,19 +110,121 @@ fn progress_aabb(direction: u8, from: f64, to: f64, pos: Vec3) -> Aabb {
))
.move_by(pos)
}
/// `Level.noCollision(shulker, aabb)`: no block collision under the empty
/// context, and none of the entities a shulker collides with overlaps
/// (`canBeCollidedWith`: living shulkers and adult happy ghasts; players
/// and other mobs never block it).
fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
let mut scene = crate::collisions::SceneCollisions {
world: w.world,
binding: w.collision,
subject: None,
};
if !crate::collisions::no_collision_empty(&mut scene, bounds) {
return false;
/// Block queries shared by attachment and teleport candidates. Entity overlap
/// is checked separately, using the nearby-entity index from MobWorld.
struct BlockSpace<'w, 'r> {
world: &'w crate::world::World,
registry: &'r horizon_world::registry::BlockRegistry,
facts: &'w crate::blocks::BlockFacts,
binding: &'w horizon_world::block_collision::BlockCollisionRegistry<'r>,
powder_walkable: bool,
}
impl<'w, 'r> BlockSpace<'w, 'r> {
fn context(m: &Mob) -> horizon_world::collision_context::CollisionContext<'_> {
use horizon_world::{
collision_context::{CollisionContext, CollisionEntity, CollisionItem},
entity_obstruction::EntityIdentity,
};
CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(u64::from(m.id)),
// A teleport candidate is queried before setPos changes the entity.
y: m.position.y,
descending: false,
main_hand: Some(CollisionItem {
item: m.extra.main_hand.as_deref().unwrap_or("minecraft:air"),
count: m
.extra
.main_hand_stack()
.map_or(i32::from(m.extra.main_hand.is_some()), |s| {
s.count.min(i32::MAX as u32) as i32
}),
}),
},
false,
)
}
fn scene(&self, m: &Mob) -> crate::collisions::SceneCollisions<'_, 'r> {
crate::collisions::SceneCollisions {
world: self.world,
binding: self.binding,
subject: Some(crate::collisions::PowderSnowSubject {
fall_distance: m.fall_distance,
leather_boots: m.extra.feet.as_deref() == Some("minecraft:leather_boots")
&& m.extra.armor[3].as_ref().is_none_or(|s| s.count > 0),
walkable_type: self.powder_walkable,
..Default::default()
}),
}
}
fn no_collision(&self, m: &Mob, bounds: Aabb) -> bool {
use horizon_world::{
entity_obstruction::EntityIdentity,
world_movement::{CollisionCheckRequest, no_world_collision},
};
no_world_collision(
&mut self.scene(m),
CollisionCheckRequest {
entity: EntityIdentity(u64::from(m.id)),
context: Self::context(m),
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
fn can_stay_at(&self, m: &Mob, p: [i32; 3], face: u8) -> bool {
// isPositionBlocked: anything but air blocks, except a moving piston
// being driven through the shulker's own cell.
if !self.facts.is_air(self.world.block(p[0], p[1], p[2]))
&& (p != block_pos(m)
|| self
.registry
.state(self.world.block(p[0], p[1], p[2]))
.map_or("", |s| s.name.as_str())
!= "minecraft:moving_piston")
{
return false;
}
// loadedAndEntityCanStandOnFace uses the entity-context collision shape,
// not the block's generic support shape (notably different on soul sand).
let s = face_step(face);
let support_pos =
horizon_core::position::BlockPos::new(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
let support = self
.world
.block(support_pos.x, support_pos.y, support_pos.z);
let open = opposite(face);
let Ok(shape) = self.binding.collision_shape_with_context(
&mut self.scene(m),
support,
support_pos,
&Self::context(m),
) else {
return false;
};
let full = shape
.face_shape(Direction::ALL[open as usize])
.is_ok_and(|face| {
VoxelShape::block()
.join_is_not_empty(&face, ShapeOp::XOR)
.is_ok_and(|differs| !differs)
});
full && self.no_collision(m, attachment_box(p, face))
}
}
fn block_space<'w, 'r>(m: &Mob, w: &'w MobWorld<'r>) -> BlockSpace<'w, 'r> {
BlockSpace {
world: w.world,
registry: w.registry,
facts: w.facts,
binding: w.collision,
powder_walkable: w
.data
.entity_has("minecraft:powder_snow_walkable_mobs", &m.kind.id),
}
}
fn entities_clear(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
let grown = bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!w.others_near(grown).any(|s| {
s.id != m.id
@@ -131,27 +233,12 @@ fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
&& s.bounds.intersects(grown)
})
}
/// `Shulker.canStayAt`: an empty spot against a sturdy face, with room
/// for the fully opened shell (`canStayAt`).
fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool {
// isPositionBlocked: anything but air blocks, except a moving piston
// being driven through the shulker's own cell.
if !w.facts.is_air(w.world.block(p[0], p[1], p[2]))
&& (p != block_pos(m) || w.block_name(p[0], p[1], p[2]) != "minecraft:moving_piston")
{
return false;
}
// loadedAndEntityCanStandOnFace: the supporting block's face toward
// the shell is sturdy.
let s = face_step(face);
let support = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
let open = opposite(face);
if w.facts.get(support).sturdy_full & (1 << open) == 0 {
return false;
}
// The fully opened shell's box, shrunk by the skin vanilla uses.
let opened = progress_aabb(
open,
fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
block_space(m, w).no_collision(m, bounds) && entities_clear(m, w, bounds)
}
fn attachment_box(p: [i32; 3], face: u8) -> Aabb {
progress_aabb(
opposite(face),
-1.0,
1.0,
Vec3::new(
@@ -160,8 +247,11 @@ fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool {
f64::from(p[2]) + 0.5,
),
)
.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6));
no_collision(m, w, opened)
.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6))
}
/// Shulker.canStayAt, including its separately indexed collidable entities.
fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool {
block_space(m, w).can_stay_at(m, p, face) && entities_clear(m, w, attachment_box(p, face))
}
/// `findAttachableSurface`: the first face (down, up, north, south, west,
/// east) to cling to.
@@ -615,6 +705,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
p.yrot("head", (s.y_rot - 180.0).to_radians());
}
#[cfg(test)]
#[path = "shulker_space_tests.rs"]
mod space_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -622,7 +716,7 @@ mod tests {
use std::sync::Arc;
/// A shulker fixed to a block face.
fn shulker() -> Mob {
pub(super) fn shulker() -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:shulker".into();
@@ -0,0 +1,220 @@
use super::*;
use horizon_core::geometry::{Axis, VoxelShape};
use horizon_world::{
block_collision::BlockCollisionRegistry,
collision::CachedCollisionRegistry,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
use std::sync::Arc;
fn text(value: &str) -> Tag {
Tag::String(value.into())
}
fn shape_tag(shape: &VoxelShape) -> Tag {
let sizes = shape.sizes();
let mut bits = Vec::new();
for z in 0..sizes[2] {
for y in 0..sizes[1] {
for x in 0..sizes[0] {
bits.push(u8::from(shape.is_full(x as i32, y as i32, z as i32)));
}
}
}
let (min, max) = shape.discrete_bounds();
let mut fields = vec![
("occupied", Tag::ByteArray(bits)),
("cube", Tag::Byte(i8::from(shape.is_cube()))),
(
"cube_axes",
Tag::Byte(
shape
.cube_coordinate_axes()
.iter()
.enumerate()
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i)),
),
),
("discrete_min", Tag::IntArray(min.to_vec())),
("discrete_max", Tag::IntArray(max.to_vec())),
];
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
fields.push((
name,
Tag::LongArray(
shape
.coordinates(axis)
.iter()
.map(|v| v.to_bits() as i64)
.collect(),
),
));
}
Tag::compound(fields)
}
fn fixture() -> (BlockRegistry, crate::blocks::BlockFacts, Tag) {
let names = ["air", "soul_sand", "stone", "scaffolding", "oak_slab"];
let states: Vec<_> = names
.iter()
.map(|name| {
let mut fields = vec![("id", text(&format!("minecraft:{name}")))];
if *name == "scaffolding" {
fields.push((
"properties",
Tag::compound([
("distance", text("7")),
("bottom", text("false")),
("waterlogged", text("false")),
]),
));
}
Tag::compound(fields)
})
.collect();
let registry_tag = Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
("states", Tag::List(states.clone())),
(
"defaults",
Tag::Compound(
names
.iter()
.enumerate()
.map(|(i, name)| {
(
format!("minecraft:{name}").as_str().into(),
Tag::Int(i as i32),
)
})
.collect(),
),
),
]);
let registry = BlockRegistry::from_nbt(&registry_tag).unwrap();
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-shulker-space-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir(&root).unwrap();
// Original minimal facts: air identity and generic sturdy flags, deliberately
// distinct from the soul sand's lower collision shape.
let facts = serde_json::json!({"states":(0..names.len()).map(|id|serde_json::json!({"id":id})).collect::<Vec<_>>()});
std::fs::write(root.join("gameplay-facts.json"), facts.to_string()).unwrap();
let behaviours = serde_json::json!({"states":[[0,0],[63,0],[63,0],[0,0],[1,0]],"blocks":names.iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>()});
let facts =
crate::blocks::BlockFacts::load_with_behaviour_json(&root, &registry, &behaviours).unwrap();
std::fs::remove_dir_all(root).unwrap();
let box_shape =
|height| VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, height, 1.0))).unwrap();
let shapes = [
VoxelShape::empty(),
box_shape(0.875),
VoxelShape::block(),
VoxelShape::block(),
box_shape(0.5),
];
let table = Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
("states", Tag::List(states)),
("shapes", Tag::List(shapes.iter().map(shape_tag).collect())),
("cached_shapes", Tag::IntArray(vec![0, 1, 2, 3, 4])),
]);
(registry, facts, table)
}
fn check(current: Vec3, cells: &[([i32; 3], u32)], target: [i32; 3], face: u8) -> bool {
let (registry, facts, table) = fixture();
let cached = CachedCollisionRegistry::from_nbt(&table, &registry).unwrap();
let binding = BlockCollisionRegistry::new(Arc::new(cached)).unwrap();
let mut world = crate::world::World::new(BlockStateId(0), 0);
for x in -1..=1 {
for z in -1..=1 {
world
.chunks
.insert((x, z), Arc::new(crate::chunk::Chunk::empty(x, z)));
}
}
for &(p, state) in cells {
world.set(p[0], p[1], p[2], BlockStateId(state)).unwrap();
}
let mut mob = super::tests::shulker();
mob.position = current;
BlockSpace {
world: &world,
registry: &registry,
facts: &facts,
binding: &binding,
powder_walkable: false,
}
.can_stay_at(&mob, target, face)
}
#[test]
fn soul_sand_only_accepts_attachment_to_its_bottom() {
for (face, step) in STEPS.iter().enumerate() {
let support = [step[0], 64 + step[1], step[2]];
assert_eq!(
check(
Vec3::new(8.5, 64.0, 8.5),
&[(support, 1)],
[0, 64, 0],
face as u8
),
face == 1,
"face {face}"
);
}
}
#[test]
fn scaffolding_uses_the_shulkers_current_height_before_and_after_teleport() {
let cells = [([0, 63, 0], 3), ([0, 65, 0], 4)];
assert!(check(Vec3::new(0.5, 62.0, 0.5), &cells, [0, 64, 0], 1));
assert!(!check(Vec3::new(0.5, 64.0, 0.5), &cells, [0, 64, 0], 1));
}
#[test]
#[ignore = "run by tools/test_shulker_space_parity.py"]
fn shulker_space_parity_fixture() {
use std::io::Write;
let input =
std::fs::read_to_string(std::env::var("HORIZON_SHULKER_SPACE_INPUT").unwrap()).unwrap();
let mut output =
std::fs::File::create(std::env::var("HORIZON_SHULKER_SPACE_OUTPUT").unwrap()).unwrap();
for line in input.lines() {
let row: serde_json::Value = serde_json::from_str(line).unwrap();
let current = &row["current"];
let current = Vec3::new(
current[0].as_f64().unwrap(),
current[1].as_f64().unwrap(),
current[2].as_f64().unwrap(),
);
let target = std::array::from_fn(|i| row["target"][i].as_i64().unwrap() as i32);
let cells: Vec<_> = row["cells"]
.as_array()
.unwrap()
.iter()
.map(|cell| {
let pos = std::array::from_fn(|i| cell[i].as_i64().unwrap() as i32);
let state = match cell[3].as_str().unwrap() {
"minecraft:air" => 0,
"minecraft:soul_sand" => 1,
"minecraft:stone" => 2,
"minecraft:scaffolding" => 3,
"minecraft:oak_slab" => 4,
_ => panic!("unknown fixture block"),
};
(pos, state)
})
.collect();
writeln!(
output,
"{}",
check(current, &cells, target, row["face"].as_u64().unwrap() as u8)
)
.unwrap();
}
}
@@ -0,0 +1,51 @@
package dev.horizonlab.parity;
import com.google.gson.JsonParser;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.monster.Shulker;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** Original oracle: the native shulker attachment predicate on a recording level. */
public final class VanillaShulkerSpaceOracle {
public static void main(String[] args) throws Exception {
ParityData.init();
var allocator = Class.forName("sun.misc.Unsafe");
var field = allocator.getDeclaredField("theUnsafe");
field.setAccessible(true);
VanillaBlockUpdateOracle.unsafe = field.get(null);
VanillaBlockUpdateOracle.allocate = allocator.getMethod("allocateInstance", Class.class);
VanillaBlockUpdateOracle.AIR = Blocks.AIR.defaultBlockState();
VanillaBlockUpdateOracle.OVERWORLD_TYPE = VanillaBlockUpdateOracle.loadOverworld();
var stay = Shulker.class.getDeclaredMethod("canStayAt", BlockPos.class, Direction.class);
stay.setAccessible(true);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) {
for (var line : Files.readAllLines(Path.of(args[0]))) {
var input = JsonParser.parseString(line).getAsJsonObject();
// Resolve names through actual vanilla states; the shared world takes numeric ids.
var cells = input.getAsJsonArray("cells");
for (var cell : cells) {
var row = cell.getAsJsonArray();
var name = row.get(3).getAsString();
var state = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(
net.minecraft.resources.Identifier.parse(name)).defaultBlockState();
row.set(3, new com.google.gson.JsonPrimitive(Block.getId(state)));
}
var world = VanillaBlockUpdateOracle.build(input);
world.registries = ParityData.access;
var shulker = new Shulker(EntityTypes.SHULKER, world);
var current = input.getAsJsonArray("current");
shulker.setPos(current.get(0).getAsDouble(), current.get(1).getAsDouble(), current.get(2).getAsDouble());
var target = VanillaBlockUpdateOracle.readPos(input.getAsJsonArray("target"));
var direction = Direction.from3DDataValue(input.get("face").getAsInt());
out.println(stay.invoke(shulker, target, direction));
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Compare native shulker attachment queries with Horizon's production block queries."""
import os
from pathlib import Path
import json
import tempfile
try:
from . import lab, oracle_harness as harness, test_rust_parity as base, integration_env
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base, integration_env
def cases():
steps = [(0,-1,0),(0,1,0),(0,0,-1),(0,0,1),(-1,0,0),(1,0,0)]
for x,z in [(0,0),(15,15),(-1,-1)]:
target=[x,64,z]
for name in ['air','soul_sand','stone','scaffolding','oak_slab']:
for face,(dx,dy,dz) in enumerate(steps):
yield json.dumps(dict(seed=42,cells=[[x+dx,64+dy,z+dz,'minecraft:'+name]],
current=[8.5,64,8.5],target=target,face=face))
for y in range(60,69):
yield json.dumps(dict(seed=42,cells=[[x,63,z,'minecraft:scaffolding'],[x,65,z,'minecraft:oak_slab']],
current=[x+.5,y,z+.5],target=target,face=1))
for face,(dx,dy,dz) in enumerate(steps):
yield json.dumps(dict(seed=42,cells=[[x+dx,64+dy,z+dz,'minecraft:stone'],[x,64,z,'minecraft:stone']],
current=[8.5,64,8.5],target=target,face=face))
def main():
lock, directory = lab.config()
manifest, client = lab.import_client(lock, directory)
natives = {v['downloads']['artifact']['path'] for v in manifest['libraries']
if v['name'].split(':')[-1].startswith('natives-')}
libraries = [lab.import_library(a, directory / 'libraries/client')
for a in lab.client_library_artifacts(manifest) if a['path'] not in natives]
build = lab.LOCAL / 'rust-shulker-space-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
sources = [Path(__file__), lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/oracle_harness.py',
lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/integration_env.py',
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-client/src/mobs/shulker.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/shulker_space_tests.rs',
lab.ROOT / 'crates/horizon-client-render/src/collisions.rs',
lab.ROOT / 'crates/horizon-world/src/block_collision.rs',
lab.ROOT / 'crates/horizon-world/src/collision_context.rs',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaShulkerSpaceOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockUpdateOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java']
hashes = base.source_hashes(sources)
rows = list(cases())
inputs = build / 'inputs.txt'
inputs.write_text('\n'.join(rows) + '\n', encoding='utf-8')
input_hash = lab.digest(inputs, 'sha256')
classes, dependencies, _ = harness.compile_oracle(build, 'VanillaShulkerSpaceOracle',
{'client': client}, libraries, lock['minecraft']['java_major'])
harness.java_run(build, classes, dependencies, 'VanillaShulkerSpaceOracle',
[inputs, build / 'client.txt'], 'client')
with tempfile.TemporaryDirectory(prefix='horizon-shulker-space-parity-') as scratch:
env = integration_env.game_environment(os.environ, scratch)
env.update(HORIZON_SHULKER_SPACE_INPUT=str(inputs), HORIZON_SHULKER_SPACE_OUTPUT=str(build / 'rust.txt'))
harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client-lib', '--lib',
'mobs::shulker::space_tests::shulker_space_parity_fixture',
'--', '--exact', '--ignored'], build, 'rust', env=env)
matched = harness.compare(build / 'client.txt', build / 'rust.txt', len(rows))
base.ensure_sources_unchanged(sources, hashes)
if lab.digest(inputs, 'sha256') != input_hash:
raise AssertionError('Input fixtures changed')
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'], sources_sha256=hashes,
client_jar_sha256=lab.digest(client, 'sha256'), input_sha256=input_hash,
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope='Actual native Shulker.canStayAt, loadedAndEntityCanStandOnFace and noCollision on a recording level, '
'compared with the production Horizon block query used by attachment and teleport. '
'Five support blocks,all six faces,blocked targets,nine current heights and three chunk-boundary positions. '
'Rust uses original minimal facts/collision fixtures; normal scale and empty equipment. '
'No nearby entities,world border,random teleport destination selection,AI ticks,networking or rendered interpolation comparison.'))
print(f'{matched} shulker attachment cases match the pinned client: {report}', flush=True)
if __name__ == '__main__':
main()