Compare commits

..
5 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 66e41ed573 Satisfy clippy in the hand module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:40:09 +02:00
Ludovic MarieandClaude Opus 5.5 72542a89e2 Compare sprint-jumping with vanilla in the sprint scene
The --sprint-input scene drives the real LocalPlayer.aiStep sprint and
jump path from keyboard state. Add sprint-jump runs: sprint held or
started by double-tapping forward, jump tapped, held through landings or
pressed first, at five headings, with too little food to sprint, under a
two-block ceiling and up onto a slab. All 293,175 states match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:38:48 +02:00
Ludovic MarieandClaude Opus 5.5 0734daa911 Swing the hand and raise it as vanilla does: SwingState, equip heights, attack clicks
The local player's clicks now go through a port of Minecraft.startAttack,
continueAttack and handleKeybinds' attack part with MultiPlayerGameMode's
block breaking (first_person.rs): attacking the air swings (and sets the
miss time), creative breaking swings every tick through its destroy delay,
survival mining keeps swinging after a block breaks, swords cannot break
blocks in creative, and attacking an entity in creative sets the delay.
The swing is LivingEntity.SwingState (restart rules, durations under haste
and mining fatigue, forward wrap on restart); the equip height follows
FirstPersonHandsAndItems with the item swap ticker, and placing or
starting to use an item drops the hand (itemUsed). Mining fatigue scales
by Math.pow(0.3, amplifier + 1) as in 26.3.

test_first_person_parity drives the real startAttack, continueAttack,
startUseItem, MultiPlayerGameMode breaking, SwingState, the tickers and
FirstPersonHandsAndItems over 4,192 scripted click sequences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:29 +02:00
Ludovic MarieandClaude Opus 5.5 f2c7a2d359 Draw the first-person hand with its own projection at hudFov
GameRenderer.render3dHud sets up the hand's projection at Camera's hudFov
(70, changed only by death and fluids), never the FOV option or the
sprint, speed and bow modifier. The hand pass now has its own globals
with that projection (a port of JOML setPerspective, as Projection does).
The camera harness compares the world and hand projection matrices at FOV
options 30, 70, 110 and others with vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:07:39 +02:00
Ludovic MarieandClaude Opus 5.5 27a32cc30d README: parity is what the harnesses check, until the final audit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:17:34 +02:00
24 changed files with 2233 additions and 88 deletions

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@
# Horizon
A Rust rewrite of Minecraft: Java Edition 26.3 with one-to-one vanilla parity, checked against the game itself with differential tests.
A Rust rewrite of Minecraft: Java Edition 26.3 built for one-to-one vanilla parity, checked against the game itself with differential tests.
Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS: 100% of what the harnesses compare (see [Project status](#project-status)). The native client is playable on Linux, macOS (Apple Silicon) and Windows.
+10 -1
View File
@@ -5,7 +5,7 @@ use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier, perspective,
},
};
use horizon_core::{
@@ -258,6 +258,15 @@ fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
fb(fov),
fb(hud)
)?;
let (w, h, far) = (fl(&op[4]), fl(&op[5]), fl(&op[6]));
let matrix =
|m: [f32; 16]| m.iter().map(|&x| format!(" {}", fb(x))).collect::<String>();
writeln!(
out,
"projection{}{}",
matrix(perspective(fov, w, h, far, 0.05, true)),
matrix(perspective(hud, w, h, far, 0.05, true))
)?;
}
other => panic!("{other}"),
}
@@ -0,0 +1,461 @@
//! Probe for test_first_person_parity.py: drives horizon_client::first_person
//! and attack_strength over the harness's scripted clicks, targets and use
//! results, printing what VanillaFirstPersonOracle prints.
use horizon_client::{
Result,
attack_strength::{AttackStrength, HeldStack},
first_person::{
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
swing_duration,
},
};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fb(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn dl(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
/// An `ItemStack` object: the hands hold indices into `Probe::stacks`, so
/// shrinking keeps its identity as vanilla's in-place `shrink` does.
#[derive(Clone)]
struct Stack {
item: String,
count: i32,
}
impl Stack {
fn empty(&self) -> bool {
self.item == "minecraft:air" || self.count <= 0
}
/// `getItem` and `getCount` of an empty stack are air and 0.
fn effective(&self) -> (&str, i32) {
if self.empty() {
("minecraft:air", 0)
} else {
(&self.item, self.count)
}
}
}
struct Probe<'a> {
case: &'a Value,
step: &'a Value,
creative: bool,
stacks: Vec<Stack>,
/// The stacks in the main and off hand.
hands: [usize; 2],
blocks: HashMap<[i32; 3], String>,
swing: SwingState,
events: Vec<String>,
heights: HandHeights,
}
fn hand_name(hand: Hand) -> &'static str {
match hand {
Hand::Main => "main",
Hand::Off => "off",
}
}
impl Probe<'_> {
fn stack(&self, hand: Hand) -> &Stack {
&self.stacks[self.hands[hand as usize]]
}
fn new_stack(&mut self, v: &Value) -> usize {
// ItemStack.EMPTY is index 0.
if v.is_null() {
return 0;
}
self.stacks.push(Stack {
item: v[0].as_str().unwrap().into(),
count: v[1].as_i64().unwrap() as i32,
});
self.stacks.len() - 1
}
fn table<'b>(&'b self, name: &str) -> &'b Value {
&self.case["rust"][name]
}
fn attack_speed(&self) -> f64 {
let item = self.stack(Hand::Main).effective().0;
self.table("attack_speed").get(item).map_or(4.0, dl)
}
fn scripted(&mut self, kind: &str, hand: Hand) -> UseResult {
let name = self.step["uses"][kind].as_str().unwrap().to_string();
self.events
.push(format!("use {kind} {} {name}", hand_name(hand)));
let result = match name.as_str() {
"success" => UseResult::Success { swing: true },
"server" | "consume" => UseResult::Success { swing: false },
"fail" => UseResult::Fail,
_ => UseResult::Pass,
};
if matches!(result, UseResult::Success { .. }) && self.step["shrink"].as_bool().unwrap() {
let i = self.hands[hand as usize];
if i != 0 {
self.stacks[i].count -= 1;
}
}
result
}
}
impl HandWorld for Probe<'_> {
fn hit(&mut self) -> Hit {
let h = &self.step["hit"];
match h[0].as_str().unwrap() {
"block" => Hit::Block {
pos: [0, 1, 2].map(|i| h[i + 1].as_i64().unwrap() as i32),
face: h[4].as_u64().unwrap() as u8,
},
"entity" => Hit::Entity,
_ => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
self.blocks.get(&pos).is_none_or(|b| b == "minecraft:air")
}
fn creative(&self) -> bool {
self.creative
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.creative
}
fn hands_busy(&self) -> bool {
self.step["busy"].as_bool().unwrap()
}
fn using_item(&self) -> bool {
self.step["using"].as_bool().unwrap()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
self.stack(hand).empty()
}
fn held_count(&mut self, hand: Hand) -> i32 {
self.stack(hand).effective().1
}
fn infinite_materials(&self) -> bool {
self.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {
unreachable!()
}
fn attack_entity(&mut self) -> bool {
self.events.push("attack".into());
true
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
false
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed` for
/// a player on the ground, out of water, with no mining efficiency.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block = self.blocks.get(&pos).cloned().unwrap_or_default();
let b = &self.table("blocks")[block.as_str()];
let hardness = fl(&b[0]);
if hardness == -1.0 {
return 0.0;
}
let item = self.stack(Hand::Main).effective().0.to_string();
let tool = &self.table("tools")[format!("{item} {block}").as_str()];
let (mut speed, correct) = if tool.is_null() {
(1.0f32, false)
} else {
(fl(&tool[0]), tool[1].as_bool().unwrap())
};
if let Some(amp) = self.case["haste"].as_i64() {
speed *= 1.0 + (amp as i32 + 1) as f32 * 0.2;
}
if let Some(amp) = self.case["fatigue"].as_i64() {
speed *= horizon_core::jmath::pow(0.3, f64::from(amp as i32 + 1)) as f32;
}
let harvest = !b[1].as_bool().unwrap() || correct;
speed / hardness / if harvest { 30.0 } else { 100.0 }
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
let item = self.stack(Hand::Main).effective().0;
// Item.canDestroyBlock: swords' tools cannot destroy in creative.
let sword = item.ends_with("_sword");
let ok = !(sword && self.creative) && !self.is_air(pos);
if ok {
self.blocks.insert(pos, "minecraft:air".into());
}
self.events
.push(format!("destroy {} {} {} {ok}", pos[0], pos[1], pos[2]));
ok
}
/// The stack object itself: `destroyingItem` is a reference.
fn main_hand_key(&mut self) -> String {
self.hands[0].to_string()
}
fn same_destroying_item(&mut self, key: &str) -> bool {
// ItemStack.EMPTY (index 0) before any break.
let then = &self.stacks[key.parse::<usize>().unwrap_or(0)];
then.effective().0 == self.stack(Hand::Main).effective().0
}
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>) {
self.events.push(format!(
"stage {} {} {} {}",
pos[0],
pos[1],
pos[2],
stage.unwrap_or(-1)
));
}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
let duration = swing_duration(
animation,
self.case["haste"].as_i64().map(|a| a as i32),
self.case["fatigue"].as_i64().map(|a| a as i32),
);
let started = self.swing.start_if_able(hand, animation, duration);
self.events.push(format!(
"swing {} {} {started}",
hand_name(hand),
animation.duration
));
}
fn reset_attack_strength(&mut self) {
unreachable!("ResetWorld resets the tickers")
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult> {
self.step["hit"][1]
.as_bool()
.unwrap()
.then(|| self.scripted("entity", hand))
}
fn use_on_block(&mut self, hand: Hand) -> UseResult {
self.scripted("block", hand)
}
fn use_item(&mut self, hand: Hand) -> UseResult {
self.scripted("item", hand)
}
fn item_used(&mut self, hand: Hand) {
self.events.push(format!("used {}", hand_name(hand)));
self.heights.item_used(hand);
}
fn release_using(&mut self) {
self.events.push("release".into());
}
}
fn held(stack: &Stack) -> Option<HeldStack> {
(!stack.empty()).then(|| HeldStack {
item: stack.item.clone(),
rest: stack.count.to_string(),
})
}
fn run(case: &Value, out: &mut impl Write) -> Result<()> {
let empty = Value::Null;
let mut probe = Probe {
case,
step: &empty,
creative: case["creative"].as_bool().unwrap(),
stacks: vec![Stack {
item: "minecraft:air".into(),
count: 0,
}],
hands: [0, 0],
blocks: case["blocks"]
.as_array()
.unwrap()
.iter()
.map(|b| {
(
[0, 1, 2].map(|i| b[i].as_i64().unwrap() as i32),
b[3].as_str().unwrap().to_string(),
)
})
.collect(),
swing: SwingState::default(),
events: Vec::new(),
heights: HandHeights::default(),
};
probe.hands[0] = probe.new_stack(&case["main"]);
probe.hands[1] = probe.new_stack(&case["off"]);
writeln!(out, "attack_speed {:x}", probe.attack_speed().to_bits())?;
let mut clicks = Clicks::default();
let mut strength = AttackStrength::default();
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
let mut visible = [0usize, 0usize];
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
probe.step = step;
probe.events.clear();
if !step["set"].is_null() {
let i = probe.new_stack(&step["set"]["stack"]);
let hand = if step["set"]["hand"] == "main" { 0 } else { 1 };
probe.hands[hand] = i;
}
clicks.tick(
&mut ResetWorld {
probe: &mut probe,
strength: &mut strength,
},
step["ac"].as_u64().unwrap() as u32,
step["uc"].as_u64().unwrap() as u32,
step["ad"].as_bool().unwrap(),
step["ud"].as_bool().unwrap(),
);
probe.swing.tick();
let main = probe.stack(Hand::Main).clone();
strength.tick(held(&main));
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
// (ItemStack.matchesIgnoringComponents; every item plays the swap
// animation), then the heights.
let mut changed = [false; 2];
for h in 0..2 {
let (a, b) = (visible[h], probe.hands[h]);
let same = a == b || probe.stacks[a].effective() == probe.stacks[b].effective();
if same {
visible[h] = b;
}
changed[h] = visible[h] != b;
}
let speed = probe.attack_speed();
let replace = probe.heights.tick(
probe.step["busy"].as_bool().unwrap(),
strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
visible[h] = probe.hands[h];
}
}
for e in &probe.events {
writeln!(out, "{e}")?;
}
let sw = &probe.swing;
let current = sw.current.map_or("- 0".to_string(), |c| {
format!("{} {}", hand_name(c.hand), c.duration_ticks)
});
let h = &probe.heights;
let d = &clicks.destroying;
writeln!(
out,
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
sw.ticks,
fb(sw.animation),
fb(sw.old_animation),
fb(sw.animation(0.5)),
strength.ticker,
strength.swap_ticker,
fb(strength.swap_scale(1.0, speed)),
fb(h.main),
fb(h.old_main),
fb(h.off),
fb(h.old_off),
clicks.miss_time,
clicks.right_click_delay,
d.delay,
d.active,
fb(d.progress)
)?;
}
Ok(())
}
/// The probe with the attack-strength tickers it resets.
struct ResetWorld<'p, 'c> {
probe: &'p mut Probe<'c>,
strength: &'p mut AttackStrength,
}
macro_rules! forward {
($($name:ident($($arg:ident: $t:ty),*) -> $r:ty;)*) => {
$(fn $name(&mut self, $($arg: $t),*) -> $r { self.probe.$name($($arg),*) })*
};
}
impl HandWorld for ResetWorld<'_, '_> {
forward! {
hit() -> Hit;
is_air(pos: [i32; 3]) -> bool;
item_enabled(hand: Hand) -> bool;
held_empty(hand: Hand) -> bool;
held_count(hand: Hand) -> i32;
cannot_attack() -> bool;
piercing_weapon() -> bool;
attack_animation() -> SwingAnimation;
interact_animation(hand: Hand) -> SwingAnimation;
piercing_attack(a: SwingAnimation) -> ();
attack_entity() -> bool;
block_action_restricted(pos: [i32; 3]) -> bool;
within_border(pos: [i32; 3]) -> bool;
attack_block(pos: [i32; 3]) -> ();
destroy_progress(pos: [i32; 3]) -> f32;
destroy_block(pos: [i32; 3]) -> bool;
main_hand_key() -> String;
same_destroying_item(key: &str) -> bool;
destroy_stage(pos: [i32; 3], stage: Option<i32>) -> ();
swing(hand: Hand, a: SwingAnimation) -> ();
entity_within_border() -> bool;
use_on_entity(hand: Hand) -> Option<UseResult>;
use_on_block(hand: Hand) -> UseResult;
use_item(hand: Hand) -> UseResult;
item_used(hand: Hand) -> ();
release_using() -> ();
}
fn creative(&self) -> bool {
self.probe.creative()
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.probe.instabuild()
}
fn hands_busy(&self) -> bool {
self.probe.hands_busy()
}
fn using_item(&self) -> bool {
self.probe.using_item()
}
fn infinite_materials(&self) -> bool {
self.probe.infinite_materials()
}
fn reset_attack_strength(&mut self) {
self.strength.reset();
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
run(&c, &mut out)?;
}
out.flush()?;
Ok(())
}
+38
View File
@@ -583,6 +583,44 @@ impl Camera {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Projection.getMatrix` for a perspective set up by `setupPerspective`:
/// JOML's `Matrix4f.setPerspective(fov * DEG_TO_RAD, width / height, near,
/// far, zZeroToOne)`, column-major. Vanilla passes its planes reversed
/// (`near` is `zFar`); the hand pass here keeps them in order.
pub fn perspective(
fov_degrees: f32,
width: f32,
height: f32,
near: f32,
far: f32,
zero_to_one: bool,
) -> [f32; 16] {
let h = horizon_core::jmath::tan(f64::from(fov_degrees * DEG_TO_RAD * 0.5)) as f32;
let aspect = width / height;
let mut m = [0.0f32; 16];
m[0] = 1.0 / (h * aspect);
m[5] = 1.0 / h;
let e = 1.0e-6f32;
if far > 0.0 && far.is_infinite() {
m[10] = e - 1.0;
m[14] = (e - if zero_to_one { 1.0 } else { 2.0 }) * near;
} else if near > 0.0 && near.is_infinite() {
m[10] = (if zero_to_one { 0.0 } else { 1.0 }) - e;
m[14] = ((if zero_to_one { 1.0 } else { 2.0 }) - e) * far;
} else {
m[10] = (if zero_to_one { far } else { far + near }) / (near - far);
m[14] = (if zero_to_one { far } else { far + far }) * near / (near - far);
}
m[11] = -1.0;
m
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
/// modifier.
pub fn hand_projection(hud_fov: f32, width: f32, height: f32, far: f32) -> [f32; 16] {
perspective(hud_fov, width, height, 0.05, far, true)
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
+1 -6
View File
@@ -365,12 +365,7 @@ impl PlayerEffects {
f *= 1.0 + (a + 1) as f32 * 0.2;
}
if let Some(a) = self.amplifier("mining_fatigue") {
f *= match a {
0 => 0.3,
1 => 0.09,
2 => 0.0027,
_ => 8.1e-4,
};
f *= horizon_core::jmath::pow(0.3, f64::from(a + 1)) as f32;
}
f
}
+601
View File
@@ -0,0 +1,601 @@
//! The local player's hand: the arm swing (`LivingEntity.SwingState`,
//! `getModifiedSwingDuration`), the first-person equip heights
//! (`FirstPersonHandsAndItems.tick` and `itemUsed`), and the click handling
//! that starts swings: `Minecraft.startAttack`, `continueAttack`,
//! `startUseItem` and `handleKeybinds`' attack and use part, with
//! `MultiPlayerGameMode`'s block breaking (`startDestroyBlock`,
//! `continueDestroyBlock`, `stopDestroyBlock`).
//!
//! What those read and do in the world is behind [`HandWorld`].
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
Main,
Off,
}
impl Hand {
pub const BOTH: [Hand; 2] = [Hand::Main, Hand::Off];
}
/// `SwingAnimation`: its type (`SwingAnimationType` ordinal) and duration.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingAnimation {
pub kind: u8,
pub duration: i32,
}
impl SwingAnimation {
/// `SwingAnimation.DEFAULT`: WHACK for 6 ticks.
pub const DEFAULT: SwingAnimation = SwingAnimation {
kind: 1,
duration: 6,
};
}
/// `LivingEntity.SwingDescription`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingDescription {
pub hand: Hand,
pub animation: SwingAnimation,
pub duration_ticks: i32,
}
/// `LivingEntity.SwingState`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SwingState {
pub current: Option<SwingDescription>,
pub ticks: i32,
pub old_animation: f32,
pub animation: f32,
}
impl SwingState {
/// `startIfAble`: a swing restarts unless the current one is in its
/// first half (past its first tick).
pub fn start_if_able(&mut self, hand: Hand, animation: SwingAnimation, duration: i32) -> bool {
if let Some(c) = self.current
&& self.ticks <= c.duration_ticks / 2
&& self.ticks > 0
{
return false;
}
let swing = SwingDescription {
hand,
animation,
duration_ticks: duration,
};
if self.current != Some(swing) {
self.old_animation = 0.0;
self.animation = 0.0;
}
self.current = Some(swing);
self.ticks = 0;
true
}
/// `tick` (from `LivingEntity.baseTick`).
pub fn tick(&mut self) {
self.old_animation = self.animation;
if let Some(c) = self.current {
if c.duration_ticks > 0 {
self.animation = (self.ticks as f32 / c.duration_ticks as f32).min(1.0);
}
let ticks = self.ticks;
self.ticks += 1;
if ticks > c.duration_ticks {
self.current = None;
self.animation = 0.0;
}
} else {
self.animation = 0.0;
}
}
/// `getAnimation(partialTicks)`: a restarted swing keeps moving forward.
pub fn animation(&self, partial: f32) -> f32 {
if self.animation >= self.old_animation {
crate::camera::lerp(partial, self.old_animation, self.animation)
} else {
crate::camera::lerp(partial, self.old_animation, self.animation + 1.0)
}
}
pub fn swinging(&self) -> bool {
self.current.is_some()
}
/// The hand of the current swing (`state.attackHand`), main by default.
pub fn attack_hand(&self) -> Hand {
self.current.map_or(Hand::Main, |c| c.hand)
}
}
/// `LivingEntity.getModifiedSwingDuration`: `dig_speed` is
/// `MobEffectUtil.getDigSpeedAmplification` while haste or conduit power is
/// active, `fatigue` the mining fatigue amplifier.
pub fn swing_duration(
animation: SwingAnimation,
dig_speed: Option<i32>,
fatigue: Option<i32>,
) -> i32 {
let duration = animation.duration;
if let Some(amplification) = dig_speed {
duration - (1 + amplification)
} else if let Some(amplifier) = fatigue {
duration + (1 + amplifier) * 2
} else {
duration
}
}
/// `FirstPersonHandsAndItems`' equip heights.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HandHeights {
pub main: f32,
pub old_main: f32,
pub off: f32,
pub old_off: f32,
}
impl Default for HandHeights {
fn default() -> Self {
HandHeights {
main: 0.0,
old_main: 0.0,
off: 0.0,
old_off: 0.0,
}
}
}
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandChange {
/// The visible item is not the hand's item after the instant replacement
/// (`mainHandItem != nextMainHand`): the hand lowers.
pub changed: bool,
/// The visible item becomes the hand's item this tick.
pub replace: bool,
}
impl HandHeights {
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
/// whether the visible item differs from the held one once
/// `shouldInstantlyReplaceVisibleItem` has run; `swap_scale` is
/// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item
/// is replaced at the end.
pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] {
self.old_main = self.main;
self.old_off = self.off;
if hands_busy {
self.main = (self.main - 0.4).clamp(0.0, 1.0);
self.off = (self.off - 0.4).clamp(0.0, 1.0);
} else {
let attack = swap_scale;
let main_target = if changed[0] {
0.0
} else {
attack * attack * attack
};
let off_target = if changed[1] { 0.0 } else { 1.0 };
self.main += (main_target - self.main).clamp(-0.4, 0.4);
self.off += (off_target - self.off).clamp(-0.4, 0.4);
}
[self.main < 0.1, self.off < 0.1]
}
/// `itemUsed`: the hand drops to the bottom and rises again.
pub fn item_used(&mut self, hand: Hand) {
match hand {
Hand::Main => self.main = 0.0,
Hand::Off => self.off = 0.0,
}
}
/// `Mth.lerp(partialTicks, oMainHandHeight, mainHandHeight)` and the
/// off hand's.
pub fn interpolated(&self, partial: f32) -> [f32; 2] {
[
crate::camera::lerp(partial, self.old_main, self.main),
crate::camera::lerp(partial, self.old_off, self.off),
]
}
}
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Miss,
Block { pos: [i32; 3], face: u8 },
Entity,
}
/// `InteractionResult` as `startUseItem` reads it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseResult {
/// `Success` with its `SwingSource`: whether the client swings.
Success {
swing: bool,
},
Pass,
Fail,
}
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
fn is_air(&mut self, pos: [i32; 3]) -> bool;
/// `MultiPlayerGameMode.localPlayerMode`.
fn creative(&self) -> bool;
fn spectator(&self) -> bool;
/// `Abilities.instabuild`.
fn instabuild(&self) -> bool;
fn hands_busy(&self) -> bool;
fn using_item(&self) -> bool;
/// `ItemStack.isItemEnabled` for the item in a hand.
fn item_enabled(&mut self, hand: Hand) -> bool;
fn held_empty(&mut self, hand: Hand) -> bool;
fn held_count(&mut self, hand: Hand) -> i32;
/// `Player.hasInfiniteMaterials`.
fn infinite_materials(&self) -> bool;
/// `Player.cannotAttackWithItem(heldItem, 0)`.
fn cannot_attack(&mut self) -> bool;
/// The main-hand item is a `PIERCING_WEAPON`.
fn piercing_weapon(&mut self) -> bool;
fn attack_animation(&mut self) -> SwingAnimation;
fn interact_animation(&mut self, hand: Hand) -> SwingAnimation;
/// `MultiPlayerGameMode.piercingAttack`.
fn piercing_attack(&mut self, animation: SwingAnimation);
/// `Player.attack` on the targeted entity when the item's attack range
/// allows it; false when out of range.
fn attack_entity(&mut self) -> bool;
/// `Player.blockActionRestricted` for the game mode.
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool;
/// `WorldBorder.isWithinBounds`.
fn within_border(&mut self, pos: [i32; 3]) -> bool;
/// `BlockState.attack` on the first hit of a break.
fn attack_block(&mut self, pos: [i32; 3]);
/// `BlockState.getDestroyProgress` for the player.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32;
/// `MultiPlayerGameMode.destroyBlock`.
fn destroy_block(&mut self, pos: [i32; 3]) -> bool;
/// A key for the main-hand stack, kept as `destroyingItem`.
fn main_hand_key(&mut self) -> String;
/// `ItemStack.isSameItemSameComponents(selected, destroyingItem)` for
/// the stack `main_hand_key` gave when the break started. Vanilla keeps
/// a reference to that stack, so it compares what it holds now.
fn same_destroying_item(&mut self, key: &str) -> bool {
self.main_hand_key() == key
}
/// Breaking progress shown (`destroyBlockProgress`), or cleared with `None`.
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>);
/// `player.swing(hand, animation, false)`.
fn swing(&mut self, hand: Hand, animation: SwingAnimation);
/// `Player.resetAttackStrengthTicker`.
fn reset_attack_strength(&mut self);
/// The targeted entity's block position is inside the world border.
fn entity_within_border(&mut self) -> bool;
/// The use attempts of `startUseItem`: on the targeted entity (`None`
/// when it is out of interaction range), the targeted block, and the
/// item alone.
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult>;
fn use_on_block(&mut self, hand: Hand) -> UseResult;
fn use_item(&mut self, hand: Hand) -> UseResult;
/// `LocalPlayer.itemUsed`.
fn item_used(&mut self, hand: Hand);
/// `MultiPlayerGameMode.releaseUsingItem`.
fn release_using(&mut self);
}
/// `MultiPlayerGameMode`'s block breaking.
#[derive(Clone, Debug, PartialEq)]
pub struct Destroying {
pub delay: i32,
pub active: bool,
pub pos: [i32; 3],
pub face: u8,
pub item: String,
pub progress: f32,
pub ticks: f32,
}
impl Default for Destroying {
/// `destroyBlockPos` starts at (-1, -1, -1) and `destroyingItem` empty
/// (an empty key).
fn default() -> Self {
Destroying {
delay: 0,
active: false,
pos: [-1; 3],
face: 0,
item: String::new(),
progress: 0.0,
ticks: 0.0,
}
}
}
impl Destroying {
/// `getDestroyStage`.
pub fn stage(&self) -> i32 {
if self.progress > 0.0 {
(self.progress * 10.0) as i32
} else {
-1
}
}
fn same_target(&self, w: &mut impl HandWorld, pos: [i32; 3]) -> bool {
pos == self.pos && w.same_destroying_item(&self.item)
}
/// `startDestroyBlock`.
pub fn start(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if w.block_action_restricted(pos) || !w.within_border(pos) {
return false;
}
if w.instabuild() {
w.destroy_block(pos);
self.delay = 5;
} else if !self.active || !self.same_target(w, pos) {
let not_air = !w.is_air(pos);
if not_air && self.progress == 0.0 {
w.attack_block(pos);
}
if not_air && w.destroy_progress(pos) >= 1.0 {
w.destroy_block(pos);
} else {
self.active = true;
self.pos = pos;
self.face = face;
self.item = w.main_hand_key();
self.progress = 0.0;
self.ticks = 0.0;
w.destroy_stage(pos, Some(self.stage()));
}
}
true
}
/// `stopDestroyBlock`.
pub fn stop(&mut self, w: &mut impl HandWorld) {
if self.active {
self.active = false;
self.progress = 0.0;
w.destroy_stage(self.pos, None);
w.reset_attack_strength();
}
}
/// `continueDestroyBlock`: true while the hand keeps swinging.
pub fn continue_destroy(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if self.delay > 0 {
self.delay -= 1;
return true;
}
if w.instabuild() && w.within_border(pos) {
self.delay = 5;
w.destroy_block(pos);
return true;
}
if !self.same_target(w, pos) {
return self.start(w, pos, face);
}
if w.is_air(pos) {
self.active = false;
return false;
}
self.progress += w.destroy_progress(pos);
self.ticks += 1.0;
if self.progress >= 1.0 {
self.active = false;
w.destroy_block(pos);
self.progress = 0.0;
self.ticks = 0.0;
self.delay = 5;
} else if self.face != face {
self.face = face;
}
w.destroy_stage(self.pos, Some(self.stage()));
true
}
}
/// `Minecraft`'s attack and use handling.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Clicks {
pub miss_time: i32,
pub right_click_delay: i32,
pub destroying: Destroying,
}
impl Clicks {
/// `Minecraft.startAttack`: whether the click ended the attack (broke
/// the block outright).
pub fn start_attack(&mut self, w: &mut impl HandWorld) -> bool {
if self.miss_time > 0 || w.hands_busy() {
return false;
}
if w.spectator() {
return true;
}
if !w.item_enabled(Hand::Main) || w.cannot_attack() {
return false;
}
let animation = w.attack_animation();
if w.piercing_weapon() {
w.piercing_attack(animation);
return true;
}
let mut end = false;
let mut miss = false;
match w.hit() {
// MultiPlayerGameMode.attack.
Hit::Entity => {
if w.attack_entity() {
w.reset_attack_strength();
if w.instabuild() {
self.destroying.delay = 5;
}
}
}
Hit::Block { pos, face } if !w.is_air(pos) => {
self.destroying.start(w, pos, face);
end = w.is_air(pos);
}
Hit::Block { .. } | Hit::Miss => miss = true,
}
if miss {
if !w.creative() {
self.miss_time = 10;
}
w.reset_attack_strength();
}
w.swing(Hand::Main, animation);
end
}
/// `Minecraft.continueAttack`.
pub fn continue_attack(&mut self, w: &mut impl HandWorld, down: bool) {
if !down {
self.miss_time = 0;
}
if self.miss_time > 0 || w.using_item() || w.piercing_weapon() {
return;
}
let animation = w.attack_animation();
match w.hit() {
Hit::Block { pos, face } if down => {
if !w.is_air(pos) && self.destroying.continue_destroy(w, pos, face) {
w.swing(Hand::Main, animation);
}
}
_ => self.destroying.stop(w),
}
}
/// `Minecraft.startUseItem`.
pub fn start_use_item(&mut self, w: &mut impl HandWorld) {
if self.destroying.active {
return;
}
self.right_click_delay = 4;
if w.hands_busy() {
return;
}
for hand in Hand::BOTH {
if !w.item_enabled(hand) {
return;
}
let animation = w.interact_animation(hand);
match w.hit() {
Hit::Entity => {
if !w.entity_within_border() {
return;
}
if let Some(UseResult::Success { swing }) = w.use_on_entity(hand) {
if swing {
w.swing(hand, animation);
}
return;
}
}
Hit::Block { .. } => {
let count = w.held_count(hand);
match w.use_on_block(hand) {
UseResult::Success { swing } => {
if swing {
w.swing(hand, animation);
if !w.held_empty(hand)
&& (w.held_count(hand) != count || w.infinite_materials())
{
w.item_used(hand);
}
}
return;
}
UseResult::Fail => return,
UseResult::Pass => {}
}
}
Hit::Miss => {}
}
if !w.held_empty(hand)
&& let UseResult::Success { swing } = w.use_item(hand)
{
if swing {
w.swing(hand, animation);
}
w.item_used(hand);
return;
}
}
}
/// The attack and use part of `Minecraft.tick`: `rightClickDelay`, the
/// clicks `handleKeybinds` consumes, held use and attack, then
/// `missTime`. `attack_down` is `keyAttack.isDown()` with no screen and
/// the mouse grabbed.
pub fn tick(
&mut self,
w: &mut impl HandWorld,
attack_clicks: u32,
use_clicks: u32,
attack_down: bool,
use_down: bool,
) {
if self.right_click_delay > 0 {
self.right_click_delay -= 1;
}
let mut instant = false;
if w.using_item() {
if !use_down {
w.release_using();
}
} else {
for _ in 0..attack_clicks {
instant |= self.start_attack(w);
}
for _ in 0..use_clicks {
self.start_use_item(w);
}
}
if use_down && self.right_click_delay == 0 && !w.using_item() {
self.start_use_item(w);
}
self.continue_attack(w, !instant && attack_down);
if self.miss_time > 0 {
self.miss_time -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_swing_lasts_its_duration_and_restarts_after_half() {
let mut s = SwingState::default();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
let mut seen = Vec::new();
for _ in 0..9 {
s.tick();
seen.push(s.animation);
}
assert_eq!(
seen,
[
0.0,
1.0 / 6.0,
2.0 / 6.0,
0.5,
4.0 / 6.0,
5.0 / 6.0,
1.0,
0.0,
0.0
]
);
assert!(!s.swinging());
s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6);
s.tick();
s.tick();
assert!(!s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
s.tick();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
// Restarting the same swing wraps forward rather than snapping back.
assert_eq!(
s.animation(0.5),
(s.old_animation + s.animation + 1.0) / 2.0
);
}
#[test]
fn haste_shortens_and_fatigue_lengthens_swings() {
let d = SwingAnimation::DEFAULT;
assert_eq!(swing_duration(d, None, None), 6);
assert_eq!(swing_duration(d, Some(1), Some(0)), 4);
assert_eq!(swing_duration(d, None, Some(2)), 12);
}
}
+36 -57
View File
@@ -39,6 +39,7 @@ mod game_mode;
mod ghasts;
mod golems;
mod growth;
mod hand;
mod item_draw;
mod jukebox;
mod lan;
@@ -685,12 +686,18 @@ struct Game<'a> {
orbs: Vec<ExperienceOrb>,
xp_delay: u32,
last_level_up: u64,
/// Arm swing: ticks into the 6-tick swing (None when idle), previous/current progress.
swing: Option<u32>,
swing_anim: (f32, f32),
/// Main-hand equip height (previous, current) and the item it shows.
hand_height: (f32, f32),
hand_item: Option<ItemStack>,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
visible_hands: [(usize, Option<ItemStack>); 2],
/// `Minecraft`'s attack handling, attack clicks since the last tick and
/// the attack strength tickers the hand reads.
clicks: horizon_client::first_person::Clicks,
attack_clicks: u32,
hand_strength: horizon_client::attack_strength::AttackStrength,
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
@@ -2789,7 +2796,7 @@ impl Game<'_> {
// ticks; ServerLevel.tick then runs the scheduled and random block
// ticks before any entity, so what they drop or spawn (dispensed
// items, popped crops) moves on the tick it appears, as in vanilla.
self.tick_mining()?;
self.tick_local_hand()?;
self.postprocess_chunks()?;
if running {
self.tick_blocks()?;
@@ -2841,7 +2848,7 @@ impl Game<'_> {
self.tick_particles();
}
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
self.use_item()?;
self.use_with_hand()?;
self.use_delay = 4;
}
Ok(())
@@ -6374,38 +6381,6 @@ impl Game<'_> {
}
self.damage_held(1);
}
/// LivingEntity.swing: restart the 6-tick arm swing.
fn swing_arm(&mut self) {
if self.swing.is_none_or(|t| t >= 3) {
self.swing = Some(0);
}
}
/// LivingEntity.updateSwingTime and ItemInHandRenderer.tick (equip height).
fn tick_hand(&mut self) {
self.swing_anim.0 = self.swing_anim.1;
if let Some(t) = self.swing {
let t = t + 1;
if t >= 6 {
self.swing = None;
self.swing_anim.1 = 0.0;
} else {
self.swing = Some(t);
self.swing_anim.1 = t as f32 / 6.0;
}
}
self.hand_height.0 = self.hand_height.1;
let held = self.inventory[self.selected].clone();
let same = match (&held, &self.hand_item) {
(Some(a), Some(b)) => a.item == b.item,
(None, None) => true,
_ => false,
};
let target = if same { 1.0 } else { 0.0 };
self.hand_height.1 += (target - self.hand_height.1).clamp(-0.4, 0.4);
if self.hand_height.1 < 0.1 {
self.hand_item = held;
}
}
/// FirstPersonHandsAndItemsRenderer: the right arm or held item in view
/// space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
@@ -6423,14 +6398,15 @@ impl Game<'_> {
return out;
}
let a = alpha as f32;
let attack = self.swing_anim.0 + (self.swing_anim.1 - self.swing_anim.0) * a;
let attack = if self.swing.is_none() && self.swing_anim.1 == 0.0 {
0.0
// FirstPersonHandsAndItemsRenderer: the swing of the hand that
// swings, and its inverse arm height (the swap scale is 1 for every
// item but spears).
let attack = if self.swing.attack_hand() == horizon_client::first_person::Hand::Main {
self.swing.animation(a)
} else {
attack
0.0
};
let height = self.hand_height.0 + (self.hand_height.1 - self.hand_height.0) * a;
let inverse_height = 1.0 - height;
let inverse_height = 1.0 - self.heights.interpolated(a)[0];
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = glam::Vec3::new(
-yaw.sin() * pitch.cos(),
@@ -6451,7 +6427,8 @@ impl Game<'_> {
if self.held_map_geometry(to_world, attack, inverse_height, packed, &mut out) {
return out;
}
match self.hand_item.clone() {
let [main_item, _] = self.visible_hand_items();
match main_item {
None => {
let Some(arm) = self.res.player_arm.clone() else {
return out;
@@ -7319,6 +7296,7 @@ impl Game<'_> {
additive: &additive,
celestial: &celestial,
hand: &hand,
hand_fov_degrees: self.hud_fov_degrees(alpha as f32, camera.to_array()),
screen: &screen,
border,
game_time: self.world.game_time as f32,
@@ -7491,22 +7469,20 @@ impl Game<'_> {
}
match button {
MouseButton::Left => {
if pressed && self.attack() {
return Ok(());
// KeyMapping.click: Minecraft.tick starts the attack.
if pressed {
self.attack_clicks += 1;
}
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
if pressed {
self.destroy_delay = 0;
}
}
MouseButton::Right => {
self.toggles
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
if pressed {
self.use_item()?;
self.use_with_hand()?;
self.use_delay = 4;
}
}
@@ -8357,10 +8333,13 @@ pub fn run() -> Result<()> {
orbs: Vec::new(),
xp_delay: 0,
last_level_up: 0,
swing: None,
swing_anim: (0.0, 0.0),
hand_height: (1.0, 1.0),
hand_item: None,
swing: Default::default(),
swings_started: 0,
heights: Default::default(),
visible_hands: [(0, None), (0, None)],
clicks: Default::default(),
attack_clicks: 0,
hand_strength: Default::default(),
settings,
options_from_title: false,
dragging: false,
@@ -183,7 +183,7 @@ impl Game<'_> {
true
}
/// The armor stand under the crosshair, its distance and the hit point.
fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+1 -1
View File
@@ -597,7 +597,7 @@ impl Game<'_> {
self.swing_arm();
true
}
fn boat_hit(&self) -> Option<(usize, f64)> {
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+9 -5
View File
@@ -722,10 +722,9 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
pub(super) fn attack_dragon(&mut self) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(u32, f64)> = None;
@@ -750,7 +749,12 @@ impl Game<'_> {
best = Some((id, t0));
}
}
let Some((id, d)) = best else { return false };
best
}
pub(super) fn attack_dragon(&mut self) -> bool {
let Some((id, d)) = self.dragon_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
+9
View File
@@ -86,6 +86,15 @@ impl Game<'_> {
false,
)
}
/// `Camera.calculateHudFov`: the first-person hand's field of view.
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
self.view.calculate_hud_fov(
partial,
self.fov_effect_scale(),
self.death_time(),
self.camera_fluid(camera),
)
}
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
/// `bobHurt`, then `bobView` with the View Bobbing option.
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
+372
View File
@@ -0,0 +1,372 @@
//! The local player's hand: clicks and held attack through
//! `horizon_client::first_person` (`Minecraft.startAttack` /
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use horizon_client::{
Result,
attack_strength::HeldStack,
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
items::ItemStack,
};
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
const OFFHAND: usize = usize::MAX;
impl Game<'_> {
/// `LivingEntity.swing` with `getModifiedSwingDuration`.
pub(super) fn swing_hand(&mut self, hand: Hand, animation: SwingAnimation) {
let effects = &self.potions.effects;
let dig_speed = effects
.amplifier("haste")
.max(effects.amplifier("conduit_power"));
let fatigue = effects.amplifier("mining_fatigue");
let duration = swing_duration(animation, dig_speed, fatigue);
if self.swing.start_if_able(hand, animation, duration) {
self.swings_started += 1;
}
}
/// `swing(InteractionHand.MAIN_HAND)` with the default animation.
pub(super) fn swing_arm(&mut self) {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
/// `Player.resetAttackStrengthTicker`.
pub(super) fn reset_attack_strength(&mut self) {
self.attack_ticks = 0;
self.hand_strength.reset();
}
/// The attack and use part of `Minecraft.tick` for the local player:
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
let attack_clicks = std::mem::take(&mut self.attack_clicks);
if self.screen == Screen::Playing && self.title.is_none() {
let mut clicks = std::mem::take(&mut self.clicks);
// keyAttack.isDown(); playing implies the grabbed mouse here
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
let mut world = GameHands {
game: self,
error: None,
};
clicks.tick(&mut world, attack_clicks, 0, down, false);
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
return Err(e);
}
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
let d = &self.clicks.destroying;
if d.active && d.ticks > ticks {
let (pos, first) = (d.pos, (d.ticks as u32 - 1).is_multiple_of(4));
let state = self.world.block(pos[0], pos[1], pos[2]);
if first {
let sounds = self.res.facts.get(state).sounds.clone();
self.audio.play(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
if let Some(hit) = self.pick() {
self.crack_particle(pos, hit.face, state);
}
}
} else {
// Minecraft.tick: a screen keeps the attack from starting.
self.clicks.miss_time = 10000;
}
let d = &self.clicks.destroying;
self.mining = d.active.then_some(Mining {
pos: d.pos,
progress: d.progress,
ticks: d.ticks as u32,
});
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts.get(state);
if facts.hardness == -1.0 {
return 0.0;
}
let held = self.held();
let mut speed = self.res.items.mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
if let Some(eye) = self.eye_position()
&& self
.res
.facts
.get(self.world.block(
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water
{
speed *= self.submerged_mining_speed();
}
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest = !facts.requires_tool || self.res.items.correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
}
/// Whether an attack would hit an entity rather than the block behind
/// it: the crosshair's `EntityHitResult`.
fn entity_targeted(&self) -> bool {
let before = |d: Option<f64>| d.is_some_and(|d| self.before_block(d));
before(self.painting_hit().map(|h| h.1))
|| before(self.frame_hit().map(|h| h.1))
|| before(self.armor_stand_hit().map(|h| h.1))
|| before(self.boat_hit().map(|h| h.1))
|| before(self.cart_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
|| before(self.mob_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
&& [
winit::keyboard::KeyCode::KeyW,
winit::keyboard::KeyCode::KeyS,
winit::keyboard::KeyCode::KeyA,
winit::keyboard::KeyCode::KeyD,
]
.iter()
.any(|k| self.keys.contains(k))
}
/// `MultiPlayerGameMode.startUseItem`'s bookkeeping around the game's
/// use: nothing starts while a block is being broken or the hands are
/// busy, and `LocalPlayer.itemUsed` drops the hand after a use that
/// swung and spent (or, with infinite materials, kept) the item, or that
/// started using it.
pub(super) fn use_with_hand(&mut self) -> Result<()> {
if self.clicks.destroying.active || self.hands_busy() {
return Ok(());
}
let before = self.held().cloned();
let swings = self.swings_started;
let using = self.using.is_some();
self.use_item()?;
let swung = self.swings_started != swings;
let after = self.held().cloned();
let spent = match (&before, &after) {
(Some(a), Some(b)) => a.count != b.count || a.item != b.item || self.creative,
_ => false,
};
if (swung && spent) || (!using && self.using.is_some() && after.is_some()) {
self.heights.item_used(Hand::Main);
}
Ok(())
}
/// `LocalPlayer.tick`: the swing (`LivingEntity.baseTick`), the attack
/// strength and item swap tickers (`Player.tick`), then
/// `FirstPersonHandsAndItems.tick`.
pub(super) fn tick_hand(&mut self) {
self.swing.tick();
let main = self.inventory[self.selected].clone();
self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
item: s.item.to_string(),
rest: format!("{} {} {:?}", s.count, s.damage, s.extra),
}));
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
// A stack that stays in its slot with the same item is the same
// stack, whatever its count (vanilla shrinks and grows it in place).
let mut changed = [false; 2];
for h in 0..2 {
let same = match (&self.visible_hands[h], &next[h]) {
((_, None), (_, None)) => true,
((slot, Some(a)), (next_slot, Some(b))) => {
a.item == b.item
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
}
_ => false,
};
if same {
self.visible_hands[h] = next[h].clone();
}
changed[h] = !same;
}
let speed = self.main_hand_attack_speed();
let replace = self.heights.tick(
self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
self.visible_hands[h] = next[h].clone();
}
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.held()
.and_then(|h| self.res.items.get(&h.item))
.map_or(0.0, |i| f64::from(i.attack_speed))
}
/// The visible main- and off-hand items.
pub(super) fn visible_hand_items(&self) -> [Option<ItemStack>; 2] {
[
self.visible_hands[0].1.clone(),
self.visible_hands[1].1.clone(),
]
}
}
/// The game as `first_person`'s world: the crosshair, breaking and attacking.
struct GameHands<'g, 'a> {
game: &'g mut Game<'a>,
error: Option<Box<dyn std::error::Error>>,
}
impl GameHands<'_, '_> {
fn keep(&mut self, r: Result<()>) {
if let Err(e) = r
&& self.error.is_none()
{
self.error = Some(e);
}
}
}
impl HandWorld for GameHands<'_, '_> {
fn hit(&mut self) -> Hit {
if self.game.entity_targeted() {
return Hit::Entity;
}
match self.game.pick() {
Some(h) => Hit::Block {
pos: h.pos,
face: match h.face {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
_ => 5,
},
},
None => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
let state = self.game.world.block(pos[0], pos[1], pos[2]);
self.game.res.facts.get(state).name.ends_with("air")
}
fn creative(&self) -> bool {
self.game.creative
}
fn spectator(&self) -> bool {
self.game.spectator()
}
fn instabuild(&self) -> bool {
self.game.creative
}
fn hands_busy(&self) -> bool {
self.game.hands_busy()
}
fn using_item(&self) -> bool {
self.game.using.is_some()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
match hand {
Hand::Main => self.game.held().is_none(),
Hand::Off => self.game.offhand.is_none(),
}
}
fn held_count(&mut self, hand: Hand) -> i32 {
let stack = match hand {
Hand::Main => self.game.held(),
Hand::Off => self.game.offhand.as_ref(),
};
stack.map_or(0, |s| s.count as i32)
}
fn infinite_materials(&self) -> bool {
self.game.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {}
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
self.game.destroy_progress(pos)
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
if self.is_air(pos) {
return false;
}
// Item.canDestroyBlock: swords, tridents and maces cannot break
// blocks in creative.
let held = self.game.held().map(|s| s.item.to_string());
if self.game.creative
&& held.is_some_and(|i| {
i.ends_with("_sword") || i == "minecraft:trident" || i == "minecraft:mace"
})
{
return false;
}
let r = self.game.break_block(pos);
self.keep(r);
true
}
fn main_hand_key(&mut self) -> String {
self.game
.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
}
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
self.game.swing_hand(hand, animation);
}
fn reset_attack_strength(&mut self) {
self.game.reset_attack_strength();
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, _: Hand) -> Option<UseResult> {
None
}
fn use_on_block(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn use_item(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn item_used(&mut self, hand: Hand) {
self.game.heights.item_used(hand);
}
fn release_using(&mut self) {}
}
@@ -29,8 +29,7 @@ pub(super) struct GuestPlay {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
swing: Option<u32>,
swing_anim: (f32, f32),
swing: horizon_client::first_person::SwingState,
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
last_progress: i32,
@@ -131,8 +130,7 @@ impl Game<'_> {
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let swing = std::mem::replace(&mut self.swing, play.swing.take());
let swing_anim = std::mem::replace(&mut self.swing_anim, play.swing_anim);
let swing = std::mem::replace(&mut self.swing, play.swing);
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
@@ -153,7 +151,6 @@ impl Game<'_> {
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.swing = std::mem::replace(&mut self.swing, swing);
play.swing_anim = std::mem::replace(&mut self.swing_anim, swing_anim);
self.container = container;
self.screen = screen;
if sneak {
@@ -689,7 +686,7 @@ impl Game<'_> {
.mining
.as_ref()
.map_or(-1, |m| (m.progress.clamp(0.0, 1.0) * 10.0) as i32 - 1);
let swinging = play.swing.is_some();
let swinging = play.swing.swinging();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
host.hub.broadcast_near(
@@ -779,7 +776,7 @@ impl Game<'_> {
};
let (id, at) = (g.entity_id, g.position);
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing == Some(0);
let swinging = play.swing.swinging() && play.swing.ticks == 0;
if swinging && !play.swung {
host.hub.swing(id, 0, at, Some(conn));
}
+1 -1
View File
@@ -890,7 +890,7 @@ impl Game<'_> {
self.swing_arm();
true
}
fn cart_hit(&self) -> Option<(usize, f64)> {
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
@@ -297,8 +297,8 @@ impl Game<'_> {
}
let crouching = p.pose == Pose::Crouching;
let (position, flying) = (p.position, p.flying || p.fall_flying);
let attack = self.swing_anim;
let swinging = self.swing.is_some();
let attack = (self.swing.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
+55 -1
View File
@@ -78,6 +78,9 @@ pub struct Frame<'a> {
pub celestial: &'a [Vertex],
/// First-person hand and held item, drawn after clearing depth.
pub hand: &'a [Vertex],
/// `CameraRenderState.hudFov`: the hand's own field of view
/// (`GameRenderer.render3dHud`).
pub hand_fov_degrees: f32,
/// Full-screen camera effects drawn over the world and hand, under the HUD.
pub screen: &'a [crate::screen_effects::ScreenQuad],
/// The world border wall, when near enough to be drawn.
@@ -139,6 +142,9 @@ pub struct Renderer {
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
/// The entity resources with the hand's projection (`hand_globals`).
hand_group: wgpu::BindGroup,
hand_globals: wgpu::Buffer,
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
group: wgpu::BindGroup,
@@ -470,6 +476,12 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let hand_globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("First-person hand globals"),
size: std::mem::size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let (mips, mip_levels) = mip_chain(sprite_pixels, sprite_size, layers);
let sprite_texture = device.create_texture_with_data(
&queue,
@@ -665,6 +677,32 @@ impl Renderer {
},
],
});
let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("First-person hand resources"),
layout: &entity_bindings,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: hand_globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&atlas_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&atlas_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
},
],
});
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity layout"),
bind_group_layouts: &[Some(&entity_bindings)],
@@ -1065,6 +1103,8 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
hand_group,
hand_globals,
entity_buffer,
entity_capacity,
group,
@@ -1492,6 +1532,20 @@ impl Renderer {
};
self.queue
.write_buffer(&self.globals, 0, bytemuck::bytes_of(&globals));
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
frame.hand_fov_degrees,
self.config.width as f32,
self.config.height.max(1) as f32,
(frame.distance * 4.0).max(1024.0),
));
let hand_globals = Globals {
view_proj: (hand_projection * frame.bob * view).to_cols_array_2d(),
..globals
};
self.queue
.write_buffer(&self.hand_globals, 0, bytemuck::bytes_of(&hand_globals));
// Frustum culling and camera-relative origins.
let planes = frustum(view_proj);
let camera = frame.camera;
@@ -1803,7 +1857,7 @@ impl Renderer {
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
+1
View File
@@ -41,6 +41,7 @@ pub mod entities;
pub mod entity_nbt;
pub mod environment;
pub mod fastutil_order;
pub mod first_person;
pub mod flatgen;
pub mod fog;
pub mod font;
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -299,6 +299,11 @@ public final class VanillaCameraOracle {
+ " " + fb(up.x()) + " " + fb(up.y()) + " " + fb(up.z())
+ " " + fb(left.x()) + " " + fb(left.y()) + " " + fb(left.z())
+ " " + camera.isDetached() + " " + fb(fv) + " " + fb(hv));
// Camera.setupPerspective and GameRenderer.render3dHud's hudProjection:
// Projection.getMatrix's setPerspective (planes reversed, zero-to-one depth).
float w = f(op.get(4)), h = f(op.get(5)), far = f(op.get(6));
out.println("projection" + matrix(new Matrix4f().setPerspective(fv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true))
+ matrix(new Matrix4f().setPerspective(hv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true)));
}
default -> throw new IllegalArgumentException(op.toString());
}
@@ -388,6 +393,16 @@ public final class VanillaCameraOracle {
out.println(sb);
}
static String matrix(Matrix4f m) {
var sb = new StringBuilder();
for (int col = 0; col < 4; col++) {
for (int row = 0; row < 4; row++) {
sb.append(' ').append(fb(m.get(col, row)));
}
}
return sb.toString();
}
static void avatar(JsonObject c, PrintWriter out) throws Exception {
StubPlayer p = make(StubPlayer.class);
var state = new ClientAvatarState();
@@ -0,0 +1,385 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
import net.minecraft.client.multiplayer.prediction.BlockStatePredictionHandler;
import net.minecraft.client.player.FirstPersonHandsAndItems;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.tutorial.Tutorial;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.protocol.Packet;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.TagKey;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.flag.FeatureFlagSet;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.SwingAnimation;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.level.material.*;
import net.minecraft.world.phys.*;
/**
* Drives the real Minecraft.startAttack, continueAttack and startUseItem,
* MultiPlayerGameMode's startDestroyBlock, continueDestroyBlock,
* stopDestroyBlock, destroyBlock and attack, LivingEntity.swing with its
* SwingState and getModifiedSwingDuration, Player's attack strength and item
* swap tickers and getItemSwapScale, and FirstPersonHandsAndItems.tick and
* itemUsed, tick by tick over scripted clicks and crosshair targets. The
* player is a LocalPlayer shell with a real Inventory and attributes; the
* level, connection and item model resolver are shells answering what these
* read, and the results of using items on blocks, entities and the air are
* scripted. The attack and use part of Minecraft.handleKeybinds and the
* ticker lines of Player.tick are mirrored here in vanilla's order.
*/
public final class VanillaFirstPersonOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static Field field(Class<?> type, String name) throws Exception {
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException e) {
// try the superclass
}
}
throw new NoSuchFieldException(type + "." + name);
}
static Method method(Class<?> type, String name, Class<?>... args) throws Exception {
Method m = type.getDeclaredMethod(name, args);
m.setAccessible(true);
return m;
}
static String fb(float v) {
return String.format("%08x", Float.floatToRawIntBits(v));
}
static List<String> events = new ArrayList<>();
static Map<BlockPos, BlockState> blocks = new HashMap<>();
static JsonObject step;
static String hand(InteractionHand h) {
return h == InteractionHand.MAIN_HAND ? "main" : "off";
}
static InteractionResult result(String name) {
return switch (name) {
case "success" -> InteractionResult.SUCCESS;
case "server" -> InteractionResult.SUCCESS_SERVER;
case "consume" -> InteractionResult.CONSUME;
case "fail" -> InteractionResult.FAIL;
default -> InteractionResult.PASS;
};
}
static final class Level0 extends ClientLevel {
Level0() {
super(null, null, null, null, 0, 0, null, false, 0L, 0);
}
@Override public BlockState getBlockState(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); }
@Override public FluidState getFluidState(BlockPos pos) { return Fluids.EMPTY.defaultFluidState(); }
@Override public boolean setBlock(BlockPos pos, BlockState state, int flags, int limit) {
blocks.put(pos.immutable(), state);
return true;
}
@Override public void levelEvent(Entity source, int type, BlockPos pos, int data) { }
@Override public void gameEvent(Holder<GameEvent> event, Vec3 pos, GameEvent.Context context) { }
@Override public void destroyBlockProgress(int id, BlockPos pos, int progress) {
events.add("stage " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + progress);
}
@Override public void addBreakingBlockEffects(BlockPos pos, Direction direction, boolean sound) { }
@Override public FeatureFlagSet enabledFeatures() { return FeatureFlags.REGISTRY.allFlags(); }
}
static final class Connection0 extends ClientPacketListener {
Connection0() {
super(null, null, null);
}
@Override public void send(Packet<?> packet) { }
}
static final class Resolver extends ItemModelResolver {
Resolver() {
super(null);
}
// No vanilla item model turns hand_animation_on_swap off, and only
// spears (not scripted here) scale the swap animation.
@Override public boolean shouldPlaySwapAnimation(ItemStack stack) { return true; }
@Override public float swapAnimationScale(ItemStack stack) { return 1.0F; }
}
static final class Player0 extends LocalPlayer {
AttributeMap attributes;
boolean inRange;
Player0() {
super(null, null, null, null, null, Input.EMPTY, false, null, null);
}
@Override public AttributeMap getAttributes() { return attributes; }
@Override public boolean isEyeInFluid(TagKey<Fluid> type) { return false; }
@Override public boolean onGround() { return true; }
@Override public boolean isWithinEntityInteractionRange(Entity entity, double buffer) { return inRange; }
@Override public void attack(Entity entity) { events.add("attack"); }
@Override public boolean swing(InteractionHand hand, SwingAnimation animation, boolean send) {
boolean started = super.swing(hand, animation, send);
events.add("swing " + hand(hand) + " " + animation.duration() + " " + started);
return started;
}
@Override public void itemUsed(InteractionHand hand) {
events.add("used " + hand(hand));
super.itemUsed(hand);
}
}
static final class GameMode0 extends MultiPlayerGameMode {
GameMode0(Minecraft minecraft, ClientPacketListener connection) {
super(minecraft, connection);
}
@Override public boolean destroyBlock(BlockPos pos) {
boolean changed = super.destroyBlock(pos);
events.add("destroy " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + changed);
return changed;
}
@Override public void releaseUsingItem(Player player) { events.add("release"); }
InteractionResult scripted(String kind, Player player, InteractionHand hand) {
String name = step.getAsJsonObject("uses").get(kind).getAsString();
events.add("use " + kind + " " + hand(hand) + " " + name);
InteractionResult r = result(name);
if (r instanceof InteractionResult.Success && step.get("shrink").getAsBoolean()) {
player.getItemInHand(hand).shrink(1);
}
return r;
}
@Override public InteractionResult useItemOn(LocalPlayer player, InteractionHand hand, BlockHitResult hit) { return scripted("block", player, hand); }
@Override public InteractionResult useItem(Player player, InteractionHand hand) { return scripted("item", player, hand); }
@Override public InteractionResult interact(Player player, Entity entity, EntityHitResult hit, InteractionHand hand) {
return scripted("entity", player, hand);
}
}
static ItemStack stack(JsonElement e) {
if (e.isJsonNull()) {
return ItemStack.EMPTY;
}
var a = e.getAsJsonArray();
return new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse(a.get(0).getAsString())), a.get(1).getAsInt());
}
/** LivingEntity.collectEquipmentChanges for the main hand: the item's attribute modifiers. */
static AttributeMap attributes(ItemStack main) {
var map = new AttributeMap(Player.createAttributes().build());
main.forEachModifier(EquipmentSlot.MAINHAND, (attribute, modifier) -> {
var instance = map.getInstance(attribute);
if (instance != null) {
instance.addTransientModifier(modifier);
}
});
return map;
}
public static void main(String[] args) throws Exception {
// The vanilla data pack: item components (tools, attribute modifiers) and block tags.
ParityData.init();
var u = Class.forName("sun.misc.Unsafe");
unsafe = field(u, "theUnsafe").get(null);
allocate = u.getMethod("allocateInstance", Class.class);
Method startAttack = method(Minecraft.class, "startAttack");
Method continueAttack = method(Minecraft.class, "continueAttack", boolean.class);
Method startUseItem = method(Minecraft.class, "startUseItem");
Field missTime = field(Minecraft.class, "missTime");
Field rightClickDelay = field(Minecraft.class, "rightClickDelay");
Field swingState = field(LivingEntity.class, "swingState");
Field attackTicker = field(LivingEntity.class, "attackStrengthTicker");
Field swapTicker = field(LivingEntity.class, "itemSwapTicker");
Field lastItem = field(Player.class, "lastItemInMainHand");
Field hands = field(LocalPlayer.class, "firstPersonHandsAndItems");
Field handsBusy = field(LocalPlayer.class, "handsBusy");
Field using = field(LocalPlayer.class, "startedUsingItem");
Field destroyDelay = field(MultiPlayerGameMode.class, "destroyDelay");
Field destroying = field(MultiPlayerGameMode.class, "isDestroying");
Field progress = field(MultiPlayerGameMode.class, "destroyProgress");
Field mainHeight = field(FirstPersonHandsAndItems.class, "mainHandHeight");
Field oMainHeight = field(FirstPersonHandsAndItems.class, "oMainHandHeight");
Field offHeight = field(FirstPersonHandsAndItems.class, "offHandHeight");
Field oOffHeight = field(FirstPersonHandsAndItems.class, "oOffHandHeight");
Field ticks = field(LivingEntity.SwingState.class, "ticks");
Field animation = field(LivingEntity.SwingState.class, "animation");
Field oldAnimation = field(LivingEntity.SwingState.class, "oldAnimation");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
blocks.clear();
for (var e : c.getAsJsonArray("blocks")) {
var b = e.getAsJsonArray();
blocks.put(new BlockPos(b.get(0).getAsInt(), b.get(1).getAsInt(), b.get(2).getAsInt()),
BuiltInRegistries.BLOCK.getValue(Identifier.parse(b.get(3).getAsString())).defaultBlockState());
}
Minecraft mc = make(Minecraft.class);
Level0 level = make(Level0.class);
field(ClientLevel.class, "worldBorder").set(level, new WorldBorder());
field(ClientLevel.class, "blockStatePredictionHandler").set(level, new BlockStatePredictionHandler());
Connection0 connection = make(Connection0.class);
Player0 player = make(Player0.class);
field(LocalPlayer.class, "minecraft").set(player, mc);
field(LocalPlayer.class, "connection").set(player, connection);
field(Entity.class, "level").set(player, level);
field(Entity.class, "id").setInt(player, 1);
var equipment = new PlayerEquipment(player);
field(LivingEntity.class, "equipment").set(player, equipment);
field(Player.class, "inventory").set(player, new Inventory(player, equipment));
var abilities = new Abilities();
boolean creative = c.get("creative").getAsBoolean();
abilities.instabuild = creative;
field(Player.class, "abilities").set(player, abilities);
swingState.set(player, new LivingEntity.SwingState());
lastItem.set(player, ItemStack.EMPTY);
hands.set(player, new FirstPersonHandsAndItems());
var effects = new HashMap<Holder<MobEffect>, MobEffectInstance>();
if (!c.get("haste").isJsonNull()) {
effects.put(MobEffects.HASTE, new MobEffectInstance(MobEffects.HASTE, 100000, c.get("haste").getAsInt()));
}
if (!c.get("fatigue").isJsonNull()) {
effects.put(MobEffects.MINING_FATIGUE, new MobEffectInstance(MobEffects.MINING_FATIGUE, 100000, c.get("fatigue").getAsInt()));
}
field(LivingEntity.class, "activeEffects").set(player, effects);
player.getInventory().setItem(0, stack(c.get("main")));
equipment.set(EquipmentSlot.OFFHAND, stack(c.get("off")));
player.attributes = attributes(player.getMainHandItem());
field(Minecraft.class, "player").set(mc, player);
field(Minecraft.class, "level").set(mc, level);
field(Minecraft.class, "tutorial").set(mc, make(Tutorial.class));
field(Minecraft.class, "itemModelResolver").set(mc, make(Resolver.class));
var gameMode = new GameMode0(mc, connection);
field(MultiPlayerGameMode.class, "localPlayerMode").set(gameMode, creative ? GameType.CREATIVE : GameType.SURVIVAL);
field(Minecraft.class, "gameMode").set(mc, gameMode);
out.println("attack_speed " + Long.toHexString(Double.doubleToRawLongBits(player.getAttributeValue(Attributes.ATTACK_SPEED))));
var entity = make(net.minecraft.world.entity.item.ItemEntity.class);
field(Entity.class, "blockPosition").set(entity, BlockPos.ZERO);
field(Entity.class, "id").setInt(entity, 2);
int s = 0;
for (var e : c.getAsJsonArray("steps")) {
step = e.getAsJsonObject();
events.clear();
if (!step.get("set").isJsonNull()) {
var set = step.getAsJsonObject("set");
if (set.get("hand").getAsString().equals("main")) {
player.getInventory().setItem(0, stack(set.get("stack")));
} else {
equipment.set(EquipmentSlot.OFFHAND, stack(set.get("stack")));
}
}
var hit = step.getAsJsonArray("hit");
HitResult hitResult = switch (hit.get(0).getAsString()) {
case "block" -> new BlockHitResult(Vec3.ZERO, Direction.from3DDataValue(hit.get(4).getAsInt()),
new BlockPos(hit.get(1).getAsInt(), hit.get(2).getAsInt(), hit.get(3).getAsInt()), false);
case "entity" -> {
player.inRange = hit.get(1).getAsBoolean();
yield new EntityHitResult(entity, Vec3.ZERO);
}
default -> BlockHitResult.miss(Vec3.ZERO, Direction.UP, BlockPos.ZERO);
};
field(Minecraft.class, "hitResult").set(mc, hitResult);
handsBusy.setBoolean(player, step.get("busy").getAsBoolean());
using.setBoolean(player, step.get("using").getAsBoolean());
boolean attackDown = step.get("ad").getAsBoolean();
boolean useDown = step.get("ud").getAsBoolean();
// Minecraft.tick: rightClickDelay, then handleKeybinds' attack and use part.
if (rightClickDelay.getInt(mc) > 0) {
rightClickDelay.setInt(mc, rightClickDelay.getInt(mc) - 1);
}
boolean instant = false;
if (player.isUsingItem()) {
if (!useDown) {
gameMode.releaseUsingItem(player);
}
} else {
for (int i = 0; i < step.get("ac").getAsInt(); i++) {
instant |= (boolean) startAttack.invoke(mc);
}
for (int i = 0; i < step.get("uc").getAsInt(); i++) {
startUseItem.invoke(mc);
}
}
if (useDown && rightClickDelay.getInt(mc) == 0 && !player.isUsingItem()) {
startUseItem.invoke(mc);
}
continueAttack.invoke(mc, !instant && attackDown);
if (missTime.getInt(mc) > 0) {
missTime.setInt(mc, missTime.getInt(mc) - 1);
}
// LocalPlayer.tick: LivingEntity.baseTick's swing, the equipment's
// attribute modifiers, Player.tick's tickers, then
// FirstPersonHandsAndItems.tick.
var swing = (LivingEntity.SwingState) swingState.get(player);
swing.tick();
// LivingEntity.tick's detectEquipmentUpdates: the main hand's modifiers.
player.attributes = attributes(player.getMainHandItem());
attackTicker.setInt(player, attackTicker.getInt(player) + 1);
swapTicker.setInt(player, swapTicker.getInt(player) + 1);
ItemStack main = player.getMainHandItem();
if (!ItemStack.matches((ItemStack) lastItem.get(player), main)) {
if (!ItemStack.isSameItem((ItemStack) lastItem.get(player), main)) {
player.resetAttackStrengthTicker();
}
lastItem.set(player, main.copy());
}
var fp = (FirstPersonHandsAndItems) hands.get(player);
fp.tick(player);
for (var ev : events) {
out.println(ev);
}
var current = player.getCurrentSwing();
out.println("tick " + s++ + " " + (current == null ? "- 0" : hand(current.hand()) + " " + current.durationTicks())
+ " " + ticks.getInt(swing) + " " + fb(animation.getFloat(swing)) + " " + fb(oldAnimation.getFloat(swing))
+ " " + fb(player.getSwingAnimation(0.5F))
+ " " + attackTicker.getInt(player) + " " + swapTicker.getInt(player) + " " + fb(player.getItemSwapScale(1.0F))
+ " " + fb(mainHeight.getFloat(fp)) + " " + fb(oMainHeight.getFloat(fp))
+ " " + fb(offHeight.getFloat(fp)) + " " + fb(oOffHeight.getFloat(fp))
+ " " + missTime.getInt(mc) + " " + rightClickDelay.getInt(mc) + " " + destroyDelay.getInt(gameMode)
+ " " + destroying.getBoolean(gameMode) + " " + fb(progress.getFloat(gameMode)));
}
}
}
System.exit(0);
}
}
+30 -5
View File
@@ -4,8 +4,10 @@ field-of-view easing), alignWithEntity (view rotation quaternion and basis,
interpolated eye, third person back and front, camera distance and scale
attributes with mounts, getMaxZoom clipping against visual shapes, sleeping),
calculateFov/calculateHudFov (option, modifier, death and fluid effects,
panoramic mode), AbstractClientPlayer.getFieldOfViewModifier (flight, speed
attribute against walking speed, bow draw, spyglass), the walk and bob
panoramic mode) with the world projection and the first-person hand's
(GameRenderer.render3dHud at hudFov, whatever the FOV option),
AbstractClientPlayer.getFieldOfViewModifier (flight, speed attribute against
walking speed, bow draw, spyglass), the walk and bob
history (ClientAvatarState, updateBob) and GameRenderer.bobHurt/bobView,
driven tick by tick and frame by frame with identical scripted states."""
import json
@@ -91,6 +93,12 @@ def entity_state(rng, center):
return s
def window(rng):
"""Window size and Camera.depthFar for the world and hand projections."""
w, h = rng.choice([(1600, 1000), (854, 480), (1920, 1080), (800, 1200), (1, 1), (rng.randint(1, 4000), rng.randint(1, 3000))])
return [fbits(w), fbits(h), fbits(rng.choice([768.0, 128.0, 2048.0, 4096.0, rng.uniform(32, 4096)]))]
def camera_cases(rng):
for n in range(2500):
center = rng.choice([(0, 64, 0), (-10, -60, 7), (1000, 120, -3000), (0, 0, 0)])
@@ -105,12 +113,27 @@ def camera_cases(rng):
ops.append(['tick'])
elif kind == 'frame':
pt = rng.choice([0.0, 1.0, 0.5, 0.25, 0.999, rng.random()])
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05])
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05] + window(rng))
else:
ops.append(['type', rng.randrange(3)])
yield dict(t='camera', fov=rng.choice([70, 30, 110, 90, rng.randint(30, 110)]),
effect=dbits(rng.choice([1.0, 0.0, 0.5, rng.random()])), type=rng.choice([0, 1, 1, 2]),
cells=world(rng, center), ops=ops)
# The hand's projection at FOV 30, 70 and 110 while walking, sprinting, under
# speed, drawing a bow, dying and in fluids: GameRenderer.render3dHud uses
# hudFov, never the option or the modifier.
for fov in (30, 70, 110):
for speed in (f32(0.1), f32(0.1) * 1.3, 0.2, 0.05):
for use in (None, ['bow', 10]):
for dead in (None, 5):
for fluid in (0, 1, 2):
s = dict(kind='player', pos=[dbits(0.5), dbits(64.0), dbits(0.5)], prev=[dbits(0.5), dbits(64.0), dbits(0.5)],
xr=fbits(0.0), yr=fbits(0.0), xro=fbits(0.0), yro=fbits(0.0), eye=fbits(1.62), flying=False,
ws=fbits(0.1), speed=dbits(speed), use=use, scale_attr=dbits(1.0), dist=dbits(4.0),
sleeping=None, dead=dead, mount=None)
ops = [['entity', s]] + [['tick']] * 8 + [['frame', fbits(pt), fluid, False, fbits(1600.0), fbits(1000.0), fbits(768.0)]
for pt in (0.0, 0.5, 1.0)]
yield dict(t='camera', fov=fov, effect=dbits(1.0), type=0, cells={}, ops=ops)
# A walled-in third-person camera stepping through every distance bound.
for dist in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 32.0]:
for wall in range(0, 6):
@@ -124,7 +147,7 @@ def camera_cases(rng):
xr=fbits(15.0), yr=fbits(yaw), xro=fbits(15.0), yro=fbits(yaw), eye=fbits(1.62), flying=False,
ws=fbits(0.1), speed=dbits(f32(0.1)), use=None, scale_attr=dbits(1.0), dist=dbits(dist),
sleeping=None, dead=None, mount=None)
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] for pt in (0.0, 0.5, 1.0)]
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] + window(rng) for pt in (0.0, 0.5, 1.0)]
yield dict(t='camera', fov=70, effect=dbits(1.0), type=1 + (wall % 2), cells=cells, ops=ops)
@@ -184,7 +207,9 @@ def main():
'interpolated eye with eye-height easing, first person, third person back and mirrored front, scale and '
'camera_distance attributes including a living mount, getMaxZoom over the actual Level.clip with '
'ClipContext.Block.VISUAL against controlled visual shapes, sleeping bed orientation), calculateFov and '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), the world projection '
'(Camera.setupPerspective) and the hand projection GameRenderer.render3dHud sets up at hudFov, as JOML setPerspective '
'matrices, at FOV options 30, 70 and 110 among others, '
'AbstractClientPlayer.getFieldOfViewModifier with real Abilities, attribute maps and item stacks, '
'ClientAvatarState walk/bob history with AbstractClientPlayer.updateBob, and GameRenderer.bobHurt/bobView '
'as the full JOML pose matrix with its property flags. The camera entity is a shell with controlled '
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Compare the local player's hand with the pinned client, tick by tick over
scripted clicks: Minecraft.startAttack, continueAttack and startUseItem with
handleKeybinds' attack and use part (missTime, rightClickDelay, held
buttons), MultiPlayerGameMode's block breaking (survival progress, creative
instant breaking and its destroy delay, swords that cannot break in
creative, stopping), LivingEntity.swing with SwingState and haste or mining
fatigue durations, Player's attack strength and item swap tickers, and
FirstPersonHandsAndItems' equip heights (swap animation, itemUsed after
placing or using an item, busy hands)."""
import json
import random
import struct
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
def fbits(v):
return struct.pack('>f', v).hex()
def dbits(v):
return struct.pack('>d', v).hex()
def f32(v):
return struct.unpack('>f', struct.pack('>f', v))[0]
# What the probe needs about the scripted blocks and items, checked against
# the client through the progress and attack speed both print:
# Block.destroyTime and requiresCorrectToolForDrops,
BLOCKS = {'minecraft:dirt': (0.5, False), 'minecraft:stone': (1.5, True), 'minecraft:glass': (0.3, False),
'minecraft:bedrock': (-1.0, False), 'minecraft:short_grass': (0.0, False), 'minecraft:oak_planks': (2.0, False)}
# the Tool component's speed and correct-for-drops per item and block (1.0
# and incorrect otherwise),
TOOLS = {'minecraft:diamond_pickaxe minecraft:stone': (8.0, True), 'minecraft:diamond_shovel minecraft:dirt': (8.0, True),
'minecraft:diamond_pickaxe minecraft:glass': (1.0, False)}
# and ATTACK_SPEED with the main-hand item's modifier (4.0 without one).
SPEEDS = {'minecraft:diamond_sword': 4.0 + f32(-2.4), 'minecraft:diamond_pickaxe': 4.0 + f32(-2.8),
'minecraft:diamond_shovel': 4.0 + f32(-3.0)}
ITEMS = [None, ['minecraft:diamond_sword', 1], ['minecraft:diamond_pickaxe', 1], ['minecraft:diamond_shovel', 1],
['minecraft:dirt', 64], ['minecraft:dirt', 2], ['minecraft:bread', 3], ['minecraft:bow', 1], ['minecraft:shield', 1]]
TARGETS = [(0, 64, 0), (1, 64, 0), (0, 65, 2)]
def step(hit=('miss',), ac=0, uc=0, ad=False, ud=False, busy=False, using=False, uses=None, shrink=False, set=None):
return dict(hit=list(hit), ac=ac, uc=uc, ad=ad, ud=ud, busy=busy, using=using,
uses=uses or dict(entity='pass', block='pass', item='pass'), shrink=shrink, set=set)
def case(steps, creative=False, main=None, off=None, blocks=None, haste=None, fatigue=None):
return dict(creative=creative, main=main, off=off, haste=haste, fatigue=fatigue,
blocks=[[*p, b] for p, b in (blocks or {}).items()], steps=steps,
rust=dict(blocks={k: [fbits(v[0]), v[1]] for k, v in BLOCKS.items()},
tools={k: [fbits(v[0]), v[1]] for k, v in TOOLS.items()},
attack_speed={k: dbits(v) for k, v in SPEEDS.items()}))
def block_hit(pos, face=1):
return ('block', *pos, face)
def scenarios():
stone = {TARGETS[0]: 'minecraft:stone', TARGETS[1]: 'minecraft:dirt', TARGETS[2]: 'minecraft:glass'}
for creative in (False, True):
for main in ITEMS:
# Hold attack on blocks until they break, then on the air behind.
for target, block in [(TARGETS[1], 'minecraft:dirt'), (TARGETS[0], 'minecraft:stone'), (TARGETS[0], 'minecraft:bedrock'),
(TARGETS[2], 'minecraft:short_grass'), (TARGETS[2], 'minecraft:glass')]:
steps = [step(block_hit(target), ac=1, ad=True)] + [step(block_hit(target), ad=True)] * 40 + [step()] * 8
yield case(steps, creative, main, blocks={target: block})
# Sweep across three blocks while holding, as when mining a tunnel.
steps = [step(block_hit(TARGETS[0]), ac=1, ad=True)]
for i in range(60):
steps.append(step(block_hit(TARGETS[(i // 7) % 3]), ad=True))
yield case(steps + [step()] * 6, creative, main, blocks=stone)
# Clicking the air, quickly and slowly; and clicking an entity.
for gap in (0, 1, 2, 3, 5, 9, 10, 11, 15):
steps = []
for _ in range(5):
steps += [step(ac=1)] + [step()] * gap
yield case(steps + [step()] * 8, creative, main)
steps = []
for _ in range(5):
steps += [step(('entity', True), ac=1)] + [step(('entity', True))] * gap
yield case(steps + [step(('entity', True), ac=1, ad=True)] + [step(('entity', True), ad=True)] * 8, creative, main)
# Placing and using: held right button on a block and in the air.
for result in ('success', 'server', 'consume', 'pass', 'fail'):
for shrink in (False, True):
uses = dict(entity=result, block=result, item=result)
steps = [step(block_hit(TARGETS[0]), uc=1, ud=True, uses=uses, shrink=shrink)]
steps += [step(block_hit(TARGETS[0]), ud=True, uses=uses, shrink=shrink)] * 14 + [step(uses=uses)] * 8
yield case(steps, creative, main, off=['minecraft:shield', 1], blocks=stone)
steps = [step(uc=1, ud=True, uses=uses, shrink=shrink)] + [step(ud=True, uses=uses, shrink=shrink)] * 10
yield case(steps + [step(uses=uses)] * 8, creative, main, blocks=stone)
steps = [step(('entity', inr), uc=1, ud=True, uses=uses, shrink=shrink) for inr in (True, False, True)]
yield case(steps + [step(('entity', True), uses=uses)] * 8, creative, main)
# Eating or drawing a bow: using the item, then releasing.
uses = dict(entity='pass', block='pass', item='consume')
steps = [step(uc=1, ud=True, uses=uses)] + [step(ud=True, using=True, ac=k % 2, ad=True, uses=uses) for k in range(12)]
steps += [step(using=True, uses=uses), step(uses=uses)] + [step()] * 8
yield case(steps, creative, main, off=['minecraft:bread', 3])
# Changing the held item: other items, the same item with another count,
# emptying the hand, while the attack recharges or the hands are busy.
for a in ITEMS:
for b in ITEMS:
for busy in (False, True):
steps = [step()] * 25 + [step(set=dict(hand='main', stack=b), busy=busy)] + [step(busy=busy)] * 25
steps += [step(set=dict(hand='off', stack=a))] + [step()] * 12 + [step(ac=1)] + [step()] * 30
yield case(steps, False, a)
# Haste and mining fatigue change the swing's length.
for haste in (None, 0, 1, 2, 4):
for fatigue in (None, 0, 1, 2):
steps = [step(block_hit(TARGETS[1]), ac=1, ad=True)] + [step(block_hit(TARGETS[1]), ad=True)] * 60 + [step(ac=1)] + [step()] * 20
yield case(steps, False, ['minecraft:diamond_shovel', 1], blocks={TARGETS[1]: 'minecraft:dirt'}, haste=haste, fatigue=fatigue)
yield case(steps, True, None, blocks={TARGETS[1]: 'minecraft:dirt'}, haste=haste, fatigue=fatigue)
def random_cases(rng):
results = ['success', 'server', 'consume', 'pass', 'fail']
for _ in range(3000):
blocks = {p: rng.choice(list(BLOCKS) + ['minecraft:air']) for p in TARGETS}
steps = []
for _ in range(rng.randrange(5, 80)):
hit = rng.choice([('miss',), ('entity', rng.random() < 0.7)] + [block_hit(p, rng.randrange(6)) for p in TARGETS] * 2)
setting = None
if rng.random() < 0.04:
setting = dict(hand=rng.choice(['main', 'off']), stack=rng.choice(ITEMS))
steps.append(step(hit, ac=rng.choice([0] * 6 + [1, 1, 2]), uc=rng.choice([0] * 8 + [1, 2]), ad=rng.random() < 0.5,
ud=rng.random() < 0.3, busy=rng.random() < 0.05, using=rng.random() < 0.08,
uses=dict(entity=rng.choice(results), block=rng.choice(results), item=rng.choice(results)),
shrink=rng.random() < 0.3, set=setting))
yield case(steps, rng.random() < 0.4, rng.choice(ITEMS), rng.choice(ITEMS), blocks,
rng.choice([None] * 6 + [0, 1, 3]), rng.choice([None] * 6 + [0, 2]))
def make_cases(build):
rng = random.Random(0x48414e44)
path = build / 'cases.jsonl'
cases = build / 'cases'
cases.mkdir(exist_ok=True)
count = 0
with path.open('w') as out:
for generator in (scenarios(), random_cases(rng)):
for c in generator:
text = json.dumps(c, separators=(',', ':'))
out.write(text + '\n')
if count < 20000:
(cases / str(count)).write_text(text)
count += 1
return path, count
def main():
scope = ('Real Minecraft.startAttack, continueAttack and startUseItem (with the attack and use part of handleKeybinds and '
'missTime mirrored in order), MultiPlayerGameMode.startDestroyBlock, continueDestroyBlock, stopDestroyBlock, '
'destroyBlock and attack, LivingEntity.swing with SwingState and getModifiedSwingDuration under haste and mining '
'fatigue, Player.getItemSwapScale and resetAttackStrengthTicker with the ticker lines of Player.tick, and '
'FirstPersonHandsAndItems.tick and itemUsed, on a LocalPlayer shell with a real Inventory, effects and attribute '
'modifiers, over scripted clicks, held buttons, crosshair targets, item changes and use results. Level effects '
'(particles, game events), packets and the outcome of using items on blocks, entities and the air are scripted '
'or absorbed; every vanilla item plays the swap animation unscaled except spears, which are not scripted.')
harness.differential(
'rust-first-person-parity', 'VanillaFirstPersonOracle', 'first_person_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/first_person.rs', lab.ROOT / 'crates/horizon-client/src/attack_strength.rs',
lab.ROOT / 'crates/horizon-client/src/camera.rs',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java'])
if __name__ == '__main__':
main()
+25
View File
@@ -334,6 +334,31 @@ def sprint_corpus():
for gap in range(12):
seq=[0,1,0]+[0]*gap+[1]*12+[0]*3+[65]*20+[1]*12+[0]
yield dict(position=vec((12,4,0)),ops=[op('controlled_tick',k) for k in seq])
# Sprint-jumping: Player.jumpFromGround's forward impulse while sprinting,
# with sprint held (65) or started by double-tapping forward, and jump
# pressed with it, tapped, held through landings (bunny hops) or held
# before sprinting; at several headings, with too little food to sprint,
# under a two-block ceiling and up onto a slab.
runs={
'held':[65]*12+[81]+[65]*14+[81]*2+[65]*14+[0]*10,
'together':[0,81]+[81]*30+[0]*10,
'hop':[65]*8+[81]*40+[0]*10,
'jump_first':[16]*3+[17]*3+[81]*30+[0]*10,
'double_tap':[1,0,1]+[1]*12+[17]+[1]*14+[17]*25+[0]*10,
'release_sprint':[65]*10+[81]*6+[17]*20+[1]*10+[0]*5,
}
for name,seq in runs.items():
for yaw in [0,45,90,180,300]:
for food in [20,6]:
for scene in ['open','ceiling','slab']:
ops=[op('food',food=food,saturation=single(5),exhaustion=single(0),timer=0),op('yaw',single(yaw))]
blocks=[]
if scene=='ceiling':blocks=[([8,6,8+i],'stone',{}) for i in range(14)]
elif scene=='slab':blocks=[([8,4,12],'stone_slab',{'type':'bottom'}),([8,4,13],'stone',{})]
ops += [op('block',name='minecraft:'+b,position=pos,properties=props) for pos,b,props in blocks]
ops += [op('controlled_tick',k) for k in seq]
ops += [op('block',name='minecraft:air',position=pos) for pos,_,_ in blocks]
yield dict(position=vec((8.5,4,8.5)),ops=ops)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--sprint-input',action='store_true');args=parser.parse_args()