Place boat items at the exact server POV hit point

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 02:01:51 +01:00
1 parent b368b50c60
commit cdd00e29a3
7 files changed
+282 -69

No files matched your search

+3 -68
View File
@@ -2,6 +2,8 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
@@ -1251,48 +1253,7 @@ impl Game<'_> {
if !self.boat_models.has(wood, chest) {
return None;
}
let (eye, dir) = self.aim()?;
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
let block = self.pick().map(|h| {
(
h.point,
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt(),
)
});
let mut fluid = None;
let mut t = 0.0;
while t < reach {
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
let (x, y, z) = (
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
);
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
fluid = Some((fluid_hit_point(p, y, h), t));
break;
}
t += 0.05;
}
let hit = match (block, fluid) {
(Some(b), Some(w)) => {
if w.1 < b.1 {
w.0
} else {
b.0
}
}
(Some(b), None) => b.0,
(None, Some(w)) => w.0,
(None, None) => return None,
};
let hit = self.boat_item_hit()?;
let mut boat = Boat::new(
self.next_entity + 1,
Arc::from(wood),
@@ -1623,36 +1584,10 @@ impl Game<'_> {
}
}
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
/// (the same fluid above it) is a full cube, so the hit is the ray's own
/// point and a boat placed under water stays at the eye; a partial cell is
/// clipped to its surface.
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
if h >= 1.0 {
p
} else {
[p[0], f64::from(y) + f64::from(h), p[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
/// cell is clipped to its surface.
#[test]
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
let eye = [0.5, 3.62, 0.5];
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
assert_eq!(
fluid_hit_point(eye, 3, 8.0 / 9.0),
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
);
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
@@ -0,0 +1,106 @@
//! BoatItem's server POV clip; deliberately separate from renderer/mining picks.
use super::{Fluid, Game};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::io;
struct BoatItemRay<'a, 'r>(&'a Game<'r>);
impl RayWorld for BoatItemRay<'_, '_> {
type Error = io::Error;
type Block = BlockStateId;
type Fluid = Option<(f32, bool, Fluid)>;
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
Ok(self.0.world.block(p.x, p.y, p.z))
}
fn fluid_state(&mut self, p: BlockPos) -> io::Result<Self::Fluid> {
Ok(self
.0
.fluid_height_of(p.x, p.y, p.z, None)
.map(|(h, source)| {
(
h,
source,
self.0
.res
.facts()
.get(self.0.world.block(p.x, p.y, p.z))
.fluid,
)
}))
}
fn block_shape(
&mut self,
state: BlockStateId,
p: BlockPos,
_: RayBlockMode,
) -> io::Result<VoxelShape> {
// LiquidBlock.getShape is empty. Waterlogged blocks retain their outline.
if matches!(
&*self.0.res.facts().get(state).name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
) {
return Ok(VoxelShape::empty());
}
let mut shape = VoxelShape::empty();
for b in self.0.outline_boxes([p.x, p.y, p.z]) {
let b: [f64; 6] = std::array::from_fn(|i| b[i] - f64::from([p.x, p.y, p.z][i % 3]));
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))
.map_err(|e| io::Error::other(format!("{e:?}")))?;
shape = shape
.join(&part, ShapeOp::OR)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
Ok(shape)
}
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
// Pinned vanilla's registered interaction shapes are all empty;
// interaction_shapes::InteractionShapes validates that same table.
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.1))
}
fn fluid_is_empty(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_none())
}
fn fluid_is_water(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.2 == Fluid::Water))
}
fn fluid_shape(&mut self, s: Self::Fluid, _: BlockPos) -> io::Result<VoxelShape> {
match s {
None => Ok(VoxelShape::empty()),
Some((h, _, _)) => {
VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, f64::from(h), 1.0)))
.map_err(|e| io::Error::other(format!("{e:?}")))
}
}
}
}
impl Game<'_> {
/// `Item.getPlayerPOVHitResult`: float view vector, OUTLINE and Fluid.ANY.
pub(super) fn boat_item_hit(&self) -> Option<[f64; 3]> {
let [x, y, z] = self.eye_position()?;
let start = Vec3::new(x, y, z);
let dir = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
let end = start.add_vector(dir.scale(self.reach()));
let hit = clip_world(
&mut BoatItemRay(self),
start,
end,
RayBlockMode::Outline,
RayFluidMode::Any,
256,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
}
}
@@ -273,6 +273,52 @@ impl Game<'_> {
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"use_item" => {
// Exercise the connected owner's normal server packet route.
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
let pitch = op["pitch"].as_f64().unwrap_or(0.0) as f32;
let owner = self
.serving
.as_ref()
.ok_or("command harness: no server")?
.owner;
self.in_owner_level(|g| -> Result<()> {
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|guest| guest.uuid == owner && guest.placed)
.map(|guest| guest.conn)
})
.ok_or("command harness: owner is not connected")?;
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::UseItem(
horizon_protocol::packets::play::UseItem {
hand: 0,
yaw,
pitch,
sequence,
},
),
);
let ack = host
.hub
.guest(conn)
.is_some_and(|g| g.ack_up_to.get() >= sequence);
g.lan = Some(host);
if !ack {
return Err("command harness: UseItem was not acknowledged".into());
}
Ok(())
})?;
}
"use_block" => {
// Exercise the connected owner's normal server packet route.
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
@@ -251,6 +251,19 @@ public final class VanillaCommandExecOracle {
}
}
case "settle" -> settle();
case "use_item" -> {
player.connection.handleUseItem(new net.minecraft.network.protocol.game.ServerboundUseItemPacket(
net.minecraft.world.InteractionHand.MAIN_HAND, op.get("sequence").getAsInt(),
op.get("yaw").getAsFloat(), op.get("pitch").getAsFloat()));
try {
var ack = net.minecraft.server.network.ServerGamePacketListenerImpl.class.getDeclaredField("ackBlockChangesUpTo");
ack.setAccessible(true);
if (ack.getInt(player.connection) < op.get("sequence").getAsInt())
throw new IllegalStateException("UseItem was not acknowledged");
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
case "use_block" -> {
JsonArray p = op.getAsJsonArray("pos");
BlockPos pos = new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt());
+59
View File
@@ -0,0 +1,59 @@
"""BoatItem use: exact placement and one-tick identity, no ray mocks."""
from command_exec_corpus import cmd, setup
def boat_item_hits(motion_diagnostic=False):
out = setup('/tick freeze', '/gamerule spawn_mobs false', '/gamerule advance_time false',
'/scoreboard objectives add boat_hit dummy')
out += [cmd('/forceload add 0 0 15 15', expect_result=1), {'op':'settle'},
cmd('/execute if loaded 5 200 5',expect_result=1)]
cases=[('stone',5.5,5.5,0,50,True),('turned',5.3,5.8,37.5,41.25,True),
('water-near',5.5,5.5,0,38,False),('water-far',5.5,5.5,0,30,True),
('slab',5.5,5.5,0,45,True),('pig',5.5,5.5,0,50,False),
('wall',5.5,5.5,0,10,False)]
for sequence,(name,x,z,yaw,pitch,placed) in enumerate(cases,1):
out += setup('/gamemode creative','/kill @e[type=!player]', '/clear @s',
'/fill 0 200 0 15 204 15 air','/fill 0 199 0 15 199 15 stone')
if name.startswith('water'):
out += [cmd('/fill 4 200 6 7 200 9 water[level=0]',expect_result=16),
cmd('/execute if block 5 200 7 water[level=0]',expect_result=1)]
if name=='slab':
out += [cmd('/fill 4 200 6 7 200 9 stone_slab[type=bottom,waterlogged=false]',expect_result=16),
cmd('/execute if block 5 200 7 stone_slab[type=bottom,waterlogged=false]',expect_result=1)]
if name=='wall':
out += [cmd('/fill 4 200 7 7 203 7 stone',expect_result=16),
cmd('/execute if block 5 203 7 stone',expect_result=1)]
if name=='pig':
out += [cmd('/summon pig 5.5 200 6.9 {UUID:[I;310,0,0,1],NoAI:1b,Tags:["boat_blocker"]}',expect_result=1),
cmd('/execute if entity @e[type=pig,tag=boat_blocker,nbt={UUID:[I;310,0,0,1],Health:10f,NoAI:1b}]',expect_result=1)]
out += setup(f'/tp @s {x} 200 {z} {yaw} {pitch}', '/gamemode survival',
'/item replace entity @s weapon.mainhand with oak_boat')
out += [cmd('/execute if entity @s[type=player,gamemode=survival]',expect_result=1),
cmd('/execute if data entity @s UUID',expect_result=1),
cmd('/data get entity @s Pos'),cmd('/data get entity @s Rotation'),cmd('/data get entity @s Health',expect_result=20),
cmd('/clear @s oak_boat 0',expect_result=1),cmd('/execute unless entity @e[type=oak_boat]',expect_result=1),
cmd('/execute if block 5 199 5 stone',expect_result=1)]
out += setup('/execute store result score start boat_hit run time query gametime')
out += [{'op':'use_item','yaw':yaw,'pitch':pitch,'sequence':sequence},cmd('/execute if entity @s[type=player]',expect_result=1)]
out += [cmd('/execute '+('if' if placed else 'unless')+' entity @e[type=oak_boat]',expect_result=1),
cmd('/clear @s oak_boat 0')]
if placed:
out += [cmd('/tag @e[type=oak_boat,limit=1] add placed_boat',expect_result=1),
cmd('/data modify storage boat_hit:identity uuid set from entity @e[tag=placed_boat,limit=1] UUID',expect_result=1),
cmd('/data get entity @e[tag=placed_boat,limit=1] Pos'),cmd('/data get entity @e[tag=placed_boat,limit=1] Rotation')]
if placed and motion_diagnostic:
out.append(cmd('/data get entity @e[tag=placed_boat,limit=1] Motion'))
out += setup('/execute store result score elapsed boat_hit run time query gametime','/scoreboard players operation elapsed boat_hit -= start boat_hit')
out += [cmd('/scoreboard players get elapsed boat_hit',expect_result=0),cmd('/tick step 1',expect_result=1),{'op':'tick','n':1}]
out += setup('/execute store result score elapsed boat_hit run time query gametime','/scoreboard players operation elapsed boat_hit -= start boat_hit')
out += [cmd('/scoreboard players get elapsed boat_hit',expect_result=1)]
if placed:
out += [cmd('/execute if entity @e[type=oak_boat,tag=placed_boat]',expect_result=1),
cmd('/data modify storage boat_hit:identity uuid set from entity @e[tag=placed_boat,limit=1] UUID'),
cmd('/data get entity @e[tag=placed_boat,limit=1] Pos')]
else:
out += [cmd('/execute unless entity @e[type=oak_boat]',expect_result=1)]
if placed and motion_diagnostic:
out.append(cmd('/data get entity @e[tag=placed_boat,limit=1] Motion'))
out += setup('/data remove storage boat_hit:identity uuid')
return out
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""BoatItem server UseItem: exact placement, consumption and one-tick identity.
Seven independently reset stone/turned/water/slab/entity/wall cases. The
optional Motion observations are additive raw diagnostics for boat physics.
"""
import argparse
from pathlib import Path
import subprocess
import sys
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path)
parser.add_argument('--prepare', action='store_true')
parser.add_argument('--motion-diagnostic', action='store_true')
parser.add_argument('--reference-only', action='store_true')
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
folder = 'boat-item-hit-motion' if args.motion_diagnostic else 'boat-item-hit-parity'
command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'),
'--boat-item-hit-fixture', '--work-dir', str(root / '.local' / folder)]
if args.binary:
command += ['--binary', str(args.binary.resolve())]
if args.prepare:
command.append('--prepare')
if args.motion_diagnostic:
command.append('--boat-motion-diagnostic')
if args.reference_only:
command.append('--reference-only')
return subprocess.run(command, cwd=root).returncode
if __name__ == '__main__':
sys.exit(main())
+19 -1
View File
@@ -83,8 +83,12 @@ def main():
parser.add_argument('--binary', type=Path, help='use an already built Horizon game instead of building it')
parser.add_argument('--slot-codec-diagnostic', action='store_true',
help='retain the separate invalid inline slot-range codec diagnostic')
parser.add_argument("--boat-item-hit-fixture", action="store_true")
parser.add_argument("--work-dir", type=Path)
parser.add_argument("--reference-only", action="store_true")
parser.add_argument("--boat-motion-diagnostic", action="store_true")
args = parser.parse_args()
build = lab.LOCAL / 'command-exec-parity'
build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity'
build.mkdir(parents=True, exist_ok=True)
report_path = build / 'report.json'
report_path.unlink(missing_ok=True)
@@ -115,6 +119,9 @@ def main():
if args.sulfur_power_load_diagnostic:
groups = [('setup', corpus_module.group_setup()),
('sulfur powered-load diagnostic', corpus_module.sulfur_cube_fuse_ticks(True))]
if args.boat_item_hit_fixture:
from boat_item_hit_corpus import boat_item_hits
groups = [('boat item hit', boat_item_hits(args.boat_motion_diagnostic))]
if args.only:
groups = [g for g in groups if args.only in g[0] or g[0] == 'setup']
if args.slot_codec_diagnostic:
@@ -138,6 +145,17 @@ def main():
run(['java', '-Xmx3G', '-Duser.timezone=UTC', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
'dev.horizonlab.parity.VanillaCommandExecOracle', vanilla_dir / 'universe', script_path, build / 'vanilla.jsonl', SEED], 'java', cwd=vanilla_dir)
if args.reference_only:
reference = [json.loads(line) for line in (build / 'vanilla.jsonl').read_text().splitlines()]
recorded = [op for op in script if op['op'] == 'cmd' and op.get('record', True)]
if len(recorded) != len(reference):
sys.exit('reference-only record count differs')
for op, row in zip(recorded, reference):
if 'expect_result' in op and row.get('results') != [[True, op['expect_result']]]:
sys.exit(f'Java fixture guard failed: {op}: {row.get("results")}')
print(f'Java reference: {len(reference)} records, fixture guards passed')
return
if args.binary:
binary = args.binary.resolve(strict=True)
else: