Compare commits

..
4 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 9bc1425177 Villagers: find POIs without reading every block of a 48-block sphere
AcquirePoi scanned every block within 48 of the villager (about 460,000),
naming each with string matches, every 20 to 40 ticks for each villager missing
a home, job site or meeting point. Villages gain such villagers over time
(babies, more villagers than beds), so the game thread's work grew with play
time: in a village, 18% of samples at the start and 30-42% eight minutes on,
frame rate dropping and staying low.

Block states are classified once per search, and 16^3 sections whose palette
holds no POI block are skipped whole (PoiManager only visits sections with
records). The same positions are visited in the same order, so the result,
ties in the distance sort included, is unchanged. Same session after: 18%
throughout, frames at the vsync cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:58:33 +02:00
Ludovic MarieandClaude Opus 5.5 1a9452d408 Memory: drop late chunks whole, bound the decoded sounds, count what stays resident
A column that finished loading or generating after the player moved out of
range was dropped, but its block entities and saved entities were added to the
world first. The entities spawned in a column that never became resident, so
nothing saved or removed them: travelling, mobs piled up (141 to 5,028 in an
8-minute flight), and when the column loaded later its entities spawned again,
so villages near the view edge doubled their villagers. Such a column is now
dropped with everything in it. Mesh results for a column unloaded meanwhile are
discarded too, rather than uploaded with nothing left to remove them.

Decoded static sounds were kept for the whole session: every sound heard, up to
1.4 GiB of samples, and a village plays hundreds. They now stay within 64 MiB,
least recently used out, and are decoded on a thread of their own instead of the
game loop (vanilla's buffers load asynchronously too). Stopping a source that
is still waiting for its sound stops it, as alSourceStop does, so a late decode
cannot start it.

status reports mobs, block entities, resident GPU sections and the sound
engine's tables, for soak tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:10:32 +02:00
Ludovic MarieandClaude Opus 5.5 1a10d4efe8 Windows: launcher without VCRUNTIME140.dll, a clear message without Vulkan, import checks in CI
The Windows launcher was built without crt-static and imported VCRUNTIME140.dll,
which only the Visual C++ redistributable installs: players without it got a
missing-DLL dialog before anything started. The game itself links only system
DLLs (vulkan-1.dll is loaded at run time).

- launcher.yml links the C runtime on Windows.
- tools/check_imports.py reads PE import and delay-import tables (and ELF
  loader, needed libraries and run paths); release.yml and launcher.yml fail
  when a Windows binary imports a non-system DLL.
- release.yml builds Linux on Ubuntu 22.04: the NixOS-made Linux release asked
  for a /nix/store loader, so it ran nowhere but NixOS.
- No adapter: the game says the Vulkan driver is missing and what to install,
  and on Windows keeps the console open until Enter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
Ludovic MarieandClaude Opus 5.5 59fd98dea4 Sound: play under the launcher's own stream name, open any sample format, say what opened
PipeWire remembers mute per application name. A test run's stream, muted once
in the mixer, left "PipeWire ALSA [horizon-client]" muted for every later game,
the release played from the launcher included: no sound at all, with the device,
the stream and the mixer all working.

- The launcher starts the game as "Horizon" (media.role Game), its own entry.
- The game opens no output on a vscreen, where agents run it (HORIZON_AUDIO=1
  forces it, for a null sink), so nobody needs to mute it again.
- Any device sample format is accepted (the mixer converts from f32): a plain
  ALSA default reporting i16 used to fail.
- Other output devices are tried when the default does not open, and the log
  names the output opened or every reason none did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
34 changed files with 654 additions and 2310 deletions

No files matched your search

+8
View File
@@ -27,7 +27,12 @@ jobs:
- os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.os }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
defaults:
run:
working-directory: launcher
@@ -48,6 +53,9 @@ jobs:
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
- run: cargo test --release --locked --lib
- run: cargo build --release --locked
- name: Only system libraries
if: runner.os == 'Windows'
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
- name: Package
run: |
mkdir -p dist
+26 -2
View File
@@ -1,7 +1,9 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
on:
workflow_dispatch:
permissions:
@@ -18,6 +20,10 @@ jobs:
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Only system DLLs
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
# "DLL not found" dialog on some players' machines.
run: python tools/check_imports.py target/release/horizon-client.exe
- name: Package
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
@@ -25,3 +31,21 @@ jobs:
with:
name: horizon-windows-x64
path: dist/horizon-windows-x64
linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: ALSA headers
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
- name: Build the client
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Standard loader and libraries only
run: python3 tools/check_imports.py target/release/horizon-client
- name: Package
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
- uses: actions/upload-artifact@v4
with:
name: horizon-linux-x86_64
path: dist/horizon-linux-x86_64
+1 -10
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, perspective,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
},
};
use horizon_core::{
@@ -258,15 +258,6 @@ 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}"),
}
@@ -1,461 +0,0 @@
//! 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(())
}
+263 -59
View File
@@ -61,10 +61,78 @@ pub struct Heard {
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
cache: Arc<Mutex<SoundCache>>,
/// The decoder thread for static sounds, started with the first one.
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
}
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
#[derive(Default)]
struct SoundCache {
entries: HashMap<String, (Samples, u64)>,
bytes: usize,
clock: u64,
}
const CACHE_BYTES: usize = 64 << 20;
impl SoundCache {
fn get(&mut self, key: &str) -> Option<Samples> {
self.clock += 1;
let entry = self.entries.get_mut(key)?;
entry.1 = self.clock;
Some(entry.0.clone())
}
fn insert(&mut self, key: String, samples: Samples) {
self.clock += 1;
self.bytes += Self::size(&samples);
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
self.bytes -= Self::size(&old);
}
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
let Some(oldest) = self
.entries
.iter()
.min_by_key(|(_, (_, used))| *used)
.map(|(k, _)| k.clone())
else {
break;
};
if let Some((gone, _)) = self.entries.remove(&oldest) {
self.bytes -= Self::size(&gone);
}
}
}
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
/// released meanwhile stays so.
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
&& c.state == State::Initial
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
c.looping |= looping;
}
}
pub struct Audio {
_stream: Option<cpal::Stream>,
pub engine: SoundEngine,
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
/// as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
attach(&mixer, channel, samples, looping);
});
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
return;
}
}
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
let (send, receive) = std::sync::mpsc::channel::<Decode>();
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
std::thread::Builder::new()
.name("sound decoder".into())
.spawn(move || {
for job in receive {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
samples
});
attach(&mixer, job.channel, samples, false);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| c.state = State::Stopped);
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
/// harnesses run the game and must stay off the user's speakers.
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
if var("HORIZON_NO_AUDIO").is_some() {
return Some("audio off: HORIZON_NO_AUDIO is set".into());
}
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
return None;
}
let display = var("WAYLAND_DISPLAY")?;
Path::new(&display)
.components()
.any(|c| c.as_os_str() == "vscreen")
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
}
/// An output stream on `device` in its default configuration, whatever its
/// sample format: the mixer works in f32 and converts.
fn open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
Ok((stream, config))
}
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
mix(&mut m, &mut scratch, channels, rate);
} else {
scratch.fill(0.0);
}
for (out, s) in data.iter_mut().zip(&scratch) {
*out = T::from_sample(*s);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
Ok(stream)
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
@@ -347,7 +486,7 @@ impl Audio {
Ok(v) => (Some(v), true),
Err(e) => {
if output {
eprintln!("Horizon: audio unavailable: {e}");
eprintln!("Horizon: no sound: {e}");
}
(None, false)
}
@@ -360,7 +499,8 @@ impl Audio {
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: HashMap::new(),
cache: Arc::default(),
decoder: None,
next: 0,
live,
},
@@ -374,32 +514,40 @@ impl Audio {
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or("no audio output device")?;
let config = device.default_output_config()?;
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
eprintln!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok(stream)
Err(e) => errors.push(format!("{name}: {e}")),
}
}
if errors.is_empty() {
return Err(format!("no audio output device on the {} host", host.id().name()).into());
}
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
}
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
@@ -525,6 +673,21 @@ impl Audio {
self.release(id);
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
let mut census = self.engine.census();
census["channels"] = self
.backend
.mixer
.lock()
.map_or(0, |m| m.channels.len())
.into();
if let Ok(cache) = self.backend.cache.lock() {
census["decoded"] = cache.entries.len().into();
census["decoded_mib"] = (cache.bytes >> 20).into();
}
census
}
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
@@ -590,6 +753,47 @@ mod tests {
}
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
assert!(cache.get("a").is_some());
cache.insert("e".into(), sound());
assert!(cache.bytes <= CACHE_BYTES);
assert!(
cache.get("b").is_none(),
"the least recently used goes first"
);
assert!(cache.get("a").is_some() && cache.get("e").is_some());
// A failed load is remembered without counting.
cache.insert("missing".into(), None);
assert_eq!(cache.get("missing"), Some(None));
}
#[test]
fn output_stays_off_on_a_virtual_screen_unless_asked() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
assert!(output_disabled(env(&[])).is_none());
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
assert!(
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
);
assert!(
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
-38
View File
@@ -583,44 +583,6 @@ 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(
+6 -1
View File
@@ -365,7 +365,12 @@ impl PlayerEffects {
f *= 1.0 + (a + 1) as f32 * 0.2;
}
if let Some(a) = self.amplifier("mining_fatigue") {
f *= horizon_core::jmath::pow(0.3, f64::from(a + 1)) as f32;
f *= match a {
0 => 0.3,
1 => 0.09,
2 => 0.0027,
_ => 8.1e-4,
};
}
f
}
-601
View File
@@ -1,601 +0,0 @@
//! 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);
}
}
+57 -36
View File
@@ -39,7 +39,6 @@ mod game_mode;
mod ghasts;
mod golems;
mod growth;
mod hand;
mod item_draw;
mod jukebox;
mod lan;
@@ -686,18 +685,12 @@ struct Game<'a> {
orbs: Vec<ExperienceOrb>,
xp_delay: u32,
last_level_up: u64,
/// 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,
/// 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>,
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
@@ -2796,7 +2789,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_local_hand()?;
self.tick_mining()?;
self.postprocess_chunks()?;
if running {
self.tick_blocks()?;
@@ -2848,7 +2841,7 @@ impl Game<'_> {
self.tick_particles();
}
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
self.use_with_hand()?;
self.use_item()?;
self.use_delay = 4;
}
Ok(())
@@ -6381,6 +6374,38 @@ 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> {
@@ -6398,15 +6423,14 @@ impl Game<'_> {
return out;
}
let a = alpha as f32;
// 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 {
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
} else {
attack
};
let inverse_height = 1.0 - self.heights.interpolated(a)[0];
let height = self.hand_height.0 + (self.hand_height.1 - self.hand_height.0) * a;
let inverse_height = 1.0 - height;
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = glam::Vec3::new(
-yaw.sin() * pitch.cos(),
@@ -6427,8 +6451,7 @@ impl Game<'_> {
if self.held_map_geometry(to_world, attack, inverse_height, packed, &mut out) {
return out;
}
let [main_item, _] = self.visible_hand_items();
match main_item {
match self.hand_item.clone() {
None => {
let Some(arm) = self.res.player_arm.clone() else {
return out;
@@ -7296,7 +7319,6 @@ 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,
@@ -7469,20 +7491,22 @@ impl Game<'_> {
}
match button {
MouseButton::Left => {
// KeyMapping.click: Minecraft.tick starts the attack.
if pressed {
self.attack_clicks += 1;
if pressed && self.attack() {
return Ok(());
}
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_with_hand()?;
self.use_item()?;
self.use_delay = 4;
}
}
@@ -8333,13 +8357,10 @@ pub fn run() -> Result<()> {
orbs: Vec::new(),
xp_delay: 0,
last_level_up: 0,
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(),
swing: None,
swing_anim: (0.0, 0.0),
hand_height: (1.0, 1.0),
hand_item: None,
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.
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
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
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
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 };
@@ -491,6 +491,7 @@ impl Game<'_> {
"present_mode": format!("{:?}", r.config.present_mode),
"drawn_sections": r.drawn_sections,
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
})
});
json!({
@@ -510,6 +511,7 @@ impl Game<'_> {
"ticks": self.ticks,
"day_time": self.world.day_time,
"music": self.music.current(&self.audio.engine),
"sounds": self.audio.census(),
"fishing": self.hook_status(),
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
@@ -519,6 +521,8 @@ impl Game<'_> {
"using": self.using,
"render_distance": self.streaming.radius,
"chunks": self.world.chunks.len(),
"mobs": self.mobs.len(),
"block_entities": self.world.block_entities.len(),
"pending_chunks": self.streaming.pending(),
"generated_chunks": self.streaming.generated_total,
"meshed_sections": self.streaming.meshed_total,
+5 -9
View File
@@ -722,9 +722,10 @@ 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.
/// 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()?;
pub(super) fn attack_dragon(&mut self) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
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;
@@ -749,12 +750,7 @@ impl Game<'_> {
best = Some((id, t0));
}
}
best
}
pub(super) fn attack_dragon(&mut self) -> bool {
let Some((id, d)) = self.dragon_hit() else {
return false;
};
let Some((id, d)) = best else { return false };
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
-9
View File
@@ -86,15 +86,6 @@ 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
@@ -1,372 +0,0 @@
//! 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,7 +29,8 @@ pub(super) struct GuestPlay {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
swing: horizon_client::first_person::SwingState,
swing: Option<u32>,
swing_anim: (f32, f32),
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
last_progress: i32,
@@ -130,7 +131,8 @@ 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);
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 (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
@@ -151,6 +153,7 @@ 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 {
@@ -686,7 +689,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.swinging();
let swinging = play.swing.is_some();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
host.hub.broadcast_near(
@@ -776,7 +779,7 @@ impl Game<'_> {
};
let (id, at) = (g.entity_id, g.position);
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing.swinging() && play.swing.ticks == 0;
let swinging = play.swing == Some(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
}
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
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.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
let attack = self.swing_anim;
let swinging = self.swing.is_some();
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
+21 -56
View File
@@ -78,9 +78,6 @@ 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.
@@ -142,9 +139,6 @@ 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,
@@ -220,6 +214,24 @@ fn mip_chain(pixels: &[u8], size: u32, layers: u32) -> (Vec<u8>, u32) {
}
(out, levels)
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
#[cfg(target_os = "windows")]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs a GPU driver with Vulkan \
(vulkan-1.dll comes with it). Install the latest driver for your NVIDIA, AMD or Intel GPU \
and start the game again";
#[cfg(target_os = "macos")]
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
@@ -240,7 +252,8 @@ impl Renderer {
force_fallback_adapter: false,
..Default::default()
})
.await?;
.await
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
let info = adapter.get_info();
eprintln!(
"Horizon renderer: {} ({:?}, {:?})",
@@ -476,12 +489,6 @@ 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,
@@ -677,32 +684,6 @@ 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)],
@@ -1103,8 +1084,6 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
hand_group,
hand_globals,
entity_buffer,
entity_capacity,
group,
@@ -1532,20 +1511,6 @@ 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;
@@ -1857,7 +1822,7 @@ impl Renderer {
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_bind_group(0, &self.entity_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,7 +41,6 @@ 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;
+7
View File
@@ -5,6 +5,13 @@ mod game;
fn main() {
if let Err(error) = game::run() {
eprintln!("Horizon: {error}");
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(1);
}
}
@@ -17,6 +17,7 @@ use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
/// `MemorySlot.NEVER_EXPIRE`.
pub(super) const NEVER: i64 = i64::MAX;
@@ -843,12 +844,20 @@ impl Poi {
}
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
let name = w.block_name(x, y, z);
poi_of_state(w, w.world.block(x, y, z))
}
/// The POI a block state is, if any (`PoiTypes.forState`).
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
let name = &*w.facts.get(state).name;
if name == "minecraft:bell" {
return Some(Poi::Meeting);
}
if name.ends_with("_bed") {
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
let part = w
.registry
.state(state)
.and_then(|s| s.properties.get("part"));
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
}
super::villager::job_site_profession(name).map(Poi::Job)
}
@@ -920,12 +929,30 @@ fn pois_in_range(
let chunks = radius.div_euclid(16) + 1;
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
// The POI each block state is, worked out once per search, and the
// sections whose palette holds none skipped whole, as PoiManager only
// visits sections with records: the same positions in the same order,
// without reading and naming every block of the sphere.
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
let mut kind = |state: u32| {
*kinds
.entry(state)
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
};
for qz in cz - chunks..=cz + chunks {
for qx in cx - chunks..=cx + chunks {
if !w.world.chunks.contains_key(&(qx, qz)) {
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
continue;
}
};
let holds: Vec<bool> = chunk
.sections
.iter()
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
.collect();
for y in y0..=y1 {
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
continue;
}
for z in qz * 16..qz * 16 + 16 {
for x in qx * 16..qx * 16 + 16 {
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
@@ -937,11 +964,11 @@ fn pois_in_range(
if d > r2 {
continue;
}
let state = w.world.block(x, y, z);
let state = chunk.block(x & 15, y, z & 15);
if w.facts.is_air(state) {
continue;
}
let Some(poi) = poi_at(w, x, y, z) else {
let Some(poi) = kind(state.0) else {
continue;
};
if !poi_matches(m, f, poi) {
+11
View File
@@ -445,6 +445,17 @@ impl SoundEngine {
self.instances.remove(&id);
}
}
/// Table sizes, for soak tests watching what the engine holds.
pub fn census(&self) -> serde_json::Value {
serde_json::json!({
"instances": self.instances.len(),
"playing": self.instance_to_channel.len(),
"handles": self.handles.len(),
"ticking": self.ticking.len(),
"queued": self.queued.len(),
"used": self.used,
})
}
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
self.instances.get(&id)
}
+27 -10
View File
@@ -358,20 +358,24 @@ impl Streaming {
}
Done::Generated(mut chunk) => {
let key = (chunk.x, chunk.z);
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generating.remove(&key);
self.finishing.remove(&key);
self.decorated.insert(key);
// A column that finished after the player moved away is
// dropped whole: its block entities and saved entities
// too, or they would stay in the world (and the entities
// spawn again, duplicated, when the column loads later).
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its
// unlit neighbours still light themselves from it.
@@ -424,6 +428,12 @@ impl Streaming {
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
continue;
}
// Its column unloaded while it was meshed: nothing would
// ever remove the uploaded section.
if !world.chunks.contains_key(&(x, z)) {
self.meshing.remove(&(x, sy, z));
continue;
}
self.meshing.remove(&(x, sy, z));
self.meshed_total += 1;
uploads.push(((x, sy, z), mesh));
@@ -449,6 +459,13 @@ impl Streaming {
}
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
self.light_snapshots.remove(key);
}
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
}
// Drop far proto chunks that are not checked out.
let areas: Vec<((i32, i32), i64)> =
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

+9
View File
@@ -55,6 +55,15 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
On headless Linux use `vscreen start ./target/release/horizon-client` and
`vscreen shot`.
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
why it has none (`Horizon: no sound: ...`). It opens none with
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
mute a test run's stream in the mixer: PipeWire remembers the mute for every
later `horizon-client`. The launcher starts the game as "Horizon" for that
reason.
### Drive the game from scripts (`horizon-ctl`)
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
+16
View File
@@ -351,6 +351,12 @@ pub fn client_command(tag: &str) -> Command {
let _ = fs::create_dir_all(&home);
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
cmd.current_dir(home).arg("--resources").arg(resources(tag));
// PipeWire remembers volume and mute per application name. The game's own name is
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
// muting it once left every later game silent: played from here it is "Horizon".
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
}
cmd
}
@@ -799,6 +805,16 @@ mod tests {
use super::*;
use std::cmp::Ordering::*;
#[test]
fn the_game_plays_under_its_own_audio_stream_name() {
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
return;
}
let cmd = client_command("v2026.10.13");
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
}
#[test]
fn tags_never_mix() {
assert!(is_game_tag("v2026.10.13"));
@@ -299,11 +299,6 @@ 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());
}
@@ -393,16 +388,6 @@ 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();
@@ -1,385 +0,0 @@
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);
}
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Fail when a release binary needs a library its players may not have.
Windows .exe: every DLL in the import and delay-import tables must ship with
Windows (no VCRUNTIME140.dll, vulkan-1.dll, ...): Vulkan and the like are
loaded at run time, so a missing driver is an error message, not a dialog.
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
needed libraries the ones every desktop distribution has.
"""
import argparse
import struct
import sys
from pathlib import Path
# DLLs that are part of Windows 10 and 11. api-ms-win-* and ext-ms-win-* API
# sets resolve inside Windows too.
WINDOWS_SYSTEM = {
"advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "cfgmgr32.dll", "combase.dll",
"comctl32.dll", "comdlg32.dll", "crypt32.dll", "d2d1.dll", "d3d11.dll", "d3d12.dll",
"d3dcompiler_47.dll", "dbghelp.dll", "dcomp.dll", "dwmapi.dll", "dwrite.dll", "dxgi.dll",
"gdi32.dll", "hid.dll", "icuuc.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll",
"kernelbase.dll", "mfplat.dll", "mmdevapi.dll", "msvcrt.dll", "ncrypt.dll", "ntdll.dll",
"ole32.dll", "oleaut32.dll", "opengl32.dll", "propsys.dll", "secur32.dll", "setupapi.dll",
"shcore.dll", "shell32.dll", "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll",
"uxtheme.dll", "version.dll", "winmm.dll", "ws2_32.dll", "xinput1_4.dll",
}
LINUX_LOADER = "/lib64/ld-linux-x86-64.so.2"
LINUX_SYSTEM = {
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
"ld-linux-x86-64.so.2", "libasound.so.2",
}
def windows_allowed(name):
name = name.lower()
return name in WINDOWS_SYSTEM or name.startswith(("api-ms-win-", "ext-ms-win-"))
def pe_imports(data):
"""DLL names in a PE32+ file's import and delay-import tables."""
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
raise ValueError("not a PE file")
sections_count, optional_size = struct.unpack_from("<H12xH", data, pe + 6)
optional = pe + 24
magic = struct.unpack_from("<H", data, optional)[0]
directories = optional + (112 if magic == 0x20B else 96)
sections = [struct.unpack_from("<8xIIII", data, optional + optional_size + 40 * i)
for i in range(sections_count)]
def offset(rva):
for size, address, raw_size, raw in sections:
if address <= rva < address + max(size, raw_size):
return rva - address + raw
raise ValueError(f"RVA {rva:#x} outside every section")
def name_at(rva):
start = offset(rva)
return data[start:data.index(b"\0", start)].decode("ascii")
names = []
# Directory 1: imports (20-byte descriptors, name at +12); 13: delay imports (32 bytes, name at +4).
for index, size, name_field in ((1, 20, 12), (13, 32, 4)):
rva = struct.unpack_from("<I", data, directories + 8 * index)[0]
if not rva:
continue
at = offset(rva)
while any(data[at:at + size]):
names.append(name_at(struct.unpack_from("<I", data, at + name_field)[0]))
at += size
return names
def elf_needs(data):
"""The interpreter and DT_NEEDED libraries of a little-endian ELF64 file."""
if data[:4] != b"\x7fELF" or data[4] != 2:
raise ValueError("not an ELF64 file")
phoff, phentsize, phnum = struct.unpack_from("<Q", data, 32)[0], *struct.unpack_from("<HH", data, 54)
interpreter, dynamic, loads = None, None, []
for i in range(phnum):
kind, _, offset, vaddr, _, filesz = struct.unpack_from("<IIQQQQ", data, phoff + i * phentsize)
if kind == 3:
interpreter = data[offset:offset + filesz].rstrip(b"\0").decode()
elif kind == 2:
dynamic = (offset, filesz)
elif kind == 1:
loads.append((vaddr, offset, filesz))
def file_offset(address):
for vaddr, offset, size in loads:
if vaddr <= address < vaddr + size:
return address - vaddr + offset
raise ValueError(f"address {address:#x} outside every segment")
entries = []
if dynamic:
for at in range(dynamic[0], dynamic[0] + dynamic[1], 16):
tag, value = struct.unpack_from("<qQ", data, at)
if tag == 0:
break
entries.append((tag, value))
strtab = next((file_offset(v) for t, v in entries if t == 5), None)
def string(index):
start = strtab + index
return data[start:data.index(b"\0", start)].decode()
needed = [string(v) for t, v in entries if t == 1]
runpath = [string(v) for t, v in entries if t in (15, 29)]
return interpreter, needed, runpath
def check(path):
data = Path(path).read_bytes()
problems = []
if data[:2] == b"MZ":
names = pe_imports(data)
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
summary = ", ".join(sorted(set(n.lower() for n in names)))
else:
interpreter, needed, runpath = elf_needs(data)
if interpreter not in (None, LINUX_LOADER):
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
summary = f"{interpreter}; " + ", ".join(needed)
print(f"{path}: {summary}")
for problem in problems:
print(f" {path} {problem}", file=sys.stderr)
return not problems
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("binaries", nargs="+")
args = parser.parse_args()
ok = all([check(path) for path in args.binaries])
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+5 -30
View File
@@ -4,10 +4,8 @@ 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) 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
panoramic mode), 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
@@ -93,12 +91,6 @@ 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)])
@@ -113,27 +105,12 @@ 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] + window(rng))
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05])
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):
@@ -147,7 +124,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] + window(rng) for pt in (0.0, 0.5, 1.0)]
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] 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)
@@ -207,9 +184,7 @@ 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), 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, '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), '
'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
@@ -1,175 +0,0 @@
#!/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,31 +334,6 @@ 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()