Respect the controlling passenger for remote boats

This commit is contained in:
voidsip committed 2026-11-06 00:21:23 -06:00
1 parent a589a3dd9b
commit 24224f4728
8 files changed
+381 -7

No files matched your search

+5
View File
@@ -71,6 +71,7 @@ mod boarding;
mod boat_authority_fixture;
mod boat_leads;
mod boat_passengers;
mod boat_rider_fixture;
mod boats;
mod books;
mod bossbars;
@@ -21760,6 +21761,10 @@ fn start_game<'a>(
},
);
}
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
return Ok(None);
}
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
return Ok(None);
@@ -0,0 +1,90 @@
//! Headless passenger-order regression through the real remote client paths.
use super::{Game, Keys, boats::Boat};
use horizon_client::{Result, held::Source, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
use horizon_protocol::{connection::Pipe, packets::play::Serverbound};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
use winit::keyboard::KeyCode;
#[derive(Deserialize)]
struct Case {
name: String,
first: Vec<bool>,
velocity: [f64; 3],
spin: f32,
keys: [bool; 4],
event: i8,
}
impl Game<'_> {
pub(super) fn run_boat_rider_fixture(&mut self, directory: &Path) -> Result<()> {
let cases: Vec<Case> =
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
let (client, peer) = Pipe::pair();
self.connect_local(client, "BoatSeatFixture")?;
self.remote.as_mut().unwrap().entity_id = 1;
self.player = Some(PlayerMotion::new(self.motion, Vec3::new(0.0, 100.0, 0.0))?);
let mut output = Vec::new();
for case in cases {
self.boats.clear();
let mut b = Boat::new(3, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
let [x, y, z] = case.velocity;
b.velocity = Vec3::new(x, y, z);
b.delta_rotation = case.spin;
self.keys.release_source(Source::Agent);
for (key, held) in [KeyCode::KeyA, KeyCode::KeyD, KeyCode::KeyW, KeyCode::KeyS]
.into_iter()
.zip(case.keys)
{
if held {
self.keys.press(key, Source::Agent);
}
}
self.boats.push(b);
let mut steps = Vec::new();
for first in case.first {
self.remote_set_passengers(3, if first { &[1, 2] } else { &[2, 1] });
let mut accepts_server_moves = false;
self.tick_remote_objects(
|_, p| {
accepts_server_moves = true;
p
},
|_, yaw| yaw,
|_| false,
);
self.remote_ride(
Keys {
left: case.keys[0],
right: case.keys[1],
forward: case.keys[2],
backward: case.keys[3],
..Keys::default()
},
7,
)?;
let packets = self.remote_vehicle_packets();
if case.event != 0 {
self.remote_boat_event(3, case.event);
}
let boat = &self.boats[0];
steps.push(json!({
"controlled": self.remote.as_ref().unwrap().first_passenger,
"accepts_server_moves": accepts_server_moves,
"hands_busy": self.hands_busy(),
"moves": packets.iter().filter(|p| matches!(p, Serverbound::MoveVehicle(_))).count(),
"paddles": packets.iter().filter(|p| matches!(p, Serverbound::PaddleBoat(_))).count(),
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
}));
}
output.push(json!({"name": case.name, "steps": steps}));
}
peer.shutdown();
self.remote = None;
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
Ok(())
}
}
+1 -1
View File
@@ -908,7 +908,7 @@ impl Game<'_> {
/// client moves only the boat its player drives.
fn boat_can_simulate_movement(&self, id: u32) -> bool {
if self.remote.is_some() {
self.riding == Some(id)
self.controls_boat(id)
} else {
!self.guest_vehicles().contains(&id)
}
+1 -1
View File
@@ -439,7 +439,7 @@ impl Game<'_> {
/// `LocalPlayer.isHandsBusy`: paddling a boat.
pub(super) fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
.is_some_and(|id| self.controls_boat(id) && self.boats.iter().any(|b| b.id == id))
&& [
winit::keyboard::KeyCode::KeyW,
winit::keyboard::KeyCode::KeyS,
@@ -332,7 +332,7 @@ impl Game<'_> {
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
let id = id as u32;
// The boat the player drives turns here (isLocalInstanceAuthoritative).
let driven = self.riding;
let driven = self.riding.filter(|&id| self.controls_boat(id));
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
if Some(id) == driven {
return;
@@ -390,7 +390,7 @@ impl Game<'_> {
}
}
// The boat the player drives moves here, not where the server says.
let driven = self.riding;
let driven = self.riding.filter(|&id| self.controls_boat(id));
for b in &mut self.boats {
if Some(b.id) == driven {
continue;
@@ -243,6 +243,12 @@ impl Game<'_> {
Some(position)
}
/// AbstractBoat.getControllingPassenger: only the first player steers a boat.
/// A non-remote world retains its existing local movement ownership.
pub(super) fn controls_boat(&self, id: u32) -> bool {
self.riding == Some(id) && self.remote.as_ref().is_none_or(|r| r.first_passenger)
}
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys, sprint_window: i32) -> Result<bool> {
@@ -276,8 +282,11 @@ impl Game<'_> {
// It steers with the input `LocalPlayer.rideTick` latched on the
// previous tick, so the paddle packet describes the motion the
// server is sent; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let mut b = self.boats[i].clone();
if self.controls_boat(id) {
b = self.tick_boat(b, true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
}
// positionRider: the seat turns with the boat; clampRotation.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
@@ -338,6 +347,7 @@ impl Game<'_> {
}
let Some(b) = self
.riding
.filter(|&id| self.controls_boat(id))
.and_then(|id| self.boats.iter().find(|b| b.id == id))
else {
return Vec::new();
@@ -372,6 +382,7 @@ impl Game<'_> {
}
let Some(b) = self
.riding
.filter(|&id| self.controls_boat(id))
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
else {
return;
@@ -403,7 +414,7 @@ impl Game<'_> {
/// the synched bubble time.
pub(super) fn tick_remote_boat_bubbles(&mut self) {
for i in 0..self.boats.len() {
let driven = self.riding == Some(self.boats[i].id);
let driven = self.controls_boat(self.boats[i].id);
if !driven {
// `AbstractBoat.tick`: the non-authoritative copy carries no
// cached motion into the next time this player drives it.
@@ -0,0 +1,110 @@
package dev.horizonlab.parity;
import com.google.common.collect.ImmutableList;
import com.google.gson.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.Predicate;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.player.*;
import net.minecraft.core.*;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.vehicle.boat.Boat;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.*;
import net.minecraft.world.phys.*;
/** Actual boat authority, LocalPlayer rideTick and sendChanges; world lifecycle and packet sinks only are isolated. */
public final class VanillaBoatRiderOracle {
static Field field(Class<?> type, String name) throws Exception {
Field f=type.getDeclaredField(name); f.setAccessible(true); return f;
}
static <T> T make(Class<T> type) throws Exception {
return type.cast(VanillaVehicleContainerOracle.allocate.invoke(VanillaVehicleContainerOracle.unsafe,type));
}
static class Lvl extends ServerLevel {
int next, paddles;
Lvl(){super(null,null,null,null,null,null,false,0L,List.of(),false);}
@Override public boolean isClientSide(){return true;}
@Override public int getNextEntityId(){return next++;}
@Override public void sendPacketToServer(Packet<?> p){if(p instanceof ServerboundPaddleBoatPacket) paddles++;}
@Override public RegistryAccess registryAccess(){return ParityData.access;}
@Override public BlockState getBlockState(BlockPos p){return Blocks.AIR.defaultBlockState();}
@Override public FluidState getFluidState(BlockPos p){return Fluids.EMPTY.defaultFluidState();}
@Override public List<Entity> getEntities(Entity e,AABB b,Predicate<? super Entity> p){return List.of();}
}
static class Vessel extends Boat {
Vessel(Lvl l){super(EntityTypes.OAK_BOAT,l,()->Items.OAK_BOAT);}
@Override public void baseTick(){}
@Override public void move(MoverType t,Vec3 m){}
@Override protected void applyEffectsFromBlocks(){}
@Override protected void positionRider(Entity e, Entity.MoveFunction f){}
@Override public float getGroundFriction(){return 0;}
@Override public net.minecraft.world.scores.PlayerTeam getTeam(){return null;}
}
static class Rider extends LocalPlayer {
Rider(){super(null,null,null,null,null,null,false,null,null);}
@Override public void tick(){}
@Override protected void updateBob(){}
@Override public boolean isSprinting(){return false;}
@Override public boolean onGround(){return false;}
@Override protected boolean isControlledCamera(){return true;}
}
static class Sink extends ClientPacketListener {
int moves;
Sink(){super(null,null,null);}
@Override public boolean hasClientLoaded(){return true;}
@Override public void send(Packet<?> p){if(p instanceof ServerboundMoveVehiclePacket)moves++;}
}
public static void main(String[] args)throws Exception {
ParityData.init(); VanillaVehicleContainerOracle.reflect();
Lvl l=make(Lvl.class); field(Level.class,"registryAccess").set(l,ParityData.access);
JsonArray out=new JsonArray();
for(var element:JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonArray()){
JsonObject c=element.getAsJsonObject();
Rider p=make(Rider.class); Sink sink=make(Sink.class);
field(Entity.class,"level").set(p,l);
field(Entity.class,"position").set(p,Vec3.ZERO);
field(AbstractClientPlayer.class,"clientAvatarState").set(p,new net.minecraft.client.entity.ClientAvatarState());
field(LocalPlayer.class,"connection").set(p,sink);
field(LocalPlayer.class,"lastSentInput").set(p,Input.EMPTY);
ClientInput input=new ClientInput();
field(LocalPlayer.class,"input").set(p,input);
JsonArray keys=c.getAsJsonArray("keys");
input.keyPresses=new Input(keys.get(2).getAsBoolean(),keys.get(3).getAsBoolean(),keys.get(0).getAsBoolean(),keys.get(1).getAsBoolean(),false,false,false);
Entity other=c.get("mob").getAsBoolean()?make(net.minecraft.world.entity.monster.zombie.Zombie.class):make(RemotePlayer.class); p.setId(1); other.setId(2);
Vessel boat=new Vessel(l); boat.setPos(0,100,0);
field(Entity.class,"vehicle").set(p,boat);
JsonArray vel=c.getAsJsonArray("velocity");
boat.setDeltaMovement(new Vec3(vel.get(0).getAsDouble(),vel.get(1).getAsDouble(),vel.get(2).getAsDouble()));
Field spin=field(AbstractBoat.class,"deltaRotation");spin.setFloat(boat,c.get("spin").getAsFloat());
JsonArray steps=new JsonArray();
for(var own:c.getAsJsonArray("first")){
boolean first=own.getAsBoolean();
field(Entity.class,"passengers").set(boat,first?ImmutableList.of(p,other):ImmutableList.of(other,p));
sink.moves=0;l.paddles=0;
boat.tick();p.rideTick();p.sendChanges();
if(c.get("event").getAsInt()!=0)boat.handleEntityEvent(c.get("event").getAsByte());
JsonObject s=new JsonObject();
s.addProperty("controlled",p.getControlledVehicle()==boat);
s.addProperty("accepts_server_moves",!boat.isLocalInstanceAuthoritative());
s.addProperty("hands_busy",p.isHandsBusy());
s.addProperty("moves",sink.moves);s.addProperty("paddles",l.paddles);
s.addProperty("spin",String.format("%08x",Float.floatToRawIntBits(spin.getFloat(boat))));
JsonArray v=new JsonArray();for(double n:new double[]{boat.getDeltaMovement().x,boat.getDeltaMovement().y,boat.getDeltaMovement().z})v.add(String.format("%016x",Double.doubleToRawLongBits(n)));
s.add("velocity",v);steps.add(s);
}
JsonObject row=new JsonObject();row.add("name",c.get("name"));row.add("steps",steps);out.add(row);
}
Files.writeString(Path.of(args[1]),out.toString());
}
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Compare passenger authority through actual vanilla and Horizon client paths.
The Java oracle runs AbstractBoat.tick, LocalPlayer.rideTick and sendChanges with
real first/second passenger identities. Only empty-world lifecycle, collision
movement and packet transport are stand-ins. Horizon runs its remote passenger
handler, object ticker, ride tick, outgoing vehicle packets and item-use gate in
an isolated headless process with a memory connection. No desktop input is used.
The contract compares ownership, packet counts, interpolation dispatch, hands,
angular/vertical motion and every non-authoritative velocity component exactly.
Authoritative horizontal motion remains in the raw outputs as a diagnostic: the
unchanged Rust sin/cos steering kernel differs from vanilla Mth lookup (observed
maximum 2.164393663406372e-6 here). This is not a full trajectory comparison.
"""
import argparse
import copy
import itertools
import json
import os
from pathlib import Path
import subprocess
import struct
import tempfile
try:
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
from .integration_env import run_game
except ImportError:
import lab
import test_rust_parity as base
import test_model_baking_parity as baking
from integration_env import run_game
SOURCES = [
'crates/horizon-client/src/game.rs',
'crates/horizon-client/src/game/boat_rider_fixture.rs',
'crates/horizon-client/src/game/remote_vehicles.rs',
'crates/horizon-client/src/game/boats.rs',
'crates/horizon-client/src/game/remote_objects.rs',
'crates/horizon-client/src/game/hand.rs',
'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBoatRiderOracle.java',
'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java',
'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaVehicleContainerOracle.java',
'tools/test_boat_rider_parity.py',
]
def cases():
out = []
for keys in itertools.product((False, True), repeat=4):
for velocity, spin in (([0., 0., 0.], 0.), ([.125, -.0625, -.25], .125)):
for first in ([False], [True], [True, False, False, True], [False, True, False]):
out.append(dict(name=f'rider-{len(out)}', keys=keys, velocity=velocity,
spin=spin, first=first, mob=False, event=0))
for keys in itertools.product((False, True), repeat=4):
out.append(dict(name=f'mob-first-{len(out)}', keys=keys, velocity=[.125, -.0625, -.25],
spin=.125, first=[False, False], mob=True, event=0))
for event, first, mob in itertools.product((71, 72), ([False], [True], [True, False, True]), (False, True)):
out.append(dict(name=f'bubble-{len(out)}', keys=[False, False, True, False],
velocity=[.125, -.0625, -.25], spin=.125, first=first, mob=mob, event=event))
return out
def contract(row):
"""Project the same authority-based contract on either raw implementation."""
result = copy.deepcopy(row)
for step in result['steps']:
if step['controlled']:
step['velocity'] = [None, step['velocity'][1], None]
return result
def compare_outputs(inputs, native, rust):
assert len(native) == len(rust) == len(inputs)
errors = [(case['name'], n, r) for case, n, r in zip(inputs, native, rust)
if contract(n) != contract(r)]
different_cases = set()
deltas = []
for case, n, r in zip(inputs, native, rust):
assert len(n['steps']) == len(r['steps']) == len(case['first'])
for ns, rs in zip(n['steps'], r['steps']):
if ns['controlled'] and rs['controlled']:
for axis in (0, 2):
a, b = ns['velocity'][axis], rs['velocity'][axis]
if a != b:
different_cases.add(case['name'])
av = struct.unpack('>d', bytes.fromhex(a))[0]
bv = struct.unpack('>d', bytes.fromhex(b))[0]
deltas.append(abs(av - bv))
return errors, dict(cases=len(different_cases), components=len(deltas),
max_absolute_delta=max(deltas, default=0.0),
compared=False, reason='unchanged authoritative sin/cos steering kernel')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path, help='use an already built debug client')
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
parser.add_argument('--java-only', action='store_true')
args = parser.parse_args()
lock, jars, _ = base.verify_artifacts()
work = lab.LOCAL / 'boat-rider-parity'
work.mkdir(parents=True, exist_ok=True)
report = work / 'report.json'
report.unlink(missing_ok=True)
hashes = {name: lab.digest(lab.ROOT / name, 'sha256') for name in SOURCES}
inputs = cases()
lab.write_json(work / 'inputs.json', inputs)
classes = work / 'classes'
classes.mkdir(exist_ok=True)
sources = lab.ROOT / 'mods/horizon/src/test/java'
package = sources / 'dev/horizonlab/parity'
dependencies = os.pathsep.join(map(str, [jars['client'], *baking.client_libraries()]))
def run(command, name):
with (work / f'{name}.log').open('w', encoding='utf-8') as log:
result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, stdout=log,
stderr=subprocess.STDOUT, timeout=1800)
if result.returncode:
raise RuntimeError(f'{name} failed: {(work / f"{name}.log").read_text(encoding="utf-8")[-4000:]}')
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn',
'-sourcepath', sources, '-cp', dependencies, '-d', classes,
*(package / f'{name}.java' for name in
('ParityData', 'VanillaVehicleContainerOracle', 'VanillaBoatRiderOracle'))], 'compile-java')
run(['java', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaBoatRiderOracle', work / 'inputs.json', work / 'vanilla.json'], 'vanilla')
if args.java_only:
print(f'actual Java passenger methods: {len(inputs)} cases')
return
binary = args.binary
if binary is None:
run(['cargo', 'build', '-p', 'horizon-client', '--bin', 'horizon-client'], 'compile-rust')
binary = lab.cargo_target() / 'debug' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
with tempfile.TemporaryDirectory(prefix='hz-boat-rider-') as scratch:
with (work / 'rust.log').open('w', encoding='utf-8') as log:
run_game([str(binary.resolve()), '--headless', '--resources', str(args.resources.resolve()),
'--game-dir', scratch], cwd=scratch,
env=lab.game_env(HORIZON_BOAT_RIDER_FIXTURE=str(work),
HORIZON_API_DISCOVERY='http://127.0.0.1:9'),
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180)
native = json.loads((work / 'vanilla.json').read_text(encoding='utf-8'))
rust = json.loads((work / 'rust.json').read_text(encoding='utf-8'))
errors, horizontal = compare_outputs(inputs, native, rust)
if errors:
raise AssertionError(f'{len(errors)}/{len(inputs)} boat rider cases differ: {errors[:2]}')
assert hashes == {name: lab.digest(lab.ROOT / name, 'sha256') for name in SOURCES}, 'sources changed during validation'
ticks = sum(len(case['first']) for case in inputs)
lab.write_json(report, dict(minecraft=lock['minecraft']['version'], cases=len(inputs), ticks=ticks,
output_sha256=lab.digest(work / 'rust.json', 'sha256'),
source_hashes=hashes, authoritative_horizontal_diagnostic=horizontal,
scope=__doc__))
print(f'boat rider: {len(inputs)}/{len(inputs)} authority cases and {ticks} tick states match vanilla; '
f'authoritative horizontal diagnostic: {horizontal}')
if __name__ == '__main__':
main()