Predict fishing bobber block hits before client movement

This commit is contained in:
voidsip committed 2026-11-05 20:20:54 -06:00
1 parent 9f8f6842db
commit 36c0d3f938
3 files changed
+102 -36

No files matched your search

+61 -26
View File
@@ -349,6 +349,34 @@ impl Game<'_> {
"vz": hook.velocity.z.to_bits() as i64, "nibble": hook.nibble, "next": hook.random.next_long(),
}));
}
// The client must take the same block hit before moving as the server;
// otherwise it lands farther away and slides back on the next update.
let stone = self
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
self.level_set(block, stone).unwrap();
for (case, motion) in [
Vec3::new(0.2, -0.4, 0.1),
Vec3::new(-0.2, -0.4, 0.1),
Vec3::new(0.0, -0.6, -0.2),
]
.into_iter()
.enumerate()
{
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
let mut random = LegacyRandom::new(0);
for tick in 0..5 {
state = self.client_hook_step(state, &mut random);
rows.push(serde_json::json!({
"case": case, "tick": tick,
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
"motion": [state.1.x, state.1.y, state.1.z],
}));
}
}
self.hook = old_hook;
self.offhand = old_offhand;
self.player.as_mut().unwrap().position = old_position;
@@ -874,27 +902,8 @@ impl Game<'_> {
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
})
.ok()
.flatten()
};
let entity_end = block_hit.map_or(to, |hit| hit.location);
let block_hit = self.hook_block_hit(h.position, to);
let entity_end = block_hit.unwrap_or(to);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
let hit = first_entity_hit(
@@ -968,9 +977,9 @@ impl Game<'_> {
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[hit.location.x, hit.location.y, hit.location.z],
[hit.x, hit.y, hit.z],
);
h.velocity = rebound(h.velocity, hit.location, h.position);
h.velocity = rebound(h.velocity, hit, h.position);
}
}
State::Hooked(id) => {
@@ -1060,7 +1069,7 @@ impl Game<'_> {
}
}
if !in_water && !h.on_ground {
h.velocity.y -= 0.03;
h.velocity.y -= f64::from(0.03_f32);
}
let mut scene = SceneCollisions {
world: &self.world,
@@ -1584,6 +1593,28 @@ impl Game<'_> {
self.remote_hooks = hooks;
}
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
self.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?
.clip(from, to, p)
})
.ok()
.flatten()
.map(|hit| hit.location)
}
/// `FishingHook.tick`: one tick of movement without the fishing (the
/// server's): position, motion, state and whether it rests on the ground.
fn client_hook_step(
@@ -1608,7 +1639,11 @@ impl Game<'_> {
State::Flying if velocity == Vec3::ZERO && on_ground => {
return (position, velocity, state, on_ground);
}
State::Flying => {}
State::Flying => {
if let Some(hit) = self.hook_block_hit(position, position.add_vector(velocity)) {
velocity = rebound(velocity, hit, position);
}
}
State::Hooked(id) => {
// updateHookedEntity: at 0.8 of the hooked entity's height.
let hooked = if self
@@ -1661,7 +1696,7 @@ impl Game<'_> {
}
}
if !in_water && !on_ground {
velocity.y -= 0.03;
velocity.y -= f64::from(0.03_f32);
}
let mut scene = SceneCollisions {
world: &self.world,
@@ -345,6 +345,31 @@ public final class VanillaCommandExecOracle {
bobs.add(row);
hook.discard();
}
// Dry block hits run on both sides: compare the client's movement
// prediction with the real hook before and after touching the floor.
level.setBlockAndUpdate(waterPos, net.minecraft.world.level.block.Blocks.STONE.defaultBlockState());
double[][] motions = {{0.2, -0.4, 0.1}, {-0.2, -0.4, 0.1}, {0.0, -0.6, -0.2}};
for (int c = 0; c < motions.length; c++) {
var hook = new net.minecraft.world.entity.projectile.FishingHook(player, level, 0, 0);
hook.setPos(0.5, 301.1, 0.5);
hook.setDeltaMovement(motions[c][0], motions[c][1], motions[c][2]);
for (int tick = 0; tick < 5; tick++) {
hook.tick();
var at = hook.position();
var motion = hook.getDeltaMovement();
JsonObject row = new JsonObject();
row.addProperty("case", c);
row.addProperty("tick", tick);
JsonArray position = new JsonArray();
position.add(at.x); position.add(at.y); position.add(at.z);
JsonArray movement = new JsonArray();
movement.add(motion.x); movement.add(motion.y); movement.add(motion.z);
row.add("position", position);
row.add("motion", movement);
bobs.add(row);
}
hook.discard();
}
player.snapTo(oldPos.x, oldPos.y, oldPos.z, originalYaw, originalPitch);
level.setBlockAndUpdate(waterPos, oldBlock);
Files.writeString(output.resolveSibling("fishing-bobbing-reference.json"), bobs.toString());
+16 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Compare fishing retrieval against a real 26.3 server and player.
"""Compare fishing movement and retrieval against a real 26.3 server and player.
The Java side calls FishingHook.retrieve with seeded hook and table sequences.
The Rust side compares seeded cast velocity, a bobbing tick, catch-timer
The Rust side compares seeded cast velocity, bobbing and dry-ground ticks, catch-timer
transitions and the rod's fallback loot path, plus named-sequence loot and XP.
It does not yet cover the UUID-to-seed relationship.
"""
@@ -49,13 +49,19 @@ def main():
raise SystemExit(f"fishing tick record count differs: {len(reference)} vs {len(actual)}")
expected_bobbing = json.loads((work / "fishing-bobbing-reference.json").read_text())
actual_bobbing = json.loads((work / "rust-fishing-bobbing.json").read_text())
if len(expected_bobbing) != 5 or len(actual_bobbing) != 5:
raise SystemExit(f"expected 5 bobbing cases: {len(expected_bobbing)} vs {len(actual_bobbing)}")
if expected_bobbing != actual_bobbing:
for expected, actual in zip(expected_bobbing, actual_bobbing):
if expected != actual:
raise SystemExit(f"fishing bobbing differs: vanilla {expected}; Horizon {actual}")
raise SystemExit(f"fishing bobbing count differs: {len(expected_bobbing)} vs {len(actual_bobbing)}")
if len(expected_bobbing) != 20 or len(actual_bobbing) != 20:
raise SystemExit(f"expected 20 bobbing and ground cases: {len(expected_bobbing)} vs {len(actual_bobbing)}")
for expected, actual in zip(expected_bobbing, actual_bobbing):
if "case" in expected:
# Collision clipping can differ below a picometre; retain exact
# case/tick matching and compare all three positions and velocities.
if expected["case"] != actual.get("case") or expected["tick"] != actual.get("tick"):
raise SystemExit(f"fishing ground case differs: {expected}; {actual}")
for field in ("position", "motion"):
if len(actual[field]) != 3 or any(not abs(a - b) <= 1e-12 for a, b in zip(expected[field], actual[field])):
raise SystemExit(f"fishing ground differs: vanilla {expected}; Horizon {actual}")
elif expected != actual:
raise SystemExit(f"fishing bobbing differs: vanilla {expected}; Horizon {actual}")
fallback = work / "fallback"
fallback_env = env.copy()
fallback_env.pop("HORIZON_FISHING_TICK_REFERENCE", None)
@@ -81,7 +87,7 @@ def main():
subprocess.run(["cargo", "test", "--profile", "fast", "-p", "horizon-client", "--lib",
"game::fishing::tests::fishing_retrieve_reference", "--", "--exact", "--ignored"],
cwd=lab.ROOT, env=env, check=True)
print("fishing: seeded catch timers, loot and XP match vanilla")
print("fishing: ground prediction, bobbing, seeded catch timers, loot and XP match vanilla")
if __name__ == "__main__":