Widen float constants in experience-orb underwater movement

Preserve the double operations around vanilla float drag, buoyancy and cap constants. Pin six executed Java method vectors and guard the real Game water and air tick paths.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-04 23:21:54 +01:00
1 parent 83ad384f48
commit c0f778ae98
7 files changed
+228 -12

No files matched your search

+76 -5
View File
@@ -1187,6 +1187,15 @@ impl ExperienceOrb {
let sky = light >> 4;
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
}
/// `ExperienceOrb.setUnderwaterMovement`: buoyancy and drag before moving.
fn set_underwater_movement(&mut self) {
// Vanilla widens float constants before applying double arithmetic.
self.velocity = Vec3::new(
self.velocity.x * f64::from(0.99_f32),
(self.velocity.y + f64::from(5.0e-4_f32)).min(f64::from(0.06_f32)),
self.velocity.z * f64::from(0.99_f32),
);
}
pub fn tick(
&mut self,
world: &World,
@@ -1216,11 +1225,7 @@ impl ExperienceOrb {
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
self.velocity = Vec3::new(
self.velocity.x * 0.99,
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
self.set_underwater_movement();
} else if !colliding {
self.velocity.y -= self.get_gravity();
}
@@ -3832,6 +3837,72 @@ mod item_destroyed_tests {
);
}
}
#[cfg(test)]
mod orb_underwater_tests {
use super::*;
#[test]
fn matches_the_executed_java_private_method() {
// Actual 26.3 setUnderwaterMovement calls on server-level orbs,
// captured by test_orb_water_parity.py (orb-water-reference.json).
// Cover both horizontal signs, falling/rising, and the vertical cap.
let cases: [([f64; 3], [u64; 3]); 6] = [
(
[0.0, 0.0, 0.0],
[0x0000000000000000, 0x3f40624de0000000, 0x0000000000000000],
),
(
[0.1, 0.01, -0.2],
[0x3fb9581066666667, 0x3f85810625ae147b, 0xbfc9581066666667],
),
(
[-0.25, -0.03, 0.125],
[0xbfcfae1480000000, 0xbf9e353f7c851eb8, 0x3fbfae1480000000],
),
(
[0.0, 0.059, 0.0],
[0x0000000000000000, 0x3fae76c8b46d9168, 0x0000000000000000],
),
(
[0.5, 0.0595, -0.5],
[0x3fdfae1480000000, 0x3faeb851e0000000, 0xbfdfae1480000000],
),
(
[-1.0, 0.2, 1.0],
[0xbfefae1480000000, 0x3faeb851e0000000, 0x3fefae1480000000],
),
];
let actual: Vec<_> = cases
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(v[0], v[1], v[2]),
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
orb.set_underwater_movement();
[
orb.velocity.x.to_bits(),
orb.velocity.y.to_bits(),
orb.velocity.z.to_bits(),
]
})
.collect();
let expected: Vec<_> = cases.into_iter().map(|(_, bits)| bits).collect();
assert_eq!(actual, expected);
}
}
#[cfg(test)]
mod render_light_tests {
use super::*;
+6 -4
View File
@@ -443,10 +443,12 @@ mod tests {
None,
&fluids,
));
// Current pushes the orb, underwater drag damps before motion, then air
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
// Current pushes the orb, then underwater and air drag each widen
// their float constants before multiplying the double motion.
assert!((carried.position.x - at.x - 0.014 * f64::from(0.99_f32)).abs() < 1e-12);
assert!(
(carried.velocity.x - 0.014 * f64::from(0.99_f32) * f64::from(0.98_f32)).abs() < 1e-12
);
for _ in 0..20 {
carried.tick(
&world,
@@ -149,6 +149,34 @@ public final class VanillaCommandExecOracle {
ServerPlayer player;
// Execute the actual private arithmetic method on unregistered orbs in
// this real server level. No world tick or live scene actor is added.
void writeOrbWaterReference() throws IOException {
String input = System.getProperty("horizon.orbWaterArithmetic");
if (input == null) return;
JsonArray result = new JsonArray();
try {
var method = net.minecraft.world.entity.ExperienceOrb.class.getDeclaredMethod("setUnderwaterMovement");
method.setAccessible(true);
for (var entry : JsonParser.parseString(Files.readString(Path.of(input))).getAsJsonArray()) {
JsonArray p = entry.getAsJsonArray();
var orb = new net.minecraft.world.entity.ExperienceOrb(net.minecraft.world.entity.EntityTypes.EXPERIENCE_ORB, player.level());
orb.setDeltaMovement(p.get(0).getAsDouble(), p.get(1).getAsDouble(), p.get(2).getAsDouble());
method.invoke(orb);
var v = orb.getDeltaMovement();
JsonObject row = new JsonObject(); row.add("input", p);
JsonArray bits = new JsonArray(); JsonArray values = new JsonArray();
for (double n : new double[] {v.x(), v.y(), v.z()}) {
bits.add(Long.toHexString(Double.doubleToRawLongBits(n))); values.add(n);
}
row.add("bits", bits); row.add("motion", values); result.add(row);
}
} catch (ReflectiveOperationException error) {
throw new IOException("orb underwater reference failed", error);
}
Files.writeString(output.resolveSibling("orb-water-reference.json"), result.toString());
}
void runScript() throws IOException {
ServerLevel level = this.overworld();
GameProfile profile = new GameProfile(UUIDUtil.createOfflinePlayerUUID("Player"), "Player");
@@ -167,6 +195,7 @@ public final class VanillaCommandExecOracle {
new EmbeddedChannel(connection);
this.getPlayerList().placeNewPlayer(connection, player, cookie);
settle();
writeOrbWaterReference();
StringBuilder out = new StringBuilder();
Gson gson = new Gson();
for (String line : Files.readAllLines(script, StandardCharsets.UTF_8)) {
+63
View File
@@ -0,0 +1,63 @@
"""Exact water/air orb fields; identity uses registration, never saved UUID NBT."""
try:
from .command_exec_corpus import cmd, setup
except ImportError:
from command_exec_corpus import cmd, setup
def arithmetic_inputs():
return [[0.0, 0.0, 0.0], [0.1, 0.01, -0.2], [-0.25, -0.03, 0.125],
[0.0, 0.059, 0.0], [0.5, 0.0595, -0.5], [-1.0, 0.2, 1.0]]
def orb_water(raw=False):
out = setup('/tick freeze', '/gamerule minecraft:spawn_mobs false',
'/gamerule minecraft:advance_time false', '/gamemode spectator',
'/tp @s 24.5 210 24.5 0 0')
out += [cmd('/forceload add 0 0 31 31', expect_result=4), {'op': 'settle'}]
for x in [0, 16]:
for z in [0, 16]:
out.append(cmd(f'/execute if loaded {x} 200 {z}', expect_result=1))
out += [cmd('/fill 0 200 0 31 207 31 stone', expect_result=8192),
cmd('/fill 0 200 0 31 207 31 air', expect_result=8192),
cmd('/fill 0 199 0 31 199 31 stone', expect_result=1024),
cmd('/fill 2 200 2 4 206 4 glass', expect_result=63),
cmd('/fill 3 200 3 3 206 3 water', expect_result=7)]
for pos, block in [('3 200 3', 'water[level=0]'), ('3 201 3', 'water[level=0]'),
('3 206 3', 'water[level=0]'), ('2 201 3', 'glass'),
('4 201 3', 'glass'), ('3 201 2', 'glass'), ('3 201 4', 'glass'),
('3 199 3', 'stone'), ('15 199 3', 'stone'), ('15 205 3', 'air')]:
out.append(cmd(f'/execute if block {pos} minecraft:{block}', expect_result=1))
out += setup('/scoreboard objectives add orb_audit dummy',
'/data modify storage h303:observer uuid set from entity @s UUID')
out.append(cmd('/data get entity @s UUID', expect_result=4))
actors = [('water', '0000012f-0000-0000-0000-000000000001', '3.5 201.2 3.5', 1),
('air', '0000012f-0000-0000-0000-000000000002', '15.5 205 3.5', 2)]
for name, uuid, pos, n in actors:
out.append(cmd(f'/summon minecraft:experience_orb {pos} '
f'{{UUID:[I;303,0,0,{n}],Tags:["h303_{name}"],Health:5s,Age:0s,'
'Value:1s,Count:1,NoGravity:0b,Motion:[0d,0d,0d]}', expect_result=1))
out += setup('/execute store result score start orb_audit run time query gametime')
for at, n in [(0, 0), (1, 1), (5, 4), (20, 15), (40, 20)]:
if n:
out += [cmd(f'/tick step {n}', expect_result=1), {'op': 'tick', 'n': n}]
out += setup('/execute store result score elapsed orb_audit run time query gametime',
'/scoreboard players operation elapsed orb_audit -= start orb_audit')
out += [cmd('/scoreboard players get elapsed orb_audit', expect_result=at),
cmd('/data get entity @s playerGameType', expect_result=3),
cmd('/execute if entity @s[type=player,nbt={Health:20f,Pos:[24.5d,210d,24.5d]}]', expect_result=1)]
for name, uuid, _, _ in actors:
# Direct UUID lookup followed by type/tag/typed live fields. A saved
# UUID read is intentionally absent: H011 is a separate getter bug.
out += [cmd(f'/execute as {uuid} if entity @s[type=minecraft:experience_orb,tag=h303_{name},nbt={{Health:5s,Age:{at}s,Value:1s,Count:1}}]', expect_result=1),
cmd(f'/execute if entity @e[type=minecraft:experience_orb,tag=h303_{name}]', expect_result=1),
cmd(f'/execute as {uuid} at @s if block ~ ~ ~ minecraft:{name if name == "water" else "air"}', expect_result=1),
cmd(f'/execute as {uuid} unless entity @s[nbt={{NoGravity:1b}}]', expect_result=1)]
for field in ['Pos[1]', 'Motion[1]'] + (['Pos', 'Motion'] if raw else []):
op = cmd(f'/data get entity {uuid} {field}')
op['label'] = f'h303 {name}/{at}/{field}'
out.append(op)
out += setup('/execute store success score changed orb_audit run data modify storage h303:observer uuid set from entity @s UUID')
out += [cmd('/scoreboard players get changed orb_audit', expect_result=0),
cmd('/data get entity @s UUID', expect_result=4)]
return out
+1 -1
View File
@@ -137,7 +137,7 @@ EquipmentTable.createForAllSlots skip:data drop chances come from the loot and
ExperienceOrb.<init> port crates/horizon-client/src/game.rs::Game::add_orb random yaw and motion from the orb's own random; gap: minor: no rough direction offset and no unstuck when the orb spawns inside a block
ExperienceOrb.unstuckIfPossible port crates/horizon-client/src/entities.rs::ExperienceOrb::unstuck an orb spawned with its box inside blocks moves to the nearest free position (find_free_position over the border), run by the spawn path
ExperienceOrb.awardWithDirection port crates/horizon-client/src/game.rs::Game::spawn_orbs gap: minor: the rough direction is never given, so xp bottle orbs do not fly away from the surface they hit
ExperienceOrb.setUnderwaterMovement port crates/horizon-client/src/entities.rs::ExperienceOrb::tick gap: minor: tests the block at the orb's feet, not its eyes
ExperienceOrb.setUnderwaterMovement port crates/horizon-client/src/entities.rs::ExperienceOrb::set_underwater_movement widened float arithmetic; gap: minor: caller still tests the block at the orb's feet, not its eyes
ExperienceOrb.hurtServer port crates/horizon-client/src/entities.rs::ExperienceOrb::hurt ticked in #3514; `ExperienceOrb.hurtServer` is cited at crates/horizon-client/src/entities.rs:538
ExperienceOrb.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::orb_to_nbt gap: minor: Health is always written as 5
ExperienceOrb.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::orbs_from_nbt gap: minor: Health is ignored
1 Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
137 ExperienceOrb.<init> port crates/horizon-client/src/game.rs::Game::add_orb random yaw and motion from the orb's own random; gap: minor: no rough direction offset and no unstuck when the orb spawns inside a block
138 ExperienceOrb.unstuckIfPossible port crates/horizon-client/src/entities.rs::ExperienceOrb::unstuck an orb spawned with its box inside blocks moves to the nearest free position (find_free_position over the border), run by the spawn path
139 ExperienceOrb.awardWithDirection port crates/horizon-client/src/game.rs::Game::spawn_orbs gap: minor: the rough direction is never given, so xp bottle orbs do not fly away from the surface they hit
140 ExperienceOrb.setUnderwaterMovement port crates/horizon-client/src/entities.rs::ExperienceOrb::tick crates/horizon-client/src/entities.rs::ExperienceOrb::set_underwater_movement gap: minor: tests the block at the orb's feet, not its eyes widened float arithmetic; gap: minor: caller still tests the block at the orb's feet, not its eyes
141 ExperienceOrb.hurtServer port crates/horizon-client/src/entities.rs::ExperienceOrb::hurt ticked in #3514; `ExperienceOrb.hurtServer` is cited at crates/horizon-client/src/entities.rs:538
142 ExperienceOrb.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::orb_to_nbt gap: minor: Health is always written as 5
143 ExperienceOrb.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::orbs_from_nbt gap: minor: Health is ignored
+24 -2
View File
@@ -83,8 +83,11 @@ 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('--work-dir', type=Path, help='isolated scratch output directory')
parser.add_argument('--orb-water-fixture', action='store_true', help='guarded orb water/air scene')
parser.add_argument('--orb-water-raw-diagnostic', action='store_true', help='add whole Pos/Motion records')
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 +118,12 @@ 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.orb_water_fixture:
try:
from .orb_water_corpus import orb_water, arithmetic_inputs
except ImportError:
from orb_water_corpus import orb_water, arithmetic_inputs
groups = [('setup', corpus_module.group_setup()), ('orb underwater arithmetic', orb_water(args.orb_water_raw_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:
@@ -135,9 +144,22 @@ def main():
shutil.rmtree(vanilla_dir, ignore_errors=True)
vanilla_dir.mkdir()
install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements)
run(['java', '-Xmx3G', '-Duser.timezone=UTC', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
java_options = []
if args.orb_water_fixture:
(build / 'orb-water-reference.json').unlink(missing_ok=True)
direct_input = build / 'orb-water-inputs.json'
direct_input.write_text(json.dumps(arithmetic_inputs()), encoding='utf-8')
java_options = ['-Dhorizon.orbWaterArithmetic=' + str(direct_input)]
run(['java', '-Xmx3G', '-Duser.timezone=UTC', *java_options, '-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.orb_water_fixture:
direct = json.loads((build / 'orb-water-reference.json').read_text())
if ([r.get('input') for r in direct] != arithmetic_inputs() or
any(len(r.get('bits', [])) != 3 or
any(re.fullmatch(r'[0-9a-f]{1,16}', b) is None for b in r['bits']) for r in direct)):
sys.exit('Java direct underwater reference prerequisite failed')
if args.binary:
binary = args.binary.resolve(strict=True)
else:
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Exact real Game/Java water-orb arithmetic, with an unchanged air control."""
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('--raw-diagnostic', action='store_true')
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
folder = 'orb-water-raw-diagnostic' if args.raw_diagnostic else 'orb-water-parity'
command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'),
'--orb-water-fixture', '--work-dir', str(root / '.local' / folder)]
if args.raw_diagnostic:
command.append('--orb-water-raw-diagnostic')
if args.binary:
command += ['--binary', str(args.binary.resolve())]
if args.prepare:
command.append('--prepare')
return subprocess.run(command, cwd=root).returncode
if __name__ == '__main__':
sys.exit(main())