Refresh reinforcement command fix against main

Keep reinforcement and advancement-loot corpus groups, all diagnostic options, and light/player observations. The reinforcement fixture function is byte-identical. Formatting, all-target Clippy, Python compilation/options/registration and Java oracle compilation pass. Original runtime proof remains qualified to its captured base.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 05:24:59 +01:00
commit f60a109738
335 files changed
+15210 -966

No files matched your search

+1 -1
View File
@@ -158,7 +158,7 @@ jobs:
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
cargo +stable test --locked --lib --bins
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
+23 -6
View File
@@ -83,10 +83,10 @@ jobs:
build:
needs: exporters
name: build ${{ matrix.name }}
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
# Windows ARM64 and musl are new: a failure never holds the other platforms' release (the signing job
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
# ARM64 files out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
# optional archives out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' || matrix.name == 'linux-musl' }}
strategy:
fail-fast: false
matrix:
@@ -98,6 +98,11 @@ jobs:
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: linux-musl
os: ubuntu-22.04
musl: true
dir: horizon-linux-musl-x86_64
asset: horizon-linux-musl-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
@@ -155,17 +160,19 @@ jobs:
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
@@ -193,6 +200,7 @@ jobs:
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
if: ${{ !matrix.musl }}
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
@@ -208,13 +216,14 @@ jobs:
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64' && !matrix.musl
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
bin=$BIN
@@ -270,7 +279,15 @@ jobs:
with:
name: original-exporters
path: exporters
- name: Build, check and package for musl in Alpine
if: matrix.musl
# Run Docker on the Ubuntu host: JavaScript actions themselves require glibc.
run: |
docker run --rm -v "$PWD:/work" -w /work \
-e HORIZON_VERSION -e HORIZON_NIGHTLY \
rust:alpine3.22 sh tools/ci/build-musl.sh
- name: Package
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
+10 -1
View File
@@ -174,8 +174,10 @@ jobs:
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" \
"${arm64[@]}" "${musl[@]}" \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -241,11 +243,18 @@ jobs:
else
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
fi
musl=()
if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then
musl=("dist/horizon-linux-musl-x86_64.tar.gz#Game files for musl Linux (horizon-linux-musl-x86_64.tar.gz)")
else
echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: the Nightly goes out without it"
fi
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"${musl[@]}" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"${arm64[@]}" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
@@ -407,6 +407,16 @@ impl BlockEntities {
self.add(pos);
}
}
/// [`Self::retain`] for unloading chunks: removes the block entities at
/// the positions `gone` names and changes none it keeps, so those that
/// sleep stay asleep (a moving player unloads chunks most ticks).
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
let asleep = std::mem::take(&mut self.asleep);
self.retain(|p, _| !gone(p));
self.asleep = asleep;
let map = &self.map;
self.asleep.retain(|p| map.contains_key(p));
}
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
@@ -558,6 +568,24 @@ mod tests {
.collect()
}
/// Unloading a chunk removes its block entities and leaves the others'
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
#[test]
fn unloading_a_chunk_keeps_the_others_asleep() {
let mut b = level();
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
b.insert(kept, hopper());
b.insert(gone, hopper());
b.sleep(kept);
b.sleep(gone);
b.unload(|p| p.0 >> 4 == 1);
assert!(b.get(&gone).is_none());
assert!(!b.is_asleep(&gone));
assert!(b.is_asleep(&kept));
b.retain(|_, _| true);
assert!(!b.is_asleep(&kept));
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
@@ -487,6 +487,63 @@ mod tests {
assert!(!stand.idle());
}
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
#[test]
fn an_idle_brewing_stand_tick_changes_nothing() {
let items = Items::for_tests(std::collections::HashMap::new());
let brewing = Brewing {
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
inputs: HashSet::new(),
recipes: vec![BrewingRecipe {
id: "awkward".into(),
input: PotionIngredient {
items: ["minecraft:potion".to_owned()].into_iter().collect(),
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
},
reagent: PotionIngredient {
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
potions: None,
},
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
}],
};
let providers = Providers::empty();
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
let stand = |bottle: bool, fuel: i32, powder: bool| {
let mut s = BrewingStand::new();
s.fuel = fuel;
if bottle {
s.items[1] = water();
}
if powder {
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
}
s.last_bottles = Some([false, bottle, false]);
s
};
for before in [
stand(false, 0, false),
stand(true, 0, false),
stand(true, 5, false),
stand(true, 5, true),
] {
assert!(before.idle(), "{before:?}");
let mut after = before.clone();
assert_eq!(
after.tick(&brewing, &items, &providers),
BrewTick::default()
);
assert_eq!(format!("{after:?}"), format!("{before:?}"));
}
// A brewable ingredient starts the brew: not idle, and the tick changes it.
let mut brewing_now = stand(true, 5, false);
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!brewing_now.idle());
brewing_now.tick(&brewing, &items, &providers);
assert_eq!(brewing_now.brew_time, 400);
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
@@ -749,6 +749,43 @@ mod tests {
assert!(c.idle(false));
}
/// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit,
/// `cooldown_tick` unlit) must change nothing then, and must change an awake one.
#[test]
fn an_idle_campfire_tick_changes_nothing() {
let tick = |c: &mut Campfire, lit: bool| {
if lit {
!c.cook_tick().is_empty()
} else {
c.cooldown_tick()
}
};
let empty = Campfire::default();
let mut food = Campfire::default();
assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
let mut warm = food.clone();
warm.cook_tick();
let mut cooled = warm.clone();
while cooled.cooldown_tick() {}
for (before, lit) in [
(&empty, true),
(&empty, false),
(&food, false),
(&cooled, false),
] {
assert!(before.idle(lit), "{before:?} lit {lit}");
let mut after = before.clone();
assert!(!tick(&mut after, lit));
assert_eq!(&after, before, "lit {lit}");
}
for (before, lit) in [(&food, true), (&warm, false)] {
assert!(!before.idle(lit), "{before:?} lit {lit}");
let mut after = before.clone();
tick(&mut after, lit);
assert_ne!(&after, before, "lit {lit}");
}
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
+4
View File
@@ -936,6 +936,10 @@ impl Font {
offset: 0.5,
})
}
/// Starts the random `random_glyph` draws on over from `seed`.
pub fn reseed(&self, seed: i64) {
self.cache.borrow_mut().random = horizon_core::random::LegacyRandom::new(seed);
}
/// `FontSet.getRandomGlyph`: what an obfuscated character draws, a random
/// glyph of the font as wide as `advance` (rounded up), else the missing
/// glyph.
+207 -60
View File
@@ -50,11 +50,13 @@ mod inter_stage_tests;
#[cfg(test)]
pub(crate) mod test_gpu;
mod weather;
mod world_text;
use entity_batch::EntityBatch;
pub use entity_batch::{
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
};
pub use view::{View, ViewSet};
@@ -1092,10 +1094,11 @@ pub struct Renderer {
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
/// The near solid sections culled and drawn on the GPU (`gpu_culling`): its pass,
/// records and fades while it is on, where the device can (`gpu_culling_supported`),
/// and whether the last frame drew them so (`gpu_frame`).
/// records and fades while it is on, where it is offered (`gpu_culling_offer`, mirrored
/// for the settings in `graphics::gpu_culling_offer`), and whether the last frame drew
/// them so (`gpu_frame`).
gpu_culling: Option<gpu_culling::GpuCulling>,
gpu_culling_supported: bool,
gpu_culling_offer: crate::graphics::GpuCullingOffer,
gpu_frame: bool,
/// GPU frames since GPU culling was switched on: the first and every 64th after it
/// check its records (`check_gpu_records`).
@@ -1167,6 +1170,7 @@ pub struct Renderer {
/// Native mods' world primitives (`Frame::world_primitives`).
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
world_text_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
@@ -1391,6 +1395,8 @@ pub struct Renderer {
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -1408,11 +1414,17 @@ pub struct Renderer {
/// panics when such a surface is acquired from (it never had one that
/// worked), so frames configure it again first and skip until it takes.
surface_unconfigured: bool,
/// The last frame's statistics, all of one frame (`show_stats`): its sections drawn
/// (F3's `C:`, the world icon's check), section draws (face ranges and translucent
/// sections) and the draw calls the backend issued for them. A CPU frame's are its own;
/// in GPU frames (`gpu_culling`) they are the newest GPU frame's whose counts came back,
/// a frame or a few earlier (`stats_lag`).
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
pub section_draws: usize,
pub section_draw_calls: usize,
/// `draw_frame`'s frames past its early returns, and the one whose statistics are shown.
frame_serial: u64,
stats_frame: u64,
pub uploaded_bytes: u64,
started: Instant,
/// The next frame to write to a PNG path (control socket, F2, world icon).
@@ -3731,6 +3743,12 @@ impl Renderer {
alpha_blend,
false,
);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
config.format.remove_srgb_suffix(),
);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -4374,14 +4392,19 @@ impl Renderer {
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
let gpu_culling_supported = gpu_culling::supported(
let gpu_culling_offer = gpu_culling::offer(
&adapter.get_info(),
device.features(),
adapter.get_downlevel_capabilities().flags,
&device.limits(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
gpu_culling::supported(
&adapter.get_info(),
device.features(),
adapter.get_downlevel_capabilities().flags,
&device.limits(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
),
flat.is_some(),
);
crate::graphics::offer_gpu_culling(gpu_culling_offer);
// A new renderer runs no shader pack: the game loads the picked one on its
// first world frame (`shader_pack`: the picked pack draws flat, a `shaders/`
// pack or one of Horizon's own with `[shadow]`), and the frames before it, the
@@ -4455,7 +4478,7 @@ impl Renderer {
"pipelines ready (multi-draw {}, occlusion culling {}, GPU culling {}, GPU timer {})",
on_off(multi_draw),
on_off(cull.is_some()),
if gpu_culling_supported {
if gpu_culling_offer == crate::graphics::GpuCullingOffer::Offered {
"available"
} else {
"unavailable"
@@ -4501,7 +4524,7 @@ impl Renderer {
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
gpu_culling_supported,
gpu_culling_offer,
gpu_frame: false,
#[cfg(debug_assertions)]
gpu_checks: 0,
@@ -4553,6 +4576,7 @@ impl Renderer {
see_through_pipeline,
world_primitives,
text_polygon_offset_pipeline,
world_text_pipeline,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
@@ -4682,6 +4706,7 @@ impl Renderer {
pack_material_atlases: None,
pack_terrain_flat: false,
separate_ao: false,
pack_lava_layers: Vec::new(),
hud,
menu_blur: None,
post_effects: None,
@@ -4693,6 +4718,8 @@ impl Renderer {
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
frame_serial: 0,
stats_frame: 0,
uploaded_bytes: 0,
started: Instant::now(),
screenshot: None,
@@ -5008,13 +5035,15 @@ impl Renderer {
pub fn gpu_fading(&self) -> Option<u32> {
self.gpu_culling.as_ref().map(|g| g.records.fading())
}
/// Switches GPU culling, for A/B checks. Its pass, its records of the
/// resident sections and their flags while they fade are made when it is
/// switched on (the next world frame decides the flags, `mark_fades`) and
/// dropped when it is switched off, with the walk marks the flags gave:
/// nothing is kept for it while it is off but the sections' other walk marks
/// (`SectionGpu::listed`) and the cave-culling graph's bits
/// (`occlusion::Reach`), which are the same either way.
/// Switches GPU culling: the GPU Culling setting sets it (`apply_ui_settings`, after
/// every settings change and every new renderer), and so does the control word. It
/// turns on only where it is offered (`gpu_culling_offer`); unchanged, it does nothing.
/// Its pass, its records of the resident sections and their flags while they
/// fade are made when it is switched on (the next world frame decides the
/// flags, `mark_fades`) and dropped when it is switched off, with the walk
/// marks the flags gave: nothing is kept for it while it is off but the
/// sections' other walk marks (`SectionGpu::listed`) and the cave-culling
/// graph's bits (`occlusion::Reach`), which are the same either way.
pub fn set_gpu_culling(&mut self, on: bool) -> Result<()> {
if !on {
if self.gpu_culling.take().is_some() {
@@ -5026,12 +5055,12 @@ impl Renderer {
if self.gpu_culling.is_some() {
return Ok(());
}
if !self.gpu_culling_supported {
return Err("GPU culling is unavailable on this device".into());
if let Some(why) = self.gpu_culling_offer.reason() {
return Err(why.into());
}
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device) else {
// Not offered from now on.
self.gpu_culling_supported = false;
// Not offered from now on: its switch greys out.
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::Unsupported);
return Err("GPU culling could not be set up on this device".into());
};
let sections = self.layout.sections();
@@ -5047,6 +5076,15 @@ impl Renderer {
}
Ok(())
}
/// GPU culling offered or not from now on, here and in the settings' switch.
fn offer_gpu_culling(&mut self, offer: crate::graphics::GpuCullingOffer) {
self.gpu_culling_offer = offer;
crate::graphics::offer_gpu_culling(offer);
}
/// Whether GPU culling is offered here, and why not (the status's `gpu_culling_offer`).
pub fn gpu_culling_offer(&self) -> crate::graphics::GpuCullingOffer {
self.gpu_culling_offer
}
/// Debug builds: GPU culling's records are the sections' as they are now,
/// compaction's moves included: each section it keeps a record of
/// (`SectionGpu::gpu_recorded`) has its own, no record is left without its
@@ -6395,6 +6433,10 @@ impl Renderer {
}
}
}
/// Lava's sprite layers (still and flowing), whose faces a pack's solid terrain draws.
pub fn set_pack_lava_layers(&mut self, layers: Vec<u16>) {
self.pack_lava_layers = layers;
}
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
/// stages' labels; on an error the pack that ran keeps running.
@@ -6458,6 +6500,9 @@ impl Renderer {
}
});
horizon_shaders::GameInputs {
terrain_parts: terrain
.as_ref()
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
terrain,
terrain_geometry,
entity_geometry: Some(pack_entities::geometry_inputs()),
@@ -7434,15 +7479,21 @@ impl Renderer {
mark_fades(sections, &mut gpu.records, &mut gpu.fades, rows, fades);
}
// The near solid sections culled and drawn on the GPU (`gpu_culling`): never where a
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists.
// Decided before the walk, so that a pass whose buffers no longer fit can still
// switch GPU culling off.
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists, nor
// under a native pack's `[displacement]`, whose grid takes its quads from the walk's
// `visible`. Decided before the walk, so that a pass whose buffers no longer fit can
// still switch GPU culling off.
let radius = (frame.detail_distance / 16.0) as i32;
let displaced = self
.native
.as_ref()
.is_some_and(|n| n.displacement.is_some());
let mut gpu_frame = self.gpu_culling.is_some()
&& !frame.title
&& !pack_terrain
&& shadow_view.is_none()
&& mirror_plane.is_none();
&& mirror_plane.is_none()
&& !displaced;
#[cfg(debug_assertions)]
if gpu_frame {
if self.gpu_checks.is_multiple_of(64) {
@@ -7450,6 +7501,14 @@ impl Renderer {
}
self.gpu_checks = self.gpu_checks.wrapping_add(1);
}
self.frame_serial += 1;
let serial = self.frame_serial;
// GPU frames' statistics come back a frame or a few later (`GpuCulling::collect`).
if let Some(gpu) = &mut self.gpu_culling
&& let Some((frame, stats)) = gpu.collect(&self.device)
{
self.show_stats(frame, stats);
}
// The walk reads the graph GPU culling is given (checked after it).
#[cfg(debug_assertions)]
let graph_given = self.occlusion.generation();
@@ -7476,7 +7535,10 @@ impl Renderer {
// above or below the level), or whose eye is not a number, is the CPU's.
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
if prepared == gpu_culling::Prepared::OutOfMemory {
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
log::warn!(
"Horizon renderer: GPU culling off until the next start: its buffers do not fit in video memory"
);
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::OutOfMemory);
let _ = self.set_gpu_culling(false);
}
}
@@ -7605,8 +7667,8 @@ impl Renderer {
// the sections GPU culling draws: every layer animates.
self.animate(&mut encoder, frame.ticks, !frame.title && !gpu_frame);
}
self.drawn_sections = visible.len() + visible_lods.len();
let instances = self.drawn_sections + shadow_visible.len();
let listed = visible.len() + visible_lods.len();
let instances = listed + shadow_visible.len();
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -7737,6 +7799,9 @@ impl Renderer {
// (`gpu_fading`), those without a record and those past the distance (far terrain's
// cutout, and columns leaving).
let from = resident::camera_column(camera);
// Listed sections whose solid quads the pass draws: it counts them among the
// sections it kept.
let mut gpu_solid = 0;
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
if gpu_frame
@@ -7744,6 +7809,7 @@ impl Renderer {
from, radius, v.key.0, v.key.2,
))
{
gpu_solid += 1;
continue;
}
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
@@ -7994,7 +8060,7 @@ impl Renderer {
}
self.translucency_cursor += budget;
let translucent_draws = self.draws.len() as u32 - solid_draws;
self.section_draws = self.draws.len();
let section_draws = self.draws.len();
// The shadow camera's sections after the camera's: quads that can face the light (all of
// them for `every_face`), then translucent ones (in their last sorted order; depth
// decides there).
@@ -8002,7 +8068,7 @@ impl Renderer {
let mut shadow_translucent = Vec::new();
self.shadow_runs.clear();
if let Some(view) = &shadow_view {
let base = self.drawn_sections;
let base = listed;
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
// takes distances): each band's draws go by page, a call per page and band.
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
@@ -8075,8 +8141,9 @@ impl Renderer {
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
laps.lap("r_nearby");
// Metal replays a multi-draw as one draw call per entry.
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
(0..self.section_draws)
let mut section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal
{
(0..section_draws)
.filter(|&i| {
i == 0
|| i == phase1_draws as usize
@@ -8085,12 +8152,12 @@ impl Renderer {
})
.count()
} else {
self.section_draws
section_draws
};
// GPU culling's draw calls, one per page. Its draws are not counted: the GPU
// decides them, and nothing reads them back.
// GPU culling's draw calls, one per page; its draws come back with its statistics
// (`GpuCulling::copy_stats`).
if gpu_frame && let Some(gpu) = &self.gpu_culling {
self.section_draw_calls += gpu.draw_calls();
section_draw_calls += gpu.draw_calls();
}
let debug_depth = frame.debug_lines.len() as u32;
let debug_opaque = frame
@@ -8242,6 +8309,8 @@ impl Renderer {
translucent_particle_quads,
translucent_item_quads,
oit_entity_quads,
world_text_quads,
world_text_offset_quads,
] = self.entity_batch.update(
entities,
frame.additive,
@@ -8267,11 +8336,14 @@ impl Renderer {
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads
+ oit_entity_quads;
+ oit_entity_quads
+ world_text_quads
+ world_text_offset_quads;
laps.lap("r_draw_lists");
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads = world_quads - oit_entity_quads;
let base_quads =
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
let (rebuilt, uniform) =
@@ -8615,7 +8687,7 @@ impl Renderer {
// where vanilla frames draw flat).
&& !gpu_frame
&& !self.draws.is_empty()
&& (self.drawn_sections >= flat.min_sections
&& (listed >= flat.min_sections
|| pack_terrain
|| shadow_view.is_some()) =>
{
@@ -8671,7 +8743,7 @@ impl Renderer {
});
}
if flat_frame {
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
} else if self.multi_draw && !self.draws.is_empty() {
if self.draws.len() as u64 > self.indirect_capacity {
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
@@ -8689,12 +8761,6 @@ impl Renderer {
bytemuck::cast_slice(&self.draws),
);
}
// A GPU frame's walk leaves out the sections GPU culling draws, and nothing reads back
// which it kept: every record counts but the flagged ones, which the walk lists, an
// upper bound (the world icon waits for more than a few drawn).
if gpu_frame && let Some(gpu) = &self.gpu_culling {
self.drawn_sections += gpu.records.live() - gpu.records.fading() as usize;
}
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.frame_format),
..Default::default()
@@ -8702,8 +8768,15 @@ impl Renderer {
if let Some(timer) = &mut self.gpu_timer {
timer.begin(&mut encoder);
}
if gpu_frame && let Some(gpu) = &self.gpu_culling {
// The frame's statistics (`show_stats`): a CPU frame's at once; a GPU frame's when the
// pass's counts come back, with the CPU's part of it: the sections the walk listed but
// those whose solid quads the pass drew (it counts them), its draws and every call.
let cpu = [listed - gpu_solid, section_draws, section_draw_calls];
if gpu_frame && let Some(gpu) = &mut self.gpu_culling {
gpu.dispatch(&mut encoder, self.own_timing.as_mut());
gpu.copy_stats(&mut encoder, serial, cpu);
} else {
self.show_stats(serial, cpu);
}
// The world's targets: the screen, or the scaled target of `post`.
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
@@ -10007,6 +10080,29 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
// Native world text is an ordinary depth-tested overlay, after
// particles (also after the OIT composite) and before the hand.
// Its own geometry is independent of block-entity lifetime.
if world_text_quads + world_text_offset_quads > 0 {
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(..));
let first = base_quads + oit_entity_quads;
for (pipeline, count, offset) in [
(&self.world_text_pipeline, world_text_quads, first),
(
&self.text_polygon_offset_pipeline,
world_text_offset_quads,
first + world_text_quads,
),
] {
if count > 0 {
pass.set_pipeline(pipeline);
pass.draw_indexed(0..count * 6, (offset * 4) as i32, 0..1);
}
}
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
@@ -10385,16 +10481,16 @@ impl Renderer {
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
{
let offscreen = surface_frame.is_none();
// Culling and a shader pack's centre depth read back as soon as the frame is
// submitted, as here.
let cull = self.cull.as_mut().and_then(|c| c.submitted());
// Occlusion culling's results, GPU culling's statistics and a shader pack's centre
// depth are read back as soon as the frame is submitted, as here.
let culling = self.after_submit();
let center = match &mut self.pack_post {
Some((_, Some(post))) => post.submitted(),
_ => None,
};
let after = (cull.is_some() || center.is_some()).then(|| {
let after = (culling.is_some() || center.is_some()).then(|| {
Box::new(move || {
if let Some(read) = cull {
if let Some(read) = culling {
read();
}
if let Some(read) = center {
@@ -10429,9 +10525,7 @@ impl Renderer {
self.first_frame = FirstFrame::Drawn;
}
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
if let Some(read) = after {
read();
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
if let Some(read) = after.or_else(|| self.after_submit()) {
read();
}
if let Some(timer) = &mut self.gpu_timer {
@@ -10900,6 +10994,39 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
@@ -11414,6 +11541,26 @@ mod tests {
assert!(!offered(&intel, counts, compute, 8, true, None));
assert!(offered(&dx12, counts, compute, 8, true, None));
assert!(!offered(&dx12, counts, compute, 8, true, Some(usize::MAX)));
// The settings offer it there, but not on a software adapter (WARP, lavapipe),
// whose pass would run on the CPU, and they say why not on DX12 with flat page groups.
use crate::graphics::GpuCullingOffer as O;
let offer = super::gpu_culling::offer;
assert_eq!(offer(&vulkan, true, true), O::Offered);
assert_eq!(
offer(&adapter(wgpu::DeviceType::IntegratedGpu), true, false),
O::Offered
);
assert_eq!(
offer(&adapter(wgpu::DeviceType::Cpu), true, false),
O::Software
);
assert_eq!(offer(&vulkan, false, false), O::Unsupported);
assert_eq!(
offer(&adapter(wgpu::DeviceType::Cpu), false, false),
O::Unsupported
);
assert_eq!(offer(&dx12, true, false), O::Offered);
assert_eq!(offer(&dx12, false, true), O::Dx12);
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
@@ -11718,7 +11865,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -14,7 +14,8 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// A native world canvas, drawn after particles with normal text depth/culling.
pub const WORLD_TEXT_OVERLAY: u32 = 39;
/// The front of its eight-offset outlined text, using POLYGON_OFFSET.
pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
@@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -118,7 +125,7 @@ impl EntityBatch {
self.combined.clear();
self.combined
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
for runs in &self.runs {
for runs in &self.runs[..10] {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
@@ -141,6 +148,27 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
for runs in &self.runs[10..] {
let start = self.combined.len();
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
self.combined[start..]
.as_chunks_mut::<4>()
.0
.sort_by(|a, b| {
let distance = |q: &[Vertex; 4]| {
(0..3)
.map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2))
.sum::<f32>()
};
distance(b).total_cmp(&distance(a))
});
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
@@ -160,6 +188,41 @@ impl EntityBatch {
mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
pos: [x, 0.0, 0.0],
..geometry(1, false)[0]
}; 4]
};
let entities: Vec<_> = [
(0, 1.0),
(35, 2.0),
(OIT_ENTITY, 3.0),
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
);
}
// Retain the original implementation as the equivalence/benchmark oracle.
fn baseline(
@@ -257,12 +320,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
@@ -31,6 +31,12 @@
//! walk's would and a thin shell more, alike on every backend; a frame whose
//! sums could leave i32 is drawn by the CPU (`Prepared::Cpu`).
//!
//! Its statistics, the sections the pass kept and their draws (`counts[0]`,
//! `counts[1]`), are copied after the pass into one of `READBACKS` small
//! buffers with the CPU's part of the same frame (`copy_stats`), mapped once the
//! frame is submitted (`submitted`, from the frame's after-submit hook) and
//! taken in a frame or a few later (`collect`); nothing waits for them.
//!
//! A record also holds where its section's buckets of solid quads by the side
//! they face end, and what `mesher::Sides::visible_from` compares the eye with
//! for each, its margin in (`Sides::thresholds`); the tables hold the eye less
@@ -70,9 +76,17 @@ use super::{
submit::Queue,
upload::Uploads,
};
use crate::mesher::Sides;
use crate::{graphics::GpuCullingOffer, mesher::Sides};
use glam::{DVec3, DVec4, Vec3};
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
use std::{
cmp::Reverse,
collections::BinaryHeap,
sync::{
Arc,
atomic::{AtomicU8, Ordering},
},
time::Instant,
};
/// One section (`gpu_culling.wgsl`'s `Section`).
#[repr(C)]
@@ -265,6 +279,15 @@ struct Params {
/// Counts before the pages' (the shader's `STATS`): the sections `cull` kept,
/// and the draws, fewer only where a region was short.
const STATS: u64 = 2;
/// Statistics on their way back at once, a frame or a few late so the CPU never
/// waits (as `cull`'s results).
const READBACKS: usize = 3;
const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// Its mapping failed or the device lost its buffer: never copied into or
/// unmapped again, so the statistics stop (as `timing::GpuTimer`'s).
const FAILED: u8 = 3;
/// In `Params::flags` (the shader's `REACH`): cave culling's graph is in
/// `reach`.
const REACH: u32 = 1;
@@ -475,10 +498,31 @@ pub(super) fn supported(
// The pass binds 8, the last cave culling's graph.
&& limits.max_storage_buffers_per_shader_stage >= 8
&& !super::flat::is_broken_intel_vulkan(info)
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
// pulled solid pipelines (those of a device with flat page groups) read the page.
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
&& !loses_base_vertex(info, flat_min_sections.is_some())
}
/// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
/// pulled solid pipelines (those of a device with flat page groups) read the page.
/// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
pub(super) fn loses_base_vertex(info: &wgpu::AdapterInfo, flat_page_groups: bool) -> bool {
info.backend == wgpu::Backend::Dx12 && flat_page_groups
}
/// What the settings offer (`graphics::GpuCullingOffer`): where `supported`
/// holds, but not on a software adapter (WARP, lavapipe), whose pass would run
/// on the CPU beside the frame, as occlusion culling's default has it; where it
/// doesn't, DX12 with flat page groups says why (`loses_base_vertex`).
pub(super) fn offer(
info: &wgpu::AdapterInfo,
supported: bool,
flat_page_groups: bool,
) -> GpuCullingOffer {
match (supported, super::is_software(info.device_type)) {
(false, _) if loses_base_vertex(info, flat_page_groups) => GpuCullingOffer::Dx12,
(false, _) => GpuCullingOffer::Unsupported,
(true, true) => GpuCullingOffer::Software,
(true, false) => GpuCullingOffer::Offered,
}
}
/// A buffer that is recreated larger when it must hold more.
@@ -653,6 +697,7 @@ impl Records {
self.fading = 0;
}
/// The sections with a record.
#[cfg(any(test, debug_assertions))]
pub fn live(&self) -> usize {
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
self.records.len() - free as usize
@@ -730,6 +775,18 @@ impl Fades {
}
}
/// One GPU frame's statistics on their way back: `counts[..STATS]`, and what
/// the CPU drew of the same frame.
struct Readback {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
/// Its frame (gfx.rs `frame_serial`), and the CPU's part of its statistics:
/// the sections it drew that the pass didn't count, its section draws, and
/// every draw call.
frame: u64,
cpu: [usize; 3],
}
/// The pass, its buffers and the records it draws from: made when GPU culling
/// is switched on, dropped when it is switched off.
pub(super) struct GpuCulling {
@@ -769,6 +826,11 @@ pub(super) struct GpuCulling {
/// This frame's updates and table words, kept for their room.
scratch: Vec<Update>,
words: Vec<u32>,
/// The statistics on their way back (`Readback`), the next one to copy
/// into, and this frame's copy.
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
}
impl GpuCulling {
@@ -828,6 +890,21 @@ impl GpuCulling {
return None;
}
use wgpu::BufferUsages as U;
let readbacks = (0..READBACKS)
.map(|_| {
Some(Readback {
buffer: try_create(
device,
"GPU culling statistics",
STATS * 4,
U::MAP_READ | U::COPY_DST,
)?,
state: Arc::new(AtomicU8::new(FREE)),
frame: 0,
cpu: [0; 3],
})
})
.collect::<Option<Vec<_>>>()?;
Some(Self {
pipelines,
layout,
@@ -859,7 +936,8 @@ impl GpuCulling {
device,
"GPU culling counts",
(STATS + 64) * 4,
U::STORAGE | U::INDIRECT | U::COPY_DST | TEST_READ,
// The counts are copied out for the statistics (`copy_stats`).
U::STORAGE | U::INDIRECT | U::COPY_DST | U::COPY_SRC,
)?,
draws: Grown::new(
device,
@@ -891,6 +969,9 @@ impl GpuCulling {
pass_pages: 1,
scratch: Vec::new(),
words: Vec::new(),
readbacks,
next: 0,
current: None,
})
}
@@ -1131,6 +1212,78 @@ impl GpuCulling {
pub fn draw_calls(&self) -> usize {
self.pages.len()
}
/// After `dispatch`, in the same encoder: the pass's counts copied out with
/// the CPU's part of frame `frame` (`Readback::cpu`), into the next readback
/// if it is free. While three frames' are on their way, this one's
/// statistics are never shown.
pub fn copy_stats(&mut self, encoder: &mut wgpu::CommandEncoder, frame: u64, cpu: [usize; 3]) {
let r = &mut self.readbacks[self.next];
if r.state.load(Ordering::Acquire) != FREE {
return;
}
encoder.copy_buffer_to_buffer(&self.counts.buffer, 0, &r.buffer, 0, STATS * 4);
(r.frame, r.cpu) = (frame, cpu);
self.current = Some(self.next);
}
/// The frame's statistics to read back, started by calling it right after
/// the frame's submission (gfx's after-submit hook, on the submit thread
/// when the frame goes there), as `cull::Cull::submitted`.
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let (buffer, state) = (r.buffer.clone(), r.state.clone());
state.store(PENDING, Ordering::Release);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FAILED },
Ordering::Release,
);
});
})
}
/// The statistics that came back, their readbacks freed: the newest
/// frame's, as (frame, [sections drawn, section draws, draw calls]), the
/// pass's counts added to the CPU's part. Called as a frame starts, so a
/// copy no submission took (a frame that failed on the way) is dropped.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<(u64, [usize; 3])> {
self.current = None;
if device.poll(wgpu::PollType::Poll).is_err() {
return None;
}
let mut newest: Option<(u64, [usize; 3])> = None;
for r in &self.readbacks {
if r.state.load(Ordering::Acquire) != MAPPED {
continue;
}
{
let Ok(data) = r.buffer.slice(..).get_mapped_range() else {
// Device loss can destroy a buffer after its mapping
// succeeded; unmapping it would panic (`timing::GpuTimer`).
r.state.store(FAILED, Ordering::Release);
continue;
};
// The mapping need not be 4-byte aligned: read the words unaligned.
let [kept, draws]: [u32; 2] = bytemuck::pod_read_unaligned(&data[..8]);
let [sections, cpu_draws, calls] = r.cpu;
if newest.is_none_or(|(frame, _)| r.frame > frame) {
newest = Some((
r.frame,
[sections + kept as usize, cpu_draws + draws as usize, calls],
));
}
}
r.buffer.unmap();
r.state.store(FREE, Ordering::Release);
}
newest
}
}
#[cfg(test)]
@@ -2581,8 +2734,12 @@ mod tests {
}
}
gpu.dispatch(&mut encoder, None);
// Its statistics come back with a CPU part of the case's own.
let cpu = [case * 7, case * 3, case];
gpu.copy_stats(&mut encoder, case as u64, cpu);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
gpu.submitted().expect("a readback free")();
let pages = u64::from(gpu.pass_pages);
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
let draws = read(device, queue, &gpu.draws.buffer, gpu.draws.bytes);
@@ -2619,6 +2776,13 @@ mod tests {
[kept_sections, draw_count],
"case {case}: sections kept, draws"
);
// The reads above waited for the GPU.
let (kept, drawn) = (kept_sections as usize, draw_count as usize);
assert_eq!(
gpu.collect(device),
Some((case as u64, [cpu[0] + kept, cpu[1] + drawn, cpu[2]])),
"case {case}: statistics"
);
compared += kept_sections as usize;
}
// Not a comparison of empty lists, nor of a test that keeps everything.
@@ -2645,6 +2809,73 @@ mod tests {
);
}
/// The statistics come back with their frame, in place and from the submit
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
/// with its `after` handed back): three frames on their way fill the ring, a
/// fourth's are skipped, `collect` takes in the newest; and a readback whose
/// buffer the device lost after its mapping goes `FAILED`, never copied into
/// or unmapped again (as `timing::GpuTimer`'s), so the statistics stop.
#[test]
fn the_statistics_come_back_with_their_frame() {
use super::super::submit::{AfterSubmit, Job};
on_each_device(|device, queue| {
for threaded in [false, true] {
let queue = Queue::new((**queue).clone(), threaded);
let mut gpu = GpuCulling::new(device).expect("the pass");
// As if the pass kept 5 sections and drew 7 times.
queue.write_buffer(&gpu.counts.buffer, 0, bytemuck::cast_slice(&[5_u32, 7]));
// Whether the frame's statistics went out.
let frame = |gpu: &mut GpuCulling, f: u64| {
let mut encoder = device.create_command_encoder(&Default::default());
gpu.copy_stats(&mut encoder, f, [f as usize, 10, 3]);
let after = gpu.submitted().map(|g| Box::new(g) as AfterSubmit);
let sent = after.is_some();
let job = Job {
encoder,
surface: None,
after,
};
if let Err(back) = queue.submit_later(job) {
queue.submit([back.encoder.finish()]);
if let Some(read) = back.after {
read();
}
}
sent
};
let landed = |gpu: &mut GpuCulling| {
queue.landed();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
gpu.collect(device)
};
for f in 1..=3 {
assert!(frame(&mut gpu, f), "frame {f}, threaded {threaded}");
}
assert!(!frame(&mut gpu, 4), "a full ring, threaded {threaded}");
assert_eq!(landed(&mut gpu), Some((3, [3 + 5, 10 + 7, 3])));
assert_eq!(gpu.collect(device), None);
assert!(frame(&mut gpu, 5));
assert_eq!(landed(&mut gpu), Some((5, [5 + 5, 10 + 7, 3])));
// Frame 6's buffer destroyed after its mapping (#6320).
let lost = gpu.next;
assert!(frame(&mut gpu, 6));
queue.landed();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
gpu.readbacks[lost].buffer.destroy();
assert_eq!(gpu.collect(device), None);
for f in 7..=12 {
let sent = frame(&mut gpu, f);
let back = landed(&mut gpu);
let state = gpu.readbacks[lost].state.load(Ordering::Acquire);
assert_eq!(state, FAILED, "frame {f}, threaded {threaded}");
// The two other readbacks, then none.
assert_eq!(sent, f <= 8, "frame {f}, threaded {threaded}");
assert_eq!(back.is_some(), f <= 8, "frame {f}, threaded {threaded}");
}
}
});
}
/// `draw`'s count draws draw what the pass kept, page by page: each kept
/// section's quads from its own range, all of them without an eye and those
/// of its runs that can face one with it, with its own origin, which the
@@ -510,15 +510,24 @@ impl NativeFrame {
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
// eased values sit at their targets.
let still = frame.pack.still_ticks;
let seconds = still.map_or_else(
|| self.start.elapsed().as_secs_f32() % 3600.0,
|ticks| (ticks as f32 / 20.0) % 3600.0,
);
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
let since = match still {
Some(_) => f32::INFINITY,
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
};
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
still.map_or(self.frames, |ticks| ticks as u32),
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
@@ -551,7 +560,13 @@ impl NativeFrame {
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = inputs.delta_time;
let dt = match still {
Some(_) => {
inputs.delta_time = 0.05;
f32::INFINITY
}
None => inputs.delta_time,
};
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
}
@@ -519,6 +519,36 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
}\n \
}\n";
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
///
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
.map(|l| format!(" || hz_v_layer == {l}u"))
.collect();
let part = |solid: bool| {
let mut inputs = terrain.clone();
inputs.after.push_str(&format!(
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
));
inputs
};
[part(true), part(false)]
}
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
@@ -43,6 +43,9 @@ mod shadow_faces_tests;
#[cfg(test)]
mod forced_flip_tests;
#[cfg(test)]
mod weather_depth_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -516,8 +519,14 @@ pub struct PackPost {
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
shadow_cache: ShadowCache,
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`;
/// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or
/// its fallback).
terrain: Option<Pass>,
/// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`,
/// when the pack draws terrain in two parts. A pack with no program for one part has only
/// the other.
terrain_cutout: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
water: Option<Pass>,
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
@@ -2163,10 +2172,15 @@ impl PackPost {
ids: block_ids.as_ref().map(|i| &i.0),
cull: g.cull,
});
let terrain = post
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
let mut terrain = post
.terrain
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let mut terrain_cutout = post
.terrain_cutout
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let water = post
.water
.zip(terrain_geometry.as_ref())
@@ -2295,7 +2309,7 @@ impl PackPost {
let weather_geometry = world.weather.then(|| Geometry {
buffers: &entity_buffers,
table: None,
depth_write: true,
depth_write: post.rain_depth,
atlas: None,
material_maps: false,
ids: None,
@@ -2371,8 +2385,31 @@ impl PackPost {
.filter_map(|p| build(p, None))
.collect();
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
if let Some(t) = &terrain {
notes.push(format!("terrain draws with {}", t.name));
// Terrain in two parts draws both or neither: one alone would leave the other part's
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
// program of a pack that doesn't split can't draw.
if split
&& terrain.is_some() != terrain_cutout.is_some()
&& let Some(p) = terrain.take().or(terrain_cutout.take())
{
notes.push(format!(
"{}: the other part of terrain can't draw; the game draws all terrain",
p.name
));
}
match (&terrain, &mut terrain_cutout) {
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
(t, Some(c)) => {
if let Some(t) = t {
notes.push(format!("solid terrain draws with {}", t.name));
}
notes.push(format!("cutout terrain draws with {}", c.name));
// Timed (and labelled) apart from the solid part when it runs the same program.
if t.as_ref().is_some_and(|t| t.name == c.name) {
c.name.push_str(" (cutout)");
}
}
(None, None) => {}
}
for (part, pass) in [
("terrain", &shadow_terrain),
@@ -2456,6 +2493,7 @@ impl PackPost {
.iter()
.chain(&shadow_comp)
.chain(&terrain)
.chain(&terrain_cutout)
.chain(&water))
.chain(shadow_terrain.iter().chain(&shadow_water))
.chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured)))
@@ -2513,6 +2551,7 @@ impl PackPost {
.iter()
.flat_map(|s| s.basic.iter().chain(&s.textured))
.chain(&terrain)
.chain(&terrain_cutout)
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
.chain(&clouds)
.chain(&lines)
@@ -2528,6 +2567,7 @@ impl PackPost {
}
// Terrain tests against the world's depth (entities...), so the blit copies it then.
let blit_depth = terrain.is_some()
|| terrain_cutout.is_some()
|| water.is_some()
|| sky.is_some()
|| entities.as_ref().is_some_and(Entities::drawn)
@@ -2665,6 +2705,7 @@ impl PackPost {
setup_pending: true,
passes,
terrain,
terrain_cutout,
water,
sky,
entities,
@@ -3153,7 +3194,8 @@ impl PackPost {
}
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
/// the game's draws.
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
/// same draws.
fn draw_terrain(
&mut self,
device: &wgpu::Device,
@@ -3163,13 +3205,35 @@ impl PackPost {
list: TerrainList,
draw: &mut TerrainDraw<'_>,
) {
let slot = match list {
self.draw_terrain_part(device, queue, encoder, size, list, false, draw);
if list == TerrainList::Solid {
self.draw_terrain_part(device, queue, encoder, size, list, true, draw);
}
}
/// The pass that draws `list`, or the cutout part of the solid list.
fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option<Pass> {
match list {
TerrainList::Solid if cutout => &mut self.terrain_cutout,
TerrainList::Solid => &mut self.terrain,
TerrainList::Translucent => &mut self.water,
TerrainList::ShadowSolid => &mut self.shadow_terrain,
TerrainList::ShadowTranslucent => &mut self.shadow_water,
};
let Some(mut pass) = slot.take() else {
}
}
#[allow(clippy::too_many_arguments)]
fn draw_terrain_part(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
size: [u32; 2],
list: TerrainList,
cutout: bool,
draw: &mut TerrainDraw<'_>,
) {
let Some(mut pass) = self.terrain_slot(list, cutout).take() else {
return;
};
let translucent = list.translucent();
@@ -3207,12 +3271,7 @@ impl PackPost {
if !pass.group.shadow() {
self.mips_valid &= !pass.written;
}
*match list {
TerrainList::Solid => &mut self.terrain,
TerrainList::Translucent => &mut self.water,
TerrainList::ShadowSolid => &mut self.shadow_terrain,
TerrainList::ShadowTranslucent => &mut self.shadow_water,
} = Some(pass);
*self.terrain_slot(list, cutout) = Some(pass);
}
/// The sky's parts in vanilla's order, each with the program that draws it; one render pass
@@ -3851,7 +3910,7 @@ impl PackPost {
/// Whether the pack draws terrain itself.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some()
self.terrain.is_some() || self.terrain_cutout.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
@@ -3902,6 +3961,7 @@ impl PackPost {
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
@@ -4561,6 +4621,7 @@ impl PackPost {
let mut shown = false;
// A water-only pack still needs the opaque phase to populate requested snapshots.
let mut terrain_drawn = self.terrain.is_none()
&& self.terrain_cutout.is_none()
&& self.sky.is_none()
&& !self.entities.as_ref().is_some_and(Entities::drawn)
&& self.clouds.is_none()
@@ -4735,6 +4796,10 @@ mod snorm_tests;
#[path = "pack_post/depth_test.rs"]
mod depth_test;
#[cfg(test)]
#[path = "pack_post/terrain_parts_tests.rs"]
mod terrain_parts_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -3,15 +3,15 @@ use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use wgpu::{TextureFormat as T, util::DeviceExt};
const SIZE: [u32; 2] = [16, 16];
pub(super) const SIZE: [u32; 2] = [16, 16];
struct Gpu {
pub(super) struct Gpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
pub(super) device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn new() -> Option<Self> {
pub(super) fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
@@ -27,7 +27,7 @@ impl Gpu {
queue,
})
}
fn texture(&self, format: T) -> wgpu::TextureView {
pub(super) fn texture(&self, format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original depth fixture input"),
@@ -47,7 +47,7 @@ impl Gpu {
})
.create_view(&Default::default())
}
fn load(
pub(super) fn load(
&self,
files: Vec<(String, String)>,
world: World<'_>,
@@ -122,7 +122,7 @@ impl Gpu {
});
self.queue.submit([encoder.finish()]);
}
fn render(
pub(super) fn render(
&self,
post: &mut PackPost,
color: &wgpu::TextureView,
@@ -213,7 +213,7 @@ enum Scene {
WaterOnly,
}
const VERTEX: &str =
pub(super) const VERTEX: &str =
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
@@ -561,7 +561,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() {
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
@@ -0,0 +1,295 @@
//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`
//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has
//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and
//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`,
//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written
//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever
//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are
//! the probes', cut down.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
const RED: [u8; 3] = [255, 0, 0];
const GREEN: [u8; 3] = [0, 255, 0];
const BLUE: [u8; 3] = [0, 0, 255];
/// A program writing `rgb` with `alpha` into colortex0.
fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] {
let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0);
[
(
format!("{name}.vsh"),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
),
(
format!("{name}.fsh"),
format!(
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}"
),
),
]
}
/// Lava's sprite layer in the fixture.
const LAVA: u16 = 5;
/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left
/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex
/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU.
fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> {
strips_with_alphas(programs, [255; 4])
}
/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top
/// left). Fails unless each strip is one colour.
fn strips_with_alphas(
programs: &[(&str, [u8; 3], f32)],
alphas: [u8; 4],
) -> Option<[Option<[u8; 3]>; 3]> {
let files = programs
.iter()
.flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha))
.collect();
let (strips, _) = strips_of(files, alphas)?;
Some(strips)
}
/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes
/// when it draws no terrain itself (`None` when it does).
#[allow(clippy::type_complexity)]
fn strips_of(
programs: Vec<(String, String)>,
alphas: [u8; 4],
) -> Option<([Option<[u8; 3]>; 3], Option<Vec<String>>)> {
let Some(gpu) = Gpu::new() else {
eprintln!("TERRAIN_PARTS_NO_ADAPTER");
return None;
};
let mut files = vec![
("final.vsh".to_owned(), VERTEX.to_owned()),
(
"final.fsh".to_owned(),
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
.to_owned(),
),
];
files.extend(programs);
// The game's terrain inputs as far as the parts read them: the sprite layer and the colour.
let terrain = horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n\
layout(location=2)in uint hz_v_layer;\n\
layout(location=3)in vec4 hz_v_color;\n"
.into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\
hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
&terrain,
&[LAVA, LAVA + 1],
)),
terrain: Some(terrain),
..Default::default()
};
const ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
let opaque = u32::from(crate::mesher::OPAQUE_LAYER);
let mut vertices: Vec<u32> = Vec::new();
for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() {
let (x0, x1) = (
-1.0 + i as f32 * 2.0 / 3.0,
-1.0 + (i + 1) as f32 * 2.0 / 3.0,
);
let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]];
for corner in [0, 1, 2, 0, 2, 3] {
let [x, y] = corners[corner];
let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF;
vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]);
}
}
let buffer = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Terrain parts fixture"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let undrawn = (!post.has_terrain()).then(|| post.notes.clone());
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid {
rp.set_vertex_buffer(0, buffer.slice(..));
rp.draw(0..18, 0..1);
}
});
// Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips).
let colour = |x: usize, y: usize| {
let [r, g, b, _] = pixels[y * SIZE[0] as usize + x];
Some([r, g, b]).filter(|&c| c != [0, 0, 0])
};
let strips = [0..5, 6..10, 11..16].map(|columns| {
let first = colour(columns.start, 0);
for x in columns.clone() {
for y in 0..SIZE[1] as usize {
assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})");
}
}
first
});
Some((strips, undrawn))
}
#[test]
fn solid_and_cutout_programs_draw_their_own_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]);
}
#[test]
fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]);
}
#[test]
fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
}
#[test]
fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 0.05),
("gbuffers_terrain_cutout", BLUE, 0.05),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), None, Some(GREEN)]);
// The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05.
let programs = [
("gbuffers_terrain", RED, 0.05),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
}
/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad
/// differ; the quad still draws whole, with its part's program.
#[test]
fn a_quad_draws_whole_whatever_its_vertex_alphas() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] {
let Some(got) = strips_with_alphas(&programs, alphas) else {
return;
};
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}");
}
}
#[test]
fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() {
let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else {
return;
};
assert_eq!(got, [Some(RED); 3]);
}
/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part,
/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and
/// `tsc-cutoutonly`.
#[test]
fn a_lone_split_program_draws_only_its_own_terrain() {
let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]);
let Some(solid) = solid else { return };
assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]);
let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]);
assert_eq!(cutout, Some([None, Some(BLUE), None]));
}
/// When one part's pipeline can't be built (here a cutout program writing no buffer, which
/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain.
#[test]
fn a_part_that_fails_to_build_takes_the_other_with_it() {
let mut files: Vec<(String, String)> = [
program("gbuffers_terrain", RED, 1.0),
program("gbuffers_terrain_solid", GREEN, 1.0),
]
.concat();
files.push((
"gbuffers_terrain_cutout.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
));
files.push((
"gbuffers_terrain_cutout.fsh".into(),
"#version 120\nvoid main(){}".into(),
));
let Some((got, undrawn)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [None; 3]);
let notes = undrawn.expect("the game draws terrain");
for note in [
"gbuffers_terrain_cutout: writes no buffer; skipped",
"gbuffers_terrain_solid: the other part of terrain can't draw",
] {
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
}
}
@@ -0,0 +1,277 @@
//! Original synthetic weather oracle: no pack assets or external shader algorithms.
use super::*;
use std::io::{Cursor, Write};
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let mut depth_failures = Vec::new();
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
for (property, writes) in [
("rain.depth=true", true),
("rain.depth=false", false),
// An evidence-limited compatibility choice: preserve accepted main here.
// The permitted packs do not establish the external omitted-value default.
("", true),
] {
let mut post = pack(&device, &queue, &adapter, program, property);
// Retain the pipelines for another frame, then resize their targets.
for size in [[6, 1], [6, 1], [12, 2]] {
post.resize(&device, size);
let mut encoder = device.create_command_encoder(&Default::default());
let targets = post.targets.as_ref().unwrap();
let color = &targets.color[0].as_ref().unwrap()[0];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(
color,
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
store: wgpu::StoreOp::Store,
},
)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &targets.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.5),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
post.set_weather(Some(geometry(&device)));
post.draw_weather(&device, &queue, &mut encoder, size);
assert!(post.weather_draw.is_none(), "draw consumes this frame only");
assert!(post.has_weather(), "pipelines survive the draw");
// A dry frame must neither redraw old quads nor discard the pipelines.
post.set_weather(None);
post.draw_weather(&device, &queue, &mut encoder, size);
assert!(post.has_weather());
let targets = post.targets.as_ref().unwrap();
let color = targets.color[0].as_ref().unwrap()[0].texture();
let colors = readback(&device, &mut encoder, color);
let depths = readback(&device, &mut encoder, &targets.depth_texture);
queue.submit([encoder.finish()]);
for buffer in [&colors, &depths] {
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let colors = colors.slice(..).get_mapped_range().unwrap();
let depths = depths.slice(..).get_mapped_range().unwrap();
for y in 0..size[1] as usize {
for x in 0..size[0] as usize {
let quad = x / (size[0] as usize / 6);
let visible = quad.is_multiple_of(3);
// First quad is in front; second is behind scene depth; third discards.
let expected_color = if visible && quad < 3 {
[128, 0, 127, 255]
} else if visible {
[0, 128, 127, 255]
} else {
[0, 0, 255, 255]
};
let offset = y * 256 + x * 4;
let actual_color = &colors[offset..offset + 4];
for (actual, expected) in actual_color.iter().zip(expected_color) {
assert!(
i16::from(*actual).abs_diff(expected) <= 1,
"{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}"
);
}
let depth =
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
if depth != expected_depth {
depth_failures.push(format!(
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
));
}
}
}
eprintln!(
"Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}",
depth_failures.len()
);
}
}
}
assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n"));
}
fn pack(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
program: &str,
property: &str,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (extension, source) in [
(
"vsh",
"#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}",
),
(
"fsh",
"#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}",
),
] {
zip.start_file(
format!("shaders/{program}.{extension}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-weather-depth",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse(property, &macros);
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
weather: Some(super::super::pack_weather::inputs()),
..Default::default()
},
);
assert!(post.notes.is_empty(), "{:?}", post.notes);
assert_eq!(post.weather.as_ref().unwrap().name, program);
let lightmap = device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let mut post = PackPost::new(
device,
queue,
adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: true,
hand: false,
},
)
.expect("weather pack runs");
assert!(
post.has_weather(),
"production weather pipelines must compile: {:?}",
post.notes
);
post.copied |= 1; // Keep colortex0 readable for the independent color oracle.
post
}
fn geometry(device: &wgpu::Device) -> Weather {
let mut vertices = Vec::new();
let mut indices = Vec::new();
for quad in 0..6u32 {
let left = quad as f32 / 3.0 - 1.0;
let right = (quad + 1) as f32 / 3.0 - 1.0;
let z = if quad % 3 == 1 { 0.5 } else { -0.5 };
for pos in [
[left, -1.0, z],
[right, -1.0, z],
[right, 1.0, z],
[left, 1.0, z],
] {
vertices.push(super::super::Vertex {
pos,
uv: [(quad % 3) as f32, 0.0],
layer: 0,
color: u32::from_le_bytes(if quad < 3 {
[255, 0, 0, 128]
} else {
[0, 255, 0, 128]
}),
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i));
}
let buffer = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
Weather {
vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX),
indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX),
quads: [0..3, 3..6],
sprites: [None, None],
generation: 1,
}
}
fn readback(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(256 * texture.height()),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
aspect: if texture.format().is_depth_stencil_format() {
wgpu::TextureAspect::DepthOnly
} else {
wgpu::TextureAspect::All
},
..texture.as_image_copy()
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
buffer
}
@@ -66,6 +66,10 @@ pub struct PackInputs {
/// The player's cave mood, 0..1 (`BiomeAmbientSoundsHandler`'s moodiness):
/// `playerMood` and `constantMood`.
pub mood: f32,
/// Under `HORIZON_STILL`, the world's ticks: the pack's clock (`frameTimeCounter`,
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
pub still_ticks: Option<u64>,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -299,6 +303,8 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
still_ticks: Option<u64>,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
@@ -317,6 +323,7 @@ impl Default for FrameUniforms {
start: std::time::Instant::now(),
last: None,
frame: 0,
still_ticks: None,
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
@@ -422,8 +429,11 @@ impl FrameUniforms {
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
self.still_ticks = inputs.still_ticks;
// Smoothing over an endless step lands on its target.
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
let aspect = size[0] as f32 / size[1].max(1) as f32;
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
@@ -502,7 +512,18 @@ impl FrameUniforms {
let (shadow_view, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let seconds = (now - self.start).as_secs_f32() % 3600.0;
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
Some(ticks) => (
(ticks as f32 / 20.0) % 3600.0,
0.05,
(ticks % 720_720) as i32,
),
None => (
(now - self.start).as_secs_f32() % 3600.0,
measured,
self.frame as i32,
),
};
let value = |name: &str| -> Value {
let v3 = |v: Vec3| Value::f(&v.to_array());
@@ -538,13 +559,13 @@ impl FrameUniforms {
"worldTime" => Value::i(&[day_time]),
"worldDay" => Value::i(&[world_day as i32]),
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
"frameCounter" => Value::i(&[self.frame as i32]),
"frameCounter" => Value::i(&[frame_counter]),
"shadowModelView" => m4(shadow_view),
"shadowModelViewInverse" => m4(shadow_view.inverse()),
"shadowProjection" => m4(shadow_projection),
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
"frameTime" => Value::f(&[dt]),
"frameTime" => Value::f(&[frame_time]),
"frameTimeCounter" => Value::f(&[seconds]),
"viewWidth" => Value::f(&[size[0] as f32]),
"viewHeight" => Value::f(&[size[1] as f32]),
@@ -631,6 +652,9 @@ impl FrameUniforms {
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
/// `NAMES`' values, in `custom.uniforms()` order.
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
if let Some(tick) = self.still_ticks {
custom.restart_at(tick);
}
self.values.truncate(NAMES.len());
let values = &self.values;
let builtin = |name: &str| {
@@ -1396,6 +1420,42 @@ mod initial_history_tests {
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// Under `HORIZON_STILL` the pack's clock is the world's ticks, whenever and however often
/// the frame is drawn, and smoothed values are at their targets.
#[test]
fn a_still_frame_takes_the_world_ticks_as_its_clock() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
eye_light: [0, 15],
still_ticks: Some(47_811),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
input.rain = 1.0;
u.center_depth_raw = Some(0.0);
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
u.update(&f, &input, [800, 450]);
assert_eq!(
float(&u, "frameTimeCounter")[0],
(47_811.0f32 / 20.0) % 3600.0
);
assert_eq!(float(&u, "frameTime")[0], 0.05);
match u.values[index("frameCounter").unwrap()] {
Value::I(v) => assert_eq!(v[0], 47_811),
_ => panic!("integer uniform expected"),
}
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(v[1], 0),
_ => panic!("integer uniform expected"),
}
assert_eq!(float(&u, "wetness")[0], 1.0);
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -0,0 +1,208 @@
//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes.
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Normal world text"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_entity"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn world_text_blends_culls_and_obeys_world_depth() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
// Use the production pipeline/vertex layout with a pass-through shader
// to isolate its actual depth, winding and blend behavior from lighting.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
r#"
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
return Out(vec4(pos,1.0),color);
}
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
"#
.into(),
),
});
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let mut vertices = Vec::new();
let mut indices = Vec::new();
let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| {
let x = -1.0 + pixel as f32 * 0.5;
let first = vertices.len() as u32;
for pos in [
[x, -1.0, z],
[x + 0.5, -1.0, z],
[x + 0.5, 1.0, z],
[x, 1.0, z],
] {
vertices.push(super::super::Vertex {
pos,
uv: [0.0; 2],
color: u32::from_le_bytes(color),
layer: 0,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
for i in if back {
[0, 2, 1, 0, 3, 2]
} else {
[0, 1, 2, 0, 2, 3]
} {
indices.push(first + i);
}
};
quad(0, 0.6, [255, 0, 0, 255], false);
// A hair behind: the entity model pipeline's 128-ulp depth bias
// would incorrectly put the green quad in front of the red surface.
quad(0, 0.599_999, [0, 255, 0, 255], false);
quad(1, 0.8, [0, 255, 0, 255], false);
quad(1, 0.7, [255, 0, 0, 255], false);
quad(2, 0.5, [255, 0, 0, 128], false);
quad(3, 0.5, [255, 255, 0, 255], true);
let buffer = |data: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: data,
usage,
})
};
let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX);
let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX);
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color = color.create_view(&Default::default());
let depth = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_vertex_buffer(0, vertex.slice(..));
pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
color.size(),
);
queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
readback.map_async(wgpu::MapMode::Read, .., move |result| {
tx.send(result).unwrap()
});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let pixels = readback.get_mapped_range(..).unwrap();
assert_eq!(
&pixels[..16],
&[
255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255
]
);
}
}
+59 -1
View File
@@ -532,6 +532,52 @@ pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
/// value waits for a renderer that offers it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GpuCullingOffer {
/// No count draws the GPU wrote, compute shaders or working first instances (OpenGL, Metal,
/// Vulkan without `VK_KHR_draw_indirect_count`, Intel Gen11/Gen12's Vulkan driver), vanilla
/// frames drawn flat, or a pass the driver couldn't build.
Unsupported = 0,
/// A software adapter (WARP, lavapipe): the pass would run on the CPU, beside the frame.
Software = 1,
/// Its buffers stopped fitting in video memory: off until the next renderer.
OutOfMemory = 2,
/// DX12 with flat page groups, whose count draws leave each draw's base vertex out of
/// what the near sections' packed faces are read by (`gpu_culling::loses_base_vertex`).
Dx12 = 3,
Offered = 4,
}
impl GpuCullingOffer {
/// The greyed switch's tooltip; `None` where it is offered.
pub fn reason(self) -> Option<&'static str> {
match self {
Self::Unsupported => Some("Your GPU or graphics API doesn't support it."),
Self::Software => Some("Not with software rendering, which runs it on the CPU."),
Self::OutOfMemory => Some("Out of video memory: off until the next start."),
Self::Dx12 => Some("Not on DirectX 12 yet: try the Vulkan graphics API."),
Self::Offered => None,
}
}
}
static GPU_CULLING_OFFER: std::sync::atomic::AtomicU8 =
std::sync::atomic::AtomicU8::new(GpuCullingOffer::Unsupported as u8);
pub fn offer_gpu_culling(offer: GpuCullingOffer) {
GPU_CULLING_OFFER.store(offer as u8, std::sync::atomic::Ordering::Relaxed);
}
pub fn gpu_culling_offer() -> GpuCullingOffer {
match GPU_CULLING_OFFER.load(std::sync::atomic::Ordering::Relaxed) {
1 => GpuCullingOffer::Software,
2 => GpuCullingOffer::OutOfMemory,
3 => GpuCullingOffer::Dx12,
4 => GpuCullingOffer::Offered,
_ => GpuCullingOffer::Unsupported,
}
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -608,6 +654,11 @@ pub struct Graphics {
pub blue_map: crate::bluemap::Settings,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
/// needs a new key, as `batchTranslucent` did.
pub gpu_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Skip particles outside the view or hidden behind opaque terrain.
@@ -716,6 +767,7 @@ impl Default for Graphics {
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
gpu_culling: false,
entity_culling: EntityCullingMode::Off,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
@@ -812,6 +864,7 @@ impl Graphics {
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
@@ -906,7 +959,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -959,6 +1012,7 @@ impl Graphics {
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
bool_text(self.gpu_culling),
);
text += &self.blue_map.serialize();
text
@@ -1097,6 +1151,7 @@ mod tests {
servers: Vec::new(),
},
cave_culling: false,
gpu_culling: true,
entity_culling: EntityCullingMode::Synchronous,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
@@ -1183,6 +1238,9 @@ mod tests {
assert!(Graphics::parse("hdr:true\n").wide_frame());
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// GPU Culling is opt-in: only the file or its switch turns it on.
assert!(!Graphics::default().gpu_culling);
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
@@ -65,6 +65,7 @@ pub enum H {
BlueMapAutoDetect,
BlueMapRadius,
CaveCulling,
GpuCulling,
EntityCulling,
ParticleCulling,
LeafCulling,
@@ -377,6 +378,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
Saves,
"Skips terrain hidden behind closed sections. Off costs a lot.",
),
h(
H::GpuCulling,
Saves,
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
),
h(
H::EntityCulling,
None,
@@ -551,15 +557,15 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
],
),
];
// Only Windows and Linux have more than one graphics API, and translucent batching
// (Metal draws flat).
// Only Windows and Linux have more than one graphics API, translucent batching (Metal
// draws flat) and GPU culling (Metal draws no count the GPU wrote).
if cfg!(target_os = "macos") {
for (_, groups) in &mut pages {
for (_, rows) in groups {
rows.retain(|r| {
!matches!(
r.item,
Item::Horizon(H::GraphicsApi | H::TranslucentBatching)
Item::Horizon(H::GraphicsApi | H::TranslucentBatching | H::GpuCulling)
)
});
}
@@ -748,6 +754,12 @@ impl H {
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
// Greyed out where it isn't offered (`Item::blocked`): Off, as it is there, while
// the saved value waits.
H::GpuCulling => labelled(
"GPU Culling",
on_off(g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()),
),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
@@ -904,6 +916,7 @@ impl H {
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::GpuCulling => g.gpu_culling = !g.gpu_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
@@ -993,7 +1006,8 @@ impl Item {
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1030,6 +1044,7 @@ impl Item {
{
Some("Your GPU or graphics API doesn't support it.")
}
Item::Horizon(H::GpuCulling) => crate::graphics::gpu_culling_offer().reason(),
_ => None,
}
}
@@ -1045,6 +1060,9 @@ impl Item {
Some(match self {
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::GpuCulling) => {
g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()
}
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
Item::Horizon(H::CacheSigns) => g.cache_signs,
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
@@ -1134,6 +1152,7 @@ impl Item {
| H::GraphicsApi
| H::TranslucentBatching
| H::HdrSmoothGradients
| H::GpuCulling
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1689,6 +1708,9 @@ impl GraphicsUi {
crate::widgets::widget_cursor(hovered(rect), true);
crate::widgets::target(rect);
let primary = target == Target::Done;
if primary {
crate::widgets::declare_action(crate::widgets::Action::Done, rect, true);
}
style::button(batch, atlas, rect, &label, (hover, true, primary));
}
}
@@ -3212,6 +3234,70 @@ mod tests {
}
}
/// GPU Culling: a switch right after Cave Culling, off by default, no part of a preset,
/// and greyed out with its reason, reading Off, wherever it isn't offered.
#[test]
fn gpu_culling_is_a_culling_switch_where_it_is_offered() {
use crate::graphics::GpuCullingOffer;
let item = Item::Horizon(H::GpuCulling);
let at = |item| items().into_iter().position(|(_, i)| i == item);
if cfg!(target_os = "macos") {
// Metal draws no count the GPU wrote: never offered, so not shown.
assert_eq!(at(item), None);
return;
}
let i = at(item).unwrap();
assert_eq!(items()[i].0, Page::Performance);
assert_eq!(at(Item::Horizon(H::CaveCulling)), Some(i - 1));
let mut s = Settings::default();
assert_eq!(item.switch(&s), Some(false));
s.horizon.preset = Preset::Balanced;
s.horizon.gpu_culling = true;
// Where it isn't offered, the saved On reads Off, and neither a click nor Enter after
// it does anything.
for offer in [
GpuCullingOffer::Unsupported,
GpuCullingOffer::Software,
GpuCullingOffer::OutOfMemory,
GpuCullingOffer::Dx12,
] {
crate::graphics::offer_gpu_culling(offer);
assert!(offer.reason().is_some());
assert_eq!(item.blocked(&s), offer.reason());
assert_eq!(item.label(&s).plain(), "GPU Culling: Off");
// The box, the narrator and the widget tree read the switch.
assert_eq!(item.switch(&s), Some(false), "{offer:?}");
let mut ui = GraphicsUi::default();
let r = row(&mut ui, &s, item);
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
ui.focus = Some(Target::Item(item));
let enter = ui.key_modifiers(&mut s, K::Enter, None, false, SIZE, FB);
assert!(enter.0.is_empty());
assert!(s.horizon.gpu_culling, "{offer:?}");
}
// Where it is, a click switches it and keeps the preset.
crate::graphics::offer_gpu_culling(GpuCullingOffer::Offered);
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "GPU Culling: On");
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
let r = row(&mut ui, &s, item);
assert_eq!(
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
[Action::ButtonClick, Action::Changed]
);
assert!(!s.horizon.gpu_culling);
assert_eq!(s.horizon.preset, Preset::Balanced);
// No preset sets it.
for on in [false, true] {
s.horizon.gpu_culling = on;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.gpu_culling, on, "{preset:?}");
}
}
}
#[test]
fn leaf_culling_cycles_modes_beside_the_other_culling() {
let mut s = Settings::default();
@@ -3396,6 +3482,11 @@ mod tests {
] {
lang::get(text);
}
// Every reason GPU Culling's switch can give, whatever this device offers.
use crate::graphics::GpuCullingOffer as O;
for offer in [O::Unsupported, O::Software, O::OutOfMemory, O::Dx12] {
lang::get(offer.reason().unwrap());
}
});
// `labelled` also looks up values that are already words in the language ("An") or
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
@@ -207,6 +207,16 @@ impl Calibration {
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
let action = if rect == l.cancel {
Some(crate::widgets::Action::Cancel)
} else if rect == l.back {
Some(crate::widgets::Action::Back)
} else {
None
};
if let Some(action) = action {
crate::widgets::declare_action(action, [rect.0, rect.1, rect.2, 20.0], active);
}
ui::button(
batch,
atlas,
+144 -17
View File
@@ -256,7 +256,15 @@ pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
match ImageFormat::sniff(data) {
Some(ImageFormat::WebP) => decode_webp(data),
Some(ImageFormat::Jpeg) => decode_jpeg(data),
Some(ImageFormat::Png) | None => decode_bytes(data),
// A PNG's IHDR states the size before the decoder sizes its pixel
// buffer, so it is checked there like the WebP and JPEG headers: the
// PNG reader alone would trust the header and allocate it.
Some(ImageFormat::Png) | None => {
if let Some((width, height)) = png_bytes_size(data) {
check_fetched_size(u64::from(width), u64::from(height))?;
}
decode_bytes(data)
}
}
}
@@ -308,15 +316,19 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
.map(|p| (*p).clone())
.collect()
}
/// A PNG's width and height from its first 24 bytes (`IHDR`), before any
/// pixel buffer is made.
fn png_bytes_size(data: &[u8]) -> Option<(u32, u32)> {
let header = data.get(..24)?;
if header[..8] != *b"\x89PNG\r\n\x1a\n" || header[12..16] != *b"IHDR" {
return None;
}
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
Some((field(16), field(20)))
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
.then(|| (field(16), field(20)))
png_bytes_size(&horizon_core::prepared_files::read_head(path, 24).ok()?)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
// A prepared root's folder: from its data file, without listing the disk.
@@ -1928,7 +1940,7 @@ impl HudBatch {
color: u32,
shadow: bool,
) -> f32 {
self.styled_text(atlas, text, x, y, color, shadow, false)
self.styled_text(atlas, text, x, y, color, shadow, false, None)
}
/// `text_styled` for text drawn in the world (`TextRenderable.render`
/// with `flat` false): each quad's `depth` is its z in text units, the
@@ -1944,7 +1956,42 @@ impl HudBatch {
color: u32,
shadow: bool,
) -> f32 {
self.styled_text(atlas, text, x, y, color, shadow, true)
self.styled_text(atlas, text, x, y, color, shadow, true, None)
}
/// `Font.drawInBatch8xOutline`: eight glyph offsets in the outline color,
/// then the original text. Returns (advance, first front-text vertex), so
/// world rendering can use POLYGON_OFFSET for the front and NORMAL for
/// its outline. Offsets use each glyph's own shadow offset (unihex: 0.5).
pub fn text_styled_outline_world(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
x: f32,
y: f32,
color: u32,
outline: u32,
) -> (f32, usize) {
for dx in -1..=1 {
for dy in -1..=1 {
if dx != 0 || dy != 0 {
self.styled_text(
atlas,
text,
x,
y,
color,
false,
true,
Some((outline, [dx as f32, dy as f32])),
);
}
}
}
let front = self.vertices.len();
(
self.styled_text(atlas, text, x, y, color, false, true, None),
front,
)
}
#[allow(clippy::too_many_arguments)]
fn styled_text(
@@ -1956,6 +2003,7 @@ impl HudBatch {
color: u32,
shadow: bool,
world: bool,
outline: Option<(u32, [f32; 2])>,
) -> f32 {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
@@ -2023,10 +2071,11 @@ impl HudBatch {
}
self.patches.extend(font.take_patches());
let white = font.white();
let has_shadow = shadow
|| glyphs
.iter()
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
let has_shadow = outline.is_none()
&& (shadow
|| glyphs
.iter()
.any(|glyph| glyph.3.is_some_and(|color| color != 0)));
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
let shade = |c: u32| {
@@ -2061,8 +2110,9 @@ impl HudBatch {
} else {
shade(*c)
};
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|| custom_shadow.is_none() && shadow)
let has_shadow = outline.is_none()
&& (custom_shadow.is_some_and(|argb| argb != 0)
|| custom_shadow.is_none() && shadow)
&& !(world && shadow_color == 0);
if is_shadow && !has_shadow {
continue;
@@ -2072,7 +2122,11 @@ impl HudBatch {
} else {
(0.0, *c)
};
let (px, py) = (*gx + dx, y + dx);
let (px, py, col) = if let Some((color, [dx, dy])) = outline {
(*gx + dx * g.offset, y + dy * g.offset, color)
} else {
(*gx + dx, y + dx, col)
};
if let Some(object) = object {
// `PlainTextRenderable.render`: sprite glyphs use the same
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
@@ -2916,6 +2970,41 @@ mod tests {
assert!(check_fetched_size(16_383, 16_383).is_err());
}
/// A fetched PNG is refused by the size its own header claims, before the
/// reader makes the pixel buffer: a few hundred bytes of payload would
/// otherwise ask for 64 MiB or much more.
#[test]
fn a_fetched_png_is_refused_by_the_size_its_header_claims() {
let mut encoded = Vec::new();
{
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[7; 16]).unwrap();
writer.finish().unwrap();
}
assert!(matches!(
ImageFormat::sniff(&encoded),
Some(ImageFormat::Png)
));
assert_eq!(
decode_image_bytes(&encoded).expect("a small PNG decodes").0,
2
);
// The same image, its IHDR claiming one pixel row past the limit: the
// CRC is ignored on purpose, so this stands in for a hostile header.
let ihdr = encoded.windows(4).position(|w| w == b"IHDR").expect("IHDR");
encoded[ihdr + 4..ihdr + 8].copy_from_slice(&4097u32.to_be_bytes());
encoded[ihdr + 8..ihdr + 12].copy_from_slice(&4096u32.to_be_bytes());
let refused = decode_image_bytes(&encoded).expect_err("the size is refused");
assert!(
refused.to_string().contains("too large to decode"),
"{refused}"
);
}
/// Only the network path reads WebP and JPEG: a resource pack's texture
/// stays PNG-only, as vanilla's `NativeImage.read` is.
#[test]
@@ -3819,6 +3908,44 @@ texture=wide",
assert_eq!(fonts.font.width("abba"), width);
}
#[test]
fn outlined_world_text_keeps_font_metrics_and_overrides_outline_color() {
let root = two_glyph_font("world-outline");
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let mut batch = HudBatch::new([1, 1]);
let text = crate::text::Text::colored("ab", 0xff0000);
let outline = rgba(1, 2, 3, 128);
let (advance, front) =
batch.text_styled_outline_world(&atlas, &text, 10.0, 20.0, u32::MAX, outline);
assert_eq!((advance, front, batch.vertices.len()), (10.0, 96, 108));
assert!(batch.vertices[..front].iter().all(|v| v.color == outline));
assert!(
batch.vertices[front..]
.iter()
.all(|v| v.color == rgba(255, 0, 0, 255))
);
// Eight independently offset silhouettes, retaining both glyphs and
// the bitmap font's advance (a=4, b=6); front text has no shadow.
for (i, [dx, dy]) in [
[-1.0, -1.0],
[-1.0, 0.0],
[-1.0, 1.0],
[0.0, -1.0],
[0.0, 1.0],
[1.0, -1.0],
[1.0, 0.0],
[1.0, 1.0],
]
.into_iter()
.enumerate()
{
let at = batch.vertices[i * 12].pos;
let main = batch.vertices[front].pos;
assert_eq!(at, [main[0] + dx, main[1] + dy]);
assert_eq!(batch.vertices[i * 12 + 6].pos[0] - at[0], 4.0);
}
}
#[test]
fn obfuscated_glyphs_keep_the_width() {
let root = two_glyph_font("obfuscated");
let fonts = HudAtlas::fonts(&root).unwrap();
+3 -1
View File
@@ -354,7 +354,9 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
if !fluid(top) {
spans.push(ground(top));
} else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) {
// Shaded water is a span per light level; the bed is under all of it.
// The bed is under all of the water, however many spans the water
// takes: a chunk's own columns shade it a span a light level, a
// previewed far column one span for the run.
spans.push(ground(under));
spans.push(Span {
min: under.max,
+19 -3
View File
@@ -3624,9 +3624,10 @@ impl OptionsUi {
true,
);
}
ui::button(
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
w / 2.0 - 100.0,
h - 28.0,
200.0,
@@ -3780,9 +3781,24 @@ impl OptionsUi {
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
WKind::Button { label, active, act } => {
let label = fitted.as_ref().map_or(label, |f| &f.0);
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
if matches!(act, Act::Done) {
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
wd.x,
wd.y,
wd.w,
wd.h,
label,
*active,
hover,
);
} else {
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
}
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
@@ -1161,6 +1161,11 @@ impl PackScreen {
d::fill(out, 0, 0, self.width, TOPBAR_H, SIDEBAR_BG);
let (bx, by) = self.back_button();
let back_hover = d::inside(mouse, bx, by, BACK_W, BACK_H);
crate::widgets::declare_action(
crate::widgets::Action::Back,
[bx as f32, by as f32, BACK_W as f32, BACK_H as f32],
true,
);
d::button(
out,
m,
@@ -1377,6 +1382,13 @@ impl PackScreen {
let hover = d::inside(mouse, bx, by, 150, 20);
// The cursor follows the mouse; the highlight also keyboard focus.
crate::widgets::widget_cursor(hover, true);
if i == 1 && self.browser.is_none() {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[bx as f32, by as f32, 150.0, 20.0],
true,
);
}
d::button(
out,
m,
+223 -26
View File
@@ -709,15 +709,24 @@ pub fn lookup(
];
for (kind, url) in urls {
let Some(url) = url else { continue };
// registerTexture computes the hash first; a bad URL fails the lookup.
let hash = texture_hash(url)?;
// registerTexture computes the hash first. A cape or elytra URL the
// hash cannot take is that texture's problem alone: vanilla's lookup
// goes on to the next, and only the skin texture decides the default.
let Some(hash) = texture_hash(url) else {
if kind == Kind::Skin {
return None;
}
continue;
};
if kind == Kind::Skin {
skin = Some(skin_location(&hash));
}
match download(kind, url) {
Download::Failed => return None,
// A cape or elytra that cannot be fetched is simply not drawn; only
// the skin itself falls back to the default one (`SkinManager.get`).
Download::Failed if kind == Kind::Skin => return None,
Download::Failed | Download::Ready => {}
Download::Pending => state = Download::Pending,
Download::Ready => {}
}
}
if state != Download::Ready || require_secure && !textures.signed {
@@ -809,6 +818,12 @@ pub struct Downloader {
resource_root: std::path::PathBuf,
/// How each `patch/` texture of the resource packs loaded, by location.
resources: std::collections::HashMap<String, Download>,
/// When each failed texture download was last tried, so it can be retried:
/// a transient network error must not pin a player to the default skin for
/// the rest of the session.
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
/// How long a failed texture download waits before it is tried again.
retry_after: std::time::Duration,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -839,6 +854,8 @@ impl Downloader {
drawn: Default::default(),
resource_root: Default::default(),
resources: Default::default(),
failed: Default::default(),
retry_after: std::time::Duration::from_secs(60),
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
@@ -879,7 +896,8 @@ impl Downloader {
};
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time.
/// How the download of `url` stands, starting it the first time. A
/// failure is permanent for `retry_after`, then tried again.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
let state = match image {
@@ -889,9 +907,13 @@ impl Downloader {
Kind::Cape => self.queue(cape_location(&key.1), rgba),
Kind::Elytra => {}
}
self.failed.remove(&key);
Download::Ready
}
None => Download::Failed,
None => {
self.failed.insert(key.clone(), std::time::Instant::now());
Download::Failed
}
};
self.states.insert(key, state);
}
@@ -899,34 +921,57 @@ impl Downloader {
return Download::Failed;
};
let key = (kind, hash);
if let Some(state) = self.states.get(&key) {
return *state;
if let Some(state) = self.states.get(&key).copied() {
let due = self
.failed
.get(&key)
.is_none_or(|at| at.elapsed() >= self.retry_after);
if state != Download::Failed || !due {
return state;
}
self.states.remove(&key);
self.failed.remove(&key);
}
self.states.insert(key.clone(), Download::Pending);
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
let file = self
.root
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
.join(&digest);
let (url, send) = (url.to_owned(), self.send.clone());
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
std::thread::spawn(move || {
let bytes = std::fs::read(&file).ok().or_else(|| {
let body = http_get(&url)?;
let _ = std::fs::create_dir_all(file.parent()?);
let _ = std::fs::write(&file, &body);
Some(body)
});
let image = bytes
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| match kind {
Kind::Skin => process_legacy_skin(w, h, &rgba),
Kind::Cape => process_cape(w, h, &rgba),
Kind::Elytra => Some(rgba),
});
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
let _ = send.send((key, image));
});
Download::Pending
}
/// One texture's pixels: the disk cache's body if it decodes, else a fetch
/// (`SkinTextureDownloader.downloadSkin`). Only a body that decodes is
/// written to the cache: an error page or a half image must not be what a
/// later start reads instead of fetching again.
fn load_texture(
root: &std::path::Path,
kind: Kind,
digest: &str,
url: &str,
fetch: impl FnOnce(&str) -> Option<Vec<u8>>,
) -> Option<Vec<u8>> {
let file = root
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
.join(digest);
let decode = |bytes: &[u8]| -> Option<Vec<u8>> {
let (w, h, rgba) = crate::hud::decode_bytes(bytes).ok()?;
match kind {
Kind::Skin => process_legacy_skin(w, h, &rgba),
Kind::Cape => process_cape(w, h, &rgba),
Kind::Elytra => Some(rgba),
}
};
if let Some(image) = std::fs::read(&file).ok().and_then(|b| decode(&b)) {
return Some(image);
}
let body = fetch(url)?;
let image = decode(&body)?;
let _ = std::fs::create_dir_all(file.parent()?);
let _ = std::fs::write(&file, &body);
Some(image)
}
/// Keeps a decoded skin for `again`, dropping the oldest past the bound.
fn remember(&mut self, location: String, rgba: Vec<u8>) {
if !self.images.contains_key(&location) {
@@ -1516,4 +1561,156 @@ mod tests {
"abc"
);
}
/// A cape or elytra that cannot be fetched takes only itself out of the
/// picture: the player's skin still shows. Only the skin texture's own
/// failure is the default skin.
#[test]
fn a_failed_cape_does_not_hide_the_skin() {
let textures = super::Textures {
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
elytra: None,
signed: true,
slim: false,
};
let skin = super::lookup(&textures, true, |kind, _| match kind {
super::Kind::Skin => super::Download::Ready,
_ => super::Download::Failed,
});
assert_eq!(skin, Some(super::skin_location("skin1")));
// A cape URL the hash cannot take is the same: the skin shows.
let bad_url = super::Textures {
cape: Some("no-scheme".into()),
..textures.clone()
};
assert_eq!(
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
Some(super::skin_location("skin1"))
);
// The skin itself failing is still the default skin.
assert_eq!(
super::lookup(&textures, true, |_, _| super::Download::Failed),
None
);
}
/// A 1x1 PNG of `rgba`.
fn png(rgba: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&rgba).unwrap();
writer.finish().unwrap();
out
}
/// A downloaded body reaches the disk cache only if it decodes; a broken
/// cached file is fetched again and replaced. A failed fetch writes nothing.
#[test]
fn the_texture_cache_holds_only_what_decodes() {
let root =
std::env::temp_dir().join(format!("horizon-test-skin-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let body = png([7, 8, 9, 255]);
let file = root.join("ab").join("abcdef");
let load = |fetched: Option<&[u8]>| {
let fetched = fetched.map(<[u8]>::to_vec);
super::Downloader::load_texture(
&root,
super::Kind::Elytra,
"abcdef",
"https://example/texture/abc.png",
|_| fetched.clone(),
)
};
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
assert_eq!(std::fs::read(&file).unwrap(), body);
// From then on the cache answers; no fetch is needed.
assert_eq!(load(None), Some(vec![7, 8, 9, 255]));
// A broken file is fetched again and replaced, not kept.
std::fs::write(&file, b"<html>not a png</html>").unwrap();
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
assert_eq!(std::fs::read(&file).unwrap(), body);
// A body that does not decode is not cached at all.
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(load(Some(b"<html>not a png</html>")), None);
assert!(!file.exists());
}
fn wait_state(
d: &mut super::Downloader,
kind: super::Kind,
url: &str,
want: super::Download,
) -> super::Download {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = d.state(kind, url);
if state == want || std::time::Instant::now() > deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// A texture download that failed is tried again after the wait, instead of
/// pinning the player to the default skin for the rest of the session.
#[test]
fn a_failed_download_is_retried_after_the_wait() {
use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let body = png([1, 2, 3, 4]);
let served = std::sync::Arc::new(AtomicUsize::new(0));
let count = served.clone();
std::thread::spawn(move || {
for (i, stream) in listener.incoming().take(2).enumerate() {
let mut stream = stream.unwrap();
let mut line = String::new();
let mut reader = BufReader::new(stream.try_clone().unwrap());
reader.read_line(&mut line).unwrap();
while reader.read_line(&mut String::new()).unwrap() > 2 {}
count.fetch_add(1, Ordering::SeqCst);
if i == 0 {
stream
.write_all(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.unwrap();
} else {
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
}
}
});
let root =
std::env::temp_dir().join(format!("horizon-test-skin-retry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut d = super::Downloader::new(root);
let url = format!("{base}/texture/retry.png");
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
assert_eq!(
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Failed),
super::Download::Failed
);
// Within the wait the failure stands: no request goes out.
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Failed);
assert_eq!(served.load(Ordering::SeqCst), 1);
// After it, the download is started again and takes the good answer.
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
assert_eq!(
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Ready),
super::Download::Ready
);
assert_eq!(served.load(Ordering::SeqCst), 2);
}
}
@@ -81,6 +81,10 @@ impl SoundEntity {
pub enum Ticking {
/// Not tickable.
No,
/// A compiled native owner updates this instance through `instance_mut`.
/// The ordinary engine applies gain, pitch and position each unpaused tick;
/// no vanilla entity behavior overwrites them. Stop and release when done.
Native,
/// `EntityBoundSoundInstance`.
EntityBound,
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
@@ -611,7 +615,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No | Ticking::MusicLayer => {}
Ticking::No | Ticking::Native | Ticking::MusicLayer => {}
Ticking::EntityBound => {
let entity = self
.bound_entity
@@ -1552,6 +1556,8 @@ pub mod test_backend {
next: ChannelId,
pub stopped: HashMap<ChannelId, bool>,
pub volume: HashMap<ChannelId, f32>,
pub pitch: HashMap<ChannelId, f32>,
pub position: HashMap<ChannelId, [f64; 3]>,
}
impl Backend for Null {
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
@@ -1562,13 +1568,17 @@ pub mod test_backend {
fn destroy(&mut self, c: ChannelId) {
self.stopped.remove(&c);
}
fn set_pitch(&mut self, _: ChannelId, _: f32) {}
fn set_pitch(&mut self, c: ChannelId, value: f32) {
self.pitch.insert(c, value);
}
fn set_volume(&mut self, c: ChannelId, v: f32) {
self.volume.insert(c, v);
}
fn set_attenuation(&mut self, _: ChannelId, _: Option<f32>) {}
fn set_looping(&mut self, _: ChannelId, _: bool) {}
fn set_position(&mut self, _: ChannelId, _: [f64; 3]) {}
fn set_position(&mut self, c: ChannelId, value: [f64; 3]) {
self.position.insert(c, value);
}
fn set_relative(&mut self, _: ChannelId, _: bool) {}
fn start(&mut self, _: ChannelId, _: &Sound, _: bool) {}
fn stop(&mut self, c: ChannelId) {
@@ -1586,6 +1596,76 @@ pub mod test_backend {
mod tests {
use super::*;
#[test]
fn native_owned_loop_applies_updates_and_stops_through_the_ordinary_engine() {
let mut registry = SoundRegistry::default();
registry.add_file(
"example",
br#"{"loop":{"sounds":["machine/loop"]}}"#,
&|_, _| true,
);
let mut engine = SoundEngine::new(registry);
let mut backend = test_backend::Null::default();
let mut instance = SoundInstance::new(
"example:loop",
4,
0.1,
0.5,
LegacyRandom::new(1),
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
instance.ticking = Ticking::Native;
let (id, result) = engine.play_new(instance, true, &mut backend);
assert_eq!(result, PlayResult::Started);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.volume[&channel], 0.1);
let owned = engine.instance_mut(id).unwrap();
owned.volume = 0.4;
owned.pitch = 1.25;
owned.position = [2.0, 3.0, 4.0];
engine.tick(true, &TickContext::default(), &mut backend);
assert_eq!(
backend.volume[&channel], 0.1,
"paused world sounds retain channel values"
);
assert_eq!(backend.pitch[&channel], 0.5);
assert_eq!(backend.position[&channel], [0.0; 3]);
engine.tick(false, &TickContext::default(), &mut backend);
assert_eq!(backend.volume[&channel], 0.4);
assert_eq!(backend.pitch[&channel], 1.25);
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
let owned = engine.instance(id).unwrap();
assert_eq!((owned.pitch, owned.position), (1.25, [2.0, 3.0, 4.0]));
let replacement = owned.clone();
assert!(engine.is_active(id));
engine.stop(id, &mut backend);
assert_eq!(backend.stopped.get(&channel), Some(&true));
engine.tick(false, &TickContext::default(), &mut backend);
engine.release(id);
for _ in 0..20 {
engine.tick(false, &TickContext::default(), &mut backend);
}
assert!(engine.instance(id).is_none());
assert!(!engine.is_active(id));
assert!(!backend.stopped.contains_key(&channel));
let (id, result) = engine.play_new(replacement, true, &mut backend);
assert_eq!(result, PlayResult::Started);
engine.stop_all(&mut backend);
assert!(!engine.is_active(id));
assert!(backend.stopped.is_empty());
assert!(engine.instance(id).is_some(), "the owner still retains it");
engine.release(id);
assert!(engine.instance(id).is_none());
assert_eq!(engine.census()["playing"], 0);
assert_eq!(engine.census()["ticking"], 0);
assert_eq!(engine.census()["handles"], 0);
}
#[test]
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
let ghast = RiddenSound {
@@ -351,6 +351,14 @@ impl SplitProfiles {
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
}
let action = match target {
Target::Keep | Target::Cancel => Some(crate::widgets::Action::Cancel),
Target::Back => Some(crate::widgets::Action::Back),
_ => None,
};
if let Some(action) = action {
crate::widgets::declare_action(action, [*x, *y, *bw, *bh], *enabled);
}
ui::button(batch, atlas, *x, *y, *bw, *bh, &text, *enabled, hover);
if remove {
crate::widgets::record(label, [*x, *y, *bw, *bh], *enabled);
@@ -691,6 +691,16 @@ impl SplitUi {
Target::Three => Some(layout_glyph(true, d.three)),
_ => None,
});
let action = match target {
Target::Apply => Some(crate::widgets::Action::Apply),
Target::Done => Some(crate::widgets::Action::Done),
_ => None,
};
if let Some(action) = action {
// Off during a hold: every button goes to the held player.
let enabled = *active && self.held.is_none();
crate::widgets::declare_action(action, [x, y, bw, bh], enabled);
}
// The selected player's name in yellow: picked, not just under the pointer.
let text = if glyph.is_some() {
Text::literal("")
@@ -1499,6 +1509,30 @@ mod tests {
assert!(ui.give_up_pad);
}
#[test]
fn done_is_declared_off_while_a_player_is_held() {
use crate::widgets::{Action, end_frame, last_actions, set_action_screen};
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut ui = screen(2);
ui.size = SIZE;
let done = |ui: &SplitUi, frame: u64| {
let mut batch = HudBatch::new([427, 300]);
ui.draw(&mut batch, &atlas, (0.0, 0.0), false);
set_action_screen(9);
end_frame(frame);
last_actions(9)
.into_iter()
.find(|a| a.action == Action::Done)
.map(|a| a.enabled)
};
assert_eq!(done(&ui, 9001), Some(true));
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert_eq!(done(&ui, 9002), Some(false));
assert!(ui.pad_button(PadButton::East));
assert_eq!(done(&ui, 9003), Some(true));
}
#[test]
fn b_puts_a_held_player_back() {
let mut ui = screen(2);
+85 -2
View File
@@ -543,6 +543,38 @@ pub fn button(
crate::widgets::Narratable::button(label.plain(), rect, active)
});
}
/// A [`button`] with a job on its screen: its controller button and glyph come from `action`, so
/// a new screen gets them by drawing this for its Done, Back, Cancel or Apply.
#[allow(clippy::too_many_arguments)]
pub fn action_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
action: crate::widgets::Action,
x: f32,
y: f32,
w: f32,
h: f32,
label: &Text,
active: bool,
hover: bool,
) {
crate::widgets::declare_action(action, [x, y, w, h], active);
let room = if active {
crate::widgets::action_glyph_room()
} else {
0.0
};
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
room,
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
);
}
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
/// `LockIconButton`): `narration` builds what it records from its rectangle,
/// and only while the narrator records.
@@ -558,6 +590,29 @@ pub fn button_as(
active: bool,
hover: bool,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
0.0,
narration,
);
}
/// A button whose label keeps `room` free at its left.
#[allow(clippy::too_many_arguments)]
fn button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
[x, y, w, h]: [f32; 4],
label: &Text,
active: bool,
hover: bool,
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
@@ -575,15 +630,18 @@ pub fn button_as(
} else {
rgba(160, 160, 160, 255)
};
scrolling_text(
// Centred on the button, as the title's are, and only moved out from under a glyph.
scrolling_text_around_at(
batch,
atlas,
label,
x + 2.0,
x + (w / 2.0).floor(),
x + 2.0 + room,
x + w - 2.0,
y,
y + h,
y + ((h - 8.0) / 2.0).floor(),
crate::hud_overlays::now_ms(),
color,
);
}
@@ -1740,6 +1798,31 @@ mod tests {
/// The label of a widget it does not fit is clipped to the widget's box
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
/// label ran over the button beside it).
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
crate::widgets::set_action_glyphs(true);
let label = Text::literal("Done");
let mut plain = HudBatch::new([640, 480]);
button(
&mut plain, &atlas, 10.0, 20.0, 150.0, 20.0, &label, true, false,
);
let mut declared = HudBatch::new([640, 480]);
action_button(
&mut declared,
&atlas,
crate::widgets::Action::Done,
10.0,
20.0,
150.0,
20.0,
&label,
true,
false,
);
assert_eq!(quads(&plain), quads(&declared));
}
#[test]
fn a_label_wider_than_its_widget_stays_inside_it() {
let atlas = atlas();
+114 -2
View File
@@ -96,6 +96,12 @@ thread_local! {
/// on-screen keyboard's keys), labelled or not: where a controller can move focus. Drawn since
/// `end_frame`, the last frame's, and how many frames rotated.
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
/// The buttons drawn since the last `end_frame` that declared their job, and the last frame's.
static ACTIONS: RefCell<(Vec<DeclaredAction>, Vec<DeclaredAction>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The pad drives, so declared buttons show their glyph (labels leave it room).
static ACTION_GLYPHS: Cell<bool> = const { Cell::new(false) };
/// The screen the buttons in `ACTIONS` were declared on, the same two frames.
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -104,17 +110,94 @@ thread_local! {
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
(Vec<DeclaredAction>, Vec<DeclaredAction>),
bool,
(u64, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
Saved(
WIDGETS.take(),
TARGETS.take(),
ACTIONS.take(),
ACTION_GLYPHS.take(),
ACTION_SCREENS.take(),
)
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
}
/// A button's job on its screen. A screen declares it ([`crate::ui::action_button`]) and the
/// controller's button, its glyph and its place on every pad follow (docs/controllers.md,
/// "Adding a screen").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Leaves the screen, keeping what was set.
Done,
/// Goes back one screen.
Back,
/// Leaves the screen, dropping what was set.
Cancel,
/// Applies what was set and stays or leaves as the screen does.
Apply,
/// Opens the options.
Options,
/// Leaves the game or the world: the pad moves the focus here, and A confirms.
Exit,
}
/// A button that declared its job, as last drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DeclaredAction {
pub action: Action,
pub rect: [f32; 4],
pub enabled: bool,
}
/// `rect` is the button doing `action` on this screen; see [`crate::ui::action_button`], for a
/// screen that draws its buttons itself.
pub fn declare_action(action: Action, rect: [f32; 4], enabled: bool) {
ACTIONS.with_borrow_mut(|(current, _)| {
if current.len() < 16 {
current.push(DeclaredAction {
action,
rect,
enabled,
});
}
});
}
/// The buttons of the last drawn frame that declared their job, if it drew `screen`: until the
/// screen just opened is drawn, the one it replaced can't be pressed.
pub fn last_actions(screen: u64) -> Vec<DeclaredAction> {
if ACTION_SCREENS.get().1 != screen {
return Vec::new();
}
ACTIONS.with_borrow(|(_, last)| last.clone())
}
/// The frame being drawn shows `screen` (the controller's own key for it).
pub fn set_action_screen(screen: u64) {
ACTION_SCREENS.set((screen, ACTION_SCREENS.get().1));
}
/// Whether declared buttons are showing their glyph: only while the pad drives.
pub fn set_action_glyphs(on: bool) {
ACTION_GLYPHS.set(on);
}
/// How much of a declared button's left the glyph takes from its label.
pub fn action_glyph_room() -> f32 {
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
@@ -225,6 +308,10 @@ pub fn end_frame(frames: u64) {
*last = std::mem::take(current);
*generation += 1;
});
ACTIONS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
ACTION_SCREENS.set((ACTION_SCREENS.get().0, ACTION_SCREENS.get().0));
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -700,8 +787,10 @@ pub fn narrating() -> bool {
/// The screen being drawn: what it narrates and which one it is. The
/// narratables recorded before it in the frame are dropped: the last screen
/// drawn (an overlay over the screen under it) is the one narrated.
/// drawn (an overlay over the screen under it) is the one narrated. So are the
/// jobs declared before it: a popup's buried buttons can't be pressed.
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
current.screen = Some(info());
@@ -1153,6 +1242,29 @@ mod tests {
assert_eq!(slug("Économie"), "économie");
}
#[test]
fn declared_buttons_are_only_those_of_the_screen_drawn() {
let done = |x| declare_action(Action::Done, [x, 0.0, 5.0, 5.0], true);
done(0.0);
set_action_screen(7);
end_frame(200);
assert_eq!(last_actions(7).len(), 1);
// Another screen opened and hasn't been drawn yet.
assert!(last_actions(8).is_empty());
}
#[test]
fn a_popup_drops_the_buttons_declared_under_it() {
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
begin_screen(|| ScreenInfo::new("popup", ""));
declare_action(Action::Cancel, [9.0, 0.0, 5.0, 5.0], true);
set_action_screen(7);
end_frame(300);
let actions = last_actions(7);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].action, Action::Cancel);
}
#[test]
fn last_lists_buttons_only() {
end_frame(100);
@@ -94,6 +94,7 @@
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
"GPU Culling": "GPU Culling",
"Entity Culling": "Entity Culling",
"Chunk Updates": "Chunk Updates",
"Upload Budget": "Upload Budget",
@@ -158,6 +159,9 @@
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Not on DirectX 12 yet: try the Vulkan graphics API.",
"%s chunks": "%s chunks",
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
@@ -180,6 +184,7 @@
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
"No entity occlusion culling.": "No entity occlusion culling.",
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
@@ -338,6 +343,7 @@
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.relay_stopped": "Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
@@ -1130,6 +1130,9 @@ pub fn main() -> horizon_client::Result<()> {
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
head: None,
chest: None,
legs: None,
feet: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
+75
View File
@@ -605,6 +605,10 @@ pub trait World {
fn light(&self, pos: [i32; 3]) -> i32;
fn smokey(&self, pos: [i32; 3]) -> bool;
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool;
/// The block entity's `saveWithFullMetadata` at `pos`, `None` without one.
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
None
}
}
/// An item stack as predicates see it.
#[derive(Clone, Debug, Default)]
@@ -853,6 +857,16 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
}
}
}
// `matchesBlockEntity`: the block entity's data holds the pattern
// (`NbtPredicate.matches`, `NbtUtils.compareNbt`); no block entity fails.
if let Some(nbt) = v["nbt"].as_str() {
let Ok((pattern, _)) = horizon_world::nbt::parse_snbt(nbt) else {
return false;
};
if !crate::nbt_path::compare_nbt(&pattern, w.block_entity_nbt(pos).as_ref()) {
return false;
}
}
true
}
/// `LocationPredicate`.
@@ -1810,6 +1824,67 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// `BlockPredicate.matchesBlockEntity`: an `nbt` test needs a block
/// entity whose `saveWithFullMetadata` contains the pattern
/// (`NbtPredicate.matches`), so it never matches a plain block.
#[test]
fn a_block_predicates_nbt_needs_the_block_entitys_data() {
let w = Flat;
let stone = serde_json::json!({"blocks": "minecraft:stone", "nbt": "{}"});
assert!(!block(&stone, [0, 0, 0], &w), "stone has no block entity");
assert!(block(
&serde_json::json!({"blocks": "minecraft:stone"}),
[0, 0, 0],
&w
));
/// An unpowered beacon: its saved data has `Levels:0`.
struct Beacon;
impl World for Beacon {
fn dimension(&self) -> &str {
Flat.dimension()
}
fn weather(&self) -> (bool, bool) {
Flat.weather()
}
fn biome(&self, p: [i32; 3]) -> String {
Flat.biome(p)
}
fn structures(&self, p: [i32; 3]) -> Vec<String> {
Flat.structures(p)
}
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
("minecraft:beacon".into(), BTreeMap::new())
}
fn fluid(&self, _: [i32; 3]) -> Option<String> {
None
}
fn can_see_sky(&self, _: [i32; 3]) -> bool {
true
}
fn light(&self, _: [i32; 3]) -> i32 {
15
}
fn smokey(&self, _: [i32; 3]) -> bool {
false
}
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
false
}
fn block_entity_nbt(&self, _: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
horizon_world::nbt::parse_snbt(r#"{id:"minecraft:beacon",x:0,y:0,z:0,Levels:0}"#)
.ok()
.map(|(t, _)| t)
}
}
let beacon = |nbt: &str| serde_json::json!({"blocks": "minecraft:beacon", "nbt": nbt});
assert!(block(&beacon("{Levels:0}"), [0, 0, 0], &Beacon));
assert!(block(&beacon("{}"), [0, 0, 0], &Beacon));
assert!(!block(&beacon("{Levels:4}"), [0, 0, 0], &Beacon));
assert!(
!block(&beacon("{Levels:0b}"), [0, 0, 0], &Beacon),
"an int, not a byte"
);
}
#[test]
fn weather_conditions_leave_absent_fields_unconstrained_and_compose() {
let ctx = Entities {
+4
View File
@@ -164,6 +164,10 @@ impl Default for Ambience {
}
}
impl Ambience {
/// Starts its random over from `seed`.
pub fn reseed(&mut self, seed: i64) {
self.random = LegacyRandom::new(seed);
}
pub fn new(seed: i64) -> Self {
Self {
random: LegacyRandom::new(seed),
+56 -6
View File
@@ -2,6 +2,18 @@
//! milliseconds turn into game ticks, partial ticks and real-time deltas, and
//! the `/tick` freeze, step and sprint state.
/// `HORIZON_STILL=1`, read once (`FixedStep::still`).
fn still_requested() -> bool {
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*STILL.get_or_init(|| {
let on = std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1");
if on {
log::warn!("HORIZON_STILL=1: worlds start frozen (/tick unfreeze), frames follow the world's ticks");
}
on
})
}
/// `DeltaTracker.Timer`: float arithmetic on integer milliseconds, exactly as
/// the client does it.
#[derive(Clone, Debug)]
@@ -311,11 +323,23 @@ pub struct FixedStep {
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
step_changes: u32,
step_requested: i32,
/// `HORIZON_STILL=1`, a dev switch for screenshots that come out the same every run: the
/// world starts frozen, and while it is, the camera's partial tick is held at 1 as the
/// level's is. With the frame's other time (`Game::visual_ticks`) following the world's
/// ticks, a frozen scene draws the same frame every time, and `/tick step` moves it on by
/// whole ticks.
still: bool,
}
impl Default for FixedStep {
fn default() -> Self {
Self {
Self::new(still_requested())
}
}
impl FixedStep {
fn new(still: bool) -> Self {
let mut clock = Self {
timer: Timer::new(20.0, 0),
manager: ServerTickRateManager::default(),
now: std::time::Duration::ZERO,
@@ -328,11 +352,16 @@ impl Default for FixedStep {
client: false,
step_changes: 0,
step_requested: 0,
}
still,
};
// Frozen before any tick: how many run while the world loads differs between runs.
clock.manager.base.set_frozen(still);
clock
}
/// `HORIZON_STILL` (`still`).
pub fn still(&self) -> bool {
self.still
}
}
impl FixedStep {
/// The clock of a client connected to a server: the `ClientLevel`'s
/// `TickRateManager`, set by the server's ticking packets.
pub fn client() -> Self {
@@ -386,8 +415,12 @@ impl FixedStep {
}
/// The camera entity's partial tick (`Camera.getCameraEntityPartialTicks`
/// for a player, who is never frozen): `getGameTimeDeltaPartialTick(true)`,
/// so the view and the hand stay smooth in a frozen level.
/// so the view and the hand stay smooth in a frozen level. Under `still`, held at 1 there
/// like the level's, so a frame doesn't depend on when it's drawn.
pub fn camera_fraction(&self) -> f64 {
if self.still && !self.runs_normally() {
return 1.0;
}
f64::from(self.timer.game_time_delta_partial_tick(true))
}
/// After a frame: whether the game is paused by a screen (the residual
@@ -555,6 +588,23 @@ mod tests {
assert_eq!(clock.camera_fraction(), 0.5, "the player does not");
}
#[test]
fn a_still_clock_starts_frozen_and_holds_the_camera_too() {
let mut clock = FixedStep::new(true);
clock.world_runs();
assert!(!clock.runs_normally(), "frozen before the first tick");
clock.advance(std::time::Duration::from_millis(25));
clock.end_frame(false);
assert_eq!(clock.fraction(), 1.0);
assert_eq!(clock.camera_fraction(), 1.0);
// `/tick unfreeze`: the world runs as without the switch.
clock.set_frozen(false);
assert!(clock.world_runs());
assert!(
FixedStep::new(false).world_runs(),
"without the switch, unfrozen"
);
}
#[test]
fn only_a_started_or_stopped_step_is_broadcast() {
let mut clock = FixedStep::default();
assert!(!clock.start_step(5), "not frozen");
@@ -67,6 +67,11 @@ impl Confirm {
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// The second button answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self) {
let (x, y) = self.buttons[1];
crate::widgets::declare_action(crate::widgets::Action::Cancel, [x, y, 150.0, 20.0], true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
@@ -183,6 +188,16 @@ impl Choice {
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// Button `i` answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self, i: usize) {
if let Some(&(x, y)) = self.buttons.get(i) {
crate::widgets::declare_action(
crate::widgets::Action::Cancel,
[x, y, self.width, 20.0],
true,
);
}
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
+29
View File
@@ -642,6 +642,19 @@ impl Dialog {
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// Which of [`Self::buttons`] does what Escape does, if Escape does anything.
pub fn escape_button(&self) -> Option<usize> {
if !self.can_close_with_escape {
return None;
}
let last = self.buttons().len().checked_sub(1);
match &self.kind {
Kind::Notice(_) | Kind::Confirmation { .. } => last,
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and(last),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
@@ -882,6 +895,22 @@ mod tests {
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn the_button_that_does_what_escape_does() {
let notice = |escape: i8| {
Dialog::parse(&Tag::compound([
("type", s("notice")),
("title", s("Hi")),
("can_close_with_escape", Tag::Byte(escape)),
]))
.unwrap()
};
assert_eq!(notice(1).escape_button(), Some(0));
assert_eq!(notice(0).escape_button(), None);
// Multi-action with no exit button: Escape has no button to be.
assert_eq!(Dialog::parse(&register()).unwrap().escape_button(), None);
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
+12
View File
@@ -1591,6 +1591,8 @@ pub struct ItemFrame {
pub fixed: bool,
pub invisible: bool,
pub check_interval: u32,
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
pub custom_name: Option<horizon_world::nbt::Tag>,
}
impl ItemFrame {
pub fn step(&self) -> [i32; 3] {
@@ -1620,6 +1622,16 @@ impl ItemFrame {
std::array::from_fn(|i| c[i] + size[i] / 2.0),
)
}
/// `ItemFrame.getFrameItemStackWithData`: `getFrameItemStack` (a glow
/// frame's own item), then `DataComponents.CUSTOM_NAME` from
/// `getCustomName`.
pub fn item_stack_with_data(&self) -> ItemStack {
let mut item_stack = ItemStack::new(self.kind(), 1);
if let Some(name) = &self.custom_name {
item_stack.data_mut().custom_name = Some(name.clone());
}
item_stack
}
}
/// An armor stand (`ArmorStand`).
#[derive(Clone, Debug)]
+44 -1
View File
@@ -1952,7 +1952,8 @@ pub fn mob_from_nbt(
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
if m.species() == crate::mobs::Species::Allay {
if let Some(Tag::List(inv)) = tag.get("Inventory") {
for stack in inv.iter().filter_map(item_from_nbt) {
@@ -3186,6 +3187,12 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
if let Some(name) = &f.custom_name {
put(&mut tag, "CustomName", name.clone());
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"CustomName".into());
}
put(&mut tag, "Facing", Tag::Byte(f.facing as i8));
put(&mut tag, "block_pos", Tag::IntArray(f.pos.to_vec()));
put(&mut tag, "ItemRotation", Tag::Byte(f.rotation as i8));
@@ -3217,6 +3224,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
fixed: flag(tag, "Fixed"),
invisible: flag(tag, "Invisible"),
check_interval: 0,
custom_name: tag.get("CustomName").cloned(),
})
}
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
@@ -6290,6 +6298,41 @@ mod fireball_nbt_tests {
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
}
#[test]
fn a_named_frame_drops_with_its_custom_name() {
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
// `DataComponents.CUSTOM_NAME` from `getCustomName`. Glow frames
// share it through `GlowItemFrame.getFrameItemStack`.
for id in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
let tag = Tag::compound([
("id", string(id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(2)),
("CustomName", string("Gallery")),
]);
let frame = item_frame_from_nbt(&tag, 1).unwrap();
let stack = frame.item_stack_with_data();
assert_eq!(&*stack.item, id);
assert_eq!(stack.count, 1);
assert_eq!(stack.custom_name().as_deref(), Some("Gallery"));
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
assert_eq!(text(saved.get("CustomName")).as_deref(), Some("Gallery"));
}
let bare = Tag::compound([
("id", string("minecraft:item_frame")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(3)),
]);
let frame = item_frame_from_nbt(&bare, 2).unwrap();
assert!(frame.custom_name.is_none());
assert!(frame.item_stack_with_data().custom_name().is_none());
assert!(
item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&bare))
.get("CustomName")
.is_none()
);
}
#[test]
fn projectile_shot_flag_round_trips_independently_of_age() {
for id in [
@@ -244,6 +244,13 @@ pub fn draw(
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
+57 -10
View File
@@ -214,15 +214,12 @@ mod tests {
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
}
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
/// data root holding that one recipe and an item table with two fuels: `test:coal`
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
let root = std::env::temp_dir().join(format!(
"horizon-furnace-remainder-{}-{}-{count}",
std::process::id(),
fuel.replace(':', "-")
));
/// A data root holding one recipe, `test:ore` into `test:ingot`, and an item table
/// with two fuels: `test:coal` (no remainder) and `test:cell` (a content mod's item
/// that leaves `test:husk`). The folder is gone when this returns.
fn data(name: &str) -> (Items, Recipes, Providers) {
let root =
std::env::temp_dir().join(format!("horizon-furnace-{name}-{}", std::process::id(),));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
@@ -248,6 +245,57 @@ mod tests {
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let providers = Providers::load(&root);
let _ = fs::remove_dir_all(&root);
(items, recipes, providers)
}
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
/// real tick on every idle case and compare, and check a furnace that can smelt
/// isn't idle and does change.
#[test]
fn an_idle_furnace_tick_changes_nothing() {
let (items, recipes, providers) = data("idle");
let stack = |id: &str| Some(ItemStack::new(id, 1));
let furnace = |input, fuel, output| Furnace {
kind: FurnaceKind::Furnace,
items: [input, fuel, output],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 200,
speed: 1.0,
recipes_used: HashMap::new(),
};
let idle = [
furnace(None, None, None),
furnace(stack("test:ore"), None, None),
furnace(None, stack("test:coal"), None),
furnace(None, None, stack("test:ingot")),
furnace(stack("test:ingot"), None, stack("test:ingot")),
];
for before in idle {
assert!(before.idle(), "{before:?}");
let mut after = before.clone();
let mut drops = Vec::new();
let lit = after.tick(&recipes, &items, &providers, &mut drops);
assert_eq!(lit, None, "{before:?}");
assert!(drops.is_empty(), "{before:?}");
assert_eq!(format!("{after:?}"), format!("{before:?}"));
}
let mut busy = furnace(stack("test:ore"), stack("test:coal"), None);
assert!(!busy.idle());
let before = format!("{busy:?}");
assert_eq!(
busy.tick(&recipes, &items, &providers, &mut Vec::new()),
Some(true)
);
assert_ne!(format!("{busy:?}"), before);
}
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot` ([`data`]).
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
let (items, recipes, providers) =
data(&format!("remainder-{}-{count}", fuel.replace(':', "-")));
let mut furnace = Furnace {
kind: FurnaceKind::Furnace,
items: [
@@ -264,7 +312,6 @@ mod tests {
};
let mut drops = Vec::new();
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
let _ = fs::remove_dir_all(&root);
assert_eq!(lit, Some(true), "the fuel lit the furnace");
(furnace, drops)
}
+119 -13
View File
@@ -247,6 +247,7 @@ mod native_shaders;
mod native_ticks;
mod native_visuals;
mod native_world_columns;
pub mod native_world_draw;
mod native_world_edit;
mod native_world_read;
mod natural_spawning;
@@ -259,6 +260,7 @@ mod options;
mod ordered_tick;
mod packs;
mod pad;
mod pad_actions;
mod pad_menu;
mod paintings;
mod pandas;
@@ -440,6 +442,7 @@ pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_draw::NativeWorldDraw;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
pub use native_world_read::NativeBlockReadError;
use std::{
@@ -526,6 +529,9 @@ struct Options {
/// `--dedicated`: run as a dedicated server; vanilla's server arguments
/// (`--nogui`, `--port`, `--world`, `--universe`, `--initSettings`) follow.
dedicated: Option<Vec<String>>,
/// `--transfers SETTINGS`: a dedicated server takes a proxy's hand-over requests
/// (`server.toml`'s `[transfer]`, which horizon-server passes; lan/arrivals.rs).
transfers: Option<horizon_client::lan::arrivals::Settings>,
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): content mods always on.
@@ -661,6 +667,7 @@ impl Options {
.ok()
.filter(|n| !n.is_empty()),
dedicated: None,
transfers: None,
benchmark: None,
dev_mods: Vec::new(),
execution_role: execution_role::ExecutionRole::Client,
@@ -674,7 +681,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -777,6 +784,10 @@ impl Options {
"--name" => o.player_name = Some(value),
"--session-file" => o.session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
"--transfers" => {
o.transfers =
Some(horizon_client::lan::arrivals::Settings::parse(&value)?)
}
"--time" => o.time = Some(value.parse()?),
"--weather" => o.weather = Some(value),
"--dimension" => {
@@ -1004,9 +1015,11 @@ struct GameLevel<'w> {
debug: bool,
native_sounds: &'w mut Vec<creatures::NativeSound>,
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
/// `Block.dropResources` rolls the loot with it instead of with none.
drop_tool: Option<ItemStack>,
/// The block a mod's machine breaks and the tool it breaks it with
/// (`native_machine_destroy_block`): `Block.dropResources` rolls that
/// block's loot with the tool instead of with none. Neighbours that pop
/// off because of the break drop as with no tool, as in vanilla.
drop_tool: Option<((i32, i32, i32), Option<ItemStack>)>,
/// Where `Block.dropResources` puts the drops instead of popping them,
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
drop_into: Option<Vec<ItemStack>>,
@@ -1990,6 +2003,15 @@ struct WorldTables {
/// The values of the data map types native mods registered
/// (`native_data_map_types`).
data_maps: native_data_maps::DataMaps,
/// The entity type, biome and damage type tags with the packs' over the
/// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
/// what the predicates' `#tag`s of those registries read.
pack_tags: HashMap<&'static str, HashMap<String, HashSet<String>>>,
}
impl WorldTables {
/// The registries [`WorldTables::pack_tags`] holds.
const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"];
}
impl Resources {
@@ -3868,7 +3890,14 @@ impl Game<'_> {
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
/// particles at the entity.
fn honey_slide_particles(&mut self, at: [f64; 3]) {
self.honey_entity_particles(at, 53);
}
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
return;
};
let Some(honey) = self
.pack
.assets
@@ -3883,7 +3912,7 @@ impl Game<'_> {
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
for _ in 0..5 {
for _ in 0..count {
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
at,
@@ -6217,8 +6246,15 @@ impl Game<'_> {
{
return Ok(());
}
// ServerPlayerGameMode.destroyBlock.
// ServerPlayerGameMode.destroyBlock (and MultiPlayerGameMode.destroyBlock
// on the client): a GameMasterBlock stays when the player cannot use
// game master blocks (Player.canUseGameMasterBlocks).
let state = self.world.block(pos[0], pos[1], pos[2]);
if !self.can_use_game_master_blocks()
&& is_game_master_block(&self.res.facts().get(state).name)
{
return Ok(());
}
// Native mods, once the game allowed the break: a canceled one keeps
// the block, and a guest's acknowledgement puts it back in its world.
let mut canceled = false;
@@ -7671,6 +7707,25 @@ impl Game<'_> {
};
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
fn tick(&mut self) -> Result<()> {
if self.agent.hold_server {
return Ok(());
@@ -7718,8 +7773,9 @@ impl Game<'_> {
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
self.tick_weather_effects();
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
let _ = self.finish_player_autosaves(false);
// MinecraftServer.tickServer's countdown, then autoSave: three
// hundred seconds of ticks at the rate the server runs at now.
@@ -8830,6 +8886,7 @@ impl Game<'_> {
hud_layout::paint(batch, &g.gui.atlas, &draws);
}
});
let (visual_ticks, visual_millis) = (self.visual_ticks(), self.visual_millis());
if !self.creative
&& !spectator
&& let Some(p) = &mut self.player
@@ -8848,8 +8905,8 @@ impl Game<'_> {
// the surface, not merely in the water block.
let under_water = p.is_eye_in_fluid(horizon_client::motion::WATER);
let inputs = hud_layout::HealthInputs {
tick: self.ticks as i32,
millis: self.started.elapsed().as_millis() as i64,
tick: visual_ticks as i32,
millis: visual_millis,
health: p.health.health(),
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
absorption: p.health.absorption(),
@@ -11640,6 +11697,7 @@ impl Game<'_> {
laps.lap("eg_cracks");
self.finish_async_entity_culling();
laps.lap("eg_culling_finish");
self.native_world_geometry(camera, alpha as f32, &mut out);
(
out,
static_block_entities,
@@ -15808,6 +15866,10 @@ impl Game<'_> {
}
// Before any text of this frame: a full glyph region starts over.
self.gui.atlas.font.begin_frame();
if self.clock.still() {
// Obfuscated text picks the same glyphs in a frame of the same world tick.
self.gui.atlas.font.reseed(self.visual_ticks() as i64);
}
self.glint_frame = self.capture_glint_frame();
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
@@ -16433,7 +16495,7 @@ impl Game<'_> {
.get(n)
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
}),
ticks: self.ticks,
ticks: self.visual_ticks(),
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(alpha as f32),
block_flicker: self.fog.block_light_flicker,
@@ -16603,7 +16665,7 @@ impl Game<'_> {
{
return None;
}
let millis = self.started.elapsed().as_millis() as i64;
let millis = self.visual_millis();
Some(horizon_client::gfx::BorderDraw {
vertices: self.border_renderer.vertices,
rebuilt: self.border_renderer.take_rebuilt(),
@@ -17194,7 +17256,7 @@ impl Game<'_> {
unloaded.iter().map(|c| (c.x, c.z)).collect();
self.world
.block_entities
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
.unload(|p| gone.contains(&(p.0 >> 4, p.2 >> 4)));
self.world
.block_names
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
@@ -18743,7 +18805,15 @@ impl Game<'_> {
let started = Instant::now();
if self.title.is_none() && !self.pause {
horizon_client::spans::time("tick", || self.tick())?;
self.tick_ambience();
// Cave mood (a shader pack's playerMood) samples on a random
// seeded from the clock: under `HORIZON_STILL` it waits for the
// world and draws from its tick.
if !self.clock.still() {
self.tick_ambience();
} else if self.clock.runs_normally() {
self.ambience.reseed(self.visual_ticks() as i64);
self.tick_ambience();
}
self.tick_fov();
}
self.tick_win_screen()?;
@@ -19946,6 +20016,14 @@ impl Game<'_> {
None => self.res.stations(),
}
}
/// The enchantments item names and tooltips read: a server's, with the
/// entries its data packs add, else the world's.
fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
Some(e) => e,
None => &self.stations().enchantments,
}
}
/// The world's packs' content files, none without packs.
/// The biome table the world plays with, `None` before a world opens.
fn packed_biomes(&self) -> Option<Arc<Biomes>> {
@@ -20025,6 +20103,20 @@ impl Game<'_> {
self.res.core.collision.clone(),
&causes_suffocation,
)?;
// A registry whose pack tags don't load keeps the game's own tags
// (`in_tag` falls back to them), rather than matching no tag at all.
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
.into_iter()
.filter_map(|registry| {
match horizon_client::items::load_tags(root, registry, &overlay) {
Ok(tags) => Some((registry, tags)),
Err(e) => {
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
None
}
}
})
.collect();
Ok((
Some(WorldTables {
suffocation,
@@ -20033,6 +20125,7 @@ impl Game<'_> {
recipes,
stations,
data_maps,
pack_tags,
}),
health,
))
@@ -22354,6 +22447,19 @@ fn debug_crosshair_drawn(
enabled && first_person && !hide_gui && !panorama
}
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
/// block, jigsaw and the test blocks.
fn is_game_master_block(name: &str) -> bool {
horizon_client::command_block::Mode::of_block(name).is_some()
|| matches!(
name,
"minecraft:structure_block"
| "minecraft:jigsaw"
| "minecraft:test_block"
| "minecraft:test_instance_block"
)
}
#[cfg(test)]
mod tests {
#[test]
+15 -5
View File
@@ -209,8 +209,21 @@ impl adv::World for View<'_, '_> {
fn smokey(&self, p: [i32; 3]) -> bool {
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
}
fn block_entity_nbt(&self, p: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
self.0.block_entity_nbt(p)
}
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
let items = &self.0.res.items();
// The world's packs' tags over the game's own, as the server's
// registries bind them (`TagLoader.loadTagsForExistingRegistries`).
if let Some(tags) = self
.0
.world_tables
.as_ref()
.and_then(|t| t.pack_tags.get(registry))
{
return tags.get(tag).is_some_and(|t| t.contains(id));
}
let items = self.0.items();
match registry {
"item" => items.item_in_tag(tag, id),
"block" => items.block_in_tag(tag, id),
@@ -871,10 +884,7 @@ impl Game<'_> {
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = self
.res
.items()
.loot_table(table, false, None, &mut self.world.random);
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
if let Some(rest) = self.give(s)
&& let Some(eye) = self.eye_position()
@@ -518,9 +518,10 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
ui::action_button(
batch,
atlas,
horizon_client::widgets::Action::Done,
bx,
by,
200.0,
+5
View File
@@ -1659,6 +1659,11 @@ impl Game<'_> {
if text.is_empty() {
return Err("command TEXT".into());
}
// A native mod's command first, as typed chat does; the
// server's console cannot run a client mod's command.
if self.client_mod_command(text) {
return ok(json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }));
}
let command = format!("/{text}");
// Single player's console is its server thread's.
if let Some(server) = &self.integrated {
@@ -21,6 +21,48 @@ use horizon_core::{
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
/// Velocity `AbstractMinecart.push` adds: the cart, and a quarter on the stand.
#[derive(Clone, Copy, Debug, PartialEq)]
struct CartShove {
cart_x: f64,
cart_z: f64,
stand_x: f64,
stand_z: f64,
}
/// `ArmorStand.pushEntities` for one cart. `None` when the cart is not the
/// plain rideable kind, the boxes miss, `distanceToSqr` is past 0.2, the
/// stand is a passenger of the cart, or the horizontal gap is under
/// `1.0E-4F` (where `AbstractMinecart.push` does nothing).
fn armor_stand_cart_shove(
stand_pos: Vec3,
stand_box: Aabb,
cart_pos: Vec3,
cart_box: Aabb,
kind: super::minecarts::CartKind,
cart_carries_stand: bool,
) -> Option<CartShove> {
if kind != super::minecarts::CartKind::Rideable
|| cart_carries_stand
|| !stand_box.intersects(cart_box)
{
return None;
}
let (dx, dy, dz) = (
stand_pos.x - cart_pos.x,
stand_pos.y - cart_pos.y,
stand_pos.z - cart_pos.z,
);
if dx * dx + dy * dy + dz * dz > 0.2 {
return None;
}
let (xa, za) = super::minecarts::cart_push_from(stand_pos, cart_pos)?;
Some(CartShove {
cart_x: -xa,
cart_z: -za,
stand_x: xa / 4.0,
stand_z: za / 4.0,
})
}
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
/// colour; any other layer stays white, dyed or not. The result is in the
@@ -879,6 +921,9 @@ impl Game<'_> {
true,
);
}
// LivingEntity.aiStep calls pushEntities after travel. The stand's
// own doPush is empty; only this shove runs.
self.push_rideable_minecarts(i);
let a = &mut self.armor_stands[i];
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
@@ -889,6 +934,50 @@ impl Game<'_> {
}
Ok(())
}
/// `ArmorStand.pushEntities`: a plain minecart (`RIDABLE_MINECARTS`,
/// `AbstractMinecart.isRideable`) whose box meets the stand's is shoved
/// when `distanceToSqr` is at most 0.2. `AbstractMinecart.push` of a
/// non-cart adds the push to the cart and a quarter of it to the stand
/// (`Entity.push(double)` does not consult `isPushable`). A stand with no
/// physics (`Marker` or `NoGravity`, `ArmorStand.hasPhysics` / `noPhysics`)
/// or one riding that cart does not shove it.
fn push_rideable_minecarts(&mut self, i: usize) {
let stand = &self.armor_stands[i];
if stand.marker || stand.no_gravity {
return;
}
let stand_pos = stand.position;
let stand_box = stand.bounds();
let vehicle = stand.vehicle;
let mut applied = Vec::new();
for (j, cart) in self.carts.iter().enumerate() {
let Some(shove) = armor_stand_cart_shove(
stand_pos,
stand_box,
cart.position,
cart.bounds(),
cart.kind,
vehicle == Some(cart.id),
) else {
continue;
};
applied.push((j, shove));
}
let mut stand_x = 0.0;
let mut stand_z = 0.0;
for (j, shove) in applied {
let cart = &mut self.carts[j];
cart.velocity.x += shove.cart_x;
cart.velocity.z += shove.cart_z;
stand_x += shove.stand_x;
stand_z += shove.stand_z;
}
if stand_x != 0.0 || stand_z != 0.0 {
let stand = &mut self.armor_stands[i];
stand.velocity.x += stand_x;
stand.velocity.z += stand_z;
}
}
/// An armor stand is a `LivingEntity`, so the egg under it
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
@@ -1683,6 +1772,84 @@ mod burn_drop_tests {
}
}
#[cfg(test)]
mod push_tests {
use super::super::minecarts::{CartKind, Minecart};
use super::*;
fn boxes(kind: CartKind, cart_at: Vec3) -> (Aabb, Aabb) {
let stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
let cart = Minecart::new(2, kind, cart_at);
(stand.bounds(), cart.bounds())
}
#[test]
fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() {
// 0.25² is inside the 0.2 gate; 0.5² is not. The function's Some/None is the check.
let near = Vec3::new(0.25, 0.0, 0.0);
let far = Vec3::new(0.5, 0.0, 0.0);
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
let shove = armor_stand_cart_shove(
Vec3::ZERO,
stand_box,
near,
cart_box,
CartKind::Rideable,
false,
)
.expect("a rideable cart 0.25 away is shoved");
// AbstractMinecart.push: gap 0.25, length 0.25, pow capped at 1, then 0.1F * 0.5.
// The cart moves east, away from the stand; the stand takes a quarter west.
let scale = f64::from(0.1_f32) * 0.5;
assert_eq!(shove.cart_x, scale);
assert_eq!(shove.cart_z, 0.0);
assert_eq!(shove.stand_x, -scale / 4.0);
assert_eq!(shove.stand_z, 0.0);
let (stand_box, cart_box) = boxes(CartKind::Rideable, far);
assert!(
armor_stand_cart_shove(
Vec3::ZERO,
stand_box,
far,
cart_box,
CartKind::Rideable,
false
)
.is_none(),
"past 0.2 the cart stays"
);
for kind in [
CartKind::Chest,
CartKind::Hopper,
CartKind::Furnace,
CartKind::Tnt,
CartKind::CommandBlock,
CartKind::Spawner,
] {
let (stand_box, cart_box) = boxes(kind, near);
assert!(
armor_stand_cart_shove(Vec3::ZERO, stand_box, near, cart_box, kind, false)
.is_none(),
"{} is not a rideable minecart",
kind.id()
);
}
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
assert!(
armor_stand_cart_shove(
Vec3::ZERO,
stand_box,
near,
cart_box,
CartKind::Rideable,
true
)
.is_none(),
"a cart does not shove the stand riding it"
);
}
}
#[cfg(test)]
mod layer_color_tests {
use super::*;
#[test]
@@ -22,6 +22,20 @@ pub(super) type Pos = (i32, i32, i32);
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
/// The job of an edit screen's footer button `id`, given the ids of its Done and Cancel.
pub(super) fn footer_action(
id: u8,
done: u8,
cancel: u8,
) -> Option<horizon_client::widgets::Action> {
use horizon_client::widgets::Action;
match id {
_ if id == done => Some(Action::Done),
_ if id == cancel => Some(Action::Cancel),
_ => None,
}
}
/// A widget of an edit screen at GUI pixels.
pub(super) enum Item {
Label {
@@ -48,6 +62,8 @@ pub(super) enum Item {
w: f32,
label: Text,
active: bool,
/// Its job on the screen, for the controller.
action: Option<horizon_client::widgets::Action>,
},
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
/// and cut to what fits `h`.
@@ -271,10 +287,14 @@ impl Game<'_> {
w: bw,
label,
active,
action,
..
} = item
{
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
if let Some(action) = *action {
horizon_client::widgets::declare_action(action, [*x, *y, *bw, 20.0], *active);
}
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
}
}
+32 -6
View File
@@ -1278,6 +1278,20 @@ impl Game<'_> {
hover(x, 196.0, bw, 20.0),
);
};
let action_button = |batch: &mut HudBatch, action, x: f32, bw: f32, label: &str| {
ui::action_button(
batch,
atlas,
action,
x,
196.0,
bw,
20.0,
&Text::literal(label),
true,
hover(x, 196.0, bw, 20.0),
);
};
match book {
BookScreen::Edit {
signing: true,
@@ -1381,12 +1395,12 @@ impl Game<'_> {
&tr("book.finalizeButton", "Sign and Close"),
!title.trim().is_empty(),
);
button(
action_button(
batch,
widgets::Action::Cancel,
w / 2.0 + 2.0,
98.0,
&tr("gui.cancel", "Cancel"),
true,
);
}
BookScreen::Edit {
@@ -1464,7 +1478,13 @@ impl Game<'_> {
&tr("book.signButton", "Sign"),
true,
);
button(batch, w / 2.0 + 2.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 + 2.0,
98.0,
&tr("gui.done", "Done"),
);
}
BookScreen::View {
pages,
@@ -1495,7 +1515,13 @@ impl Game<'_> {
// LecternScreen.createMenuControls: Done and Take Book only with
// build permission, else BookViewScreen's wide Done.
if lectern.is_some() && self.may_build() {
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 - 100.0,
98.0,
&tr("gui.done", "Done"),
);
button(
batch,
w / 2.0 + 2.0,
@@ -1504,12 +1530,12 @@ impl Game<'_> {
true,
);
} else {
button(
action_button(
batch,
widgets::Action::Done,
(w - 200.0) / 2.0,
200.0,
&tr("gui.done", "Done"),
true,
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
+7 -6
View File
@@ -908,7 +908,7 @@ impl Game<'_> {
text,
indicator,
source,
self.ticks,
self.visual_ticks(),
focused,
);
}
@@ -1073,7 +1073,7 @@ impl Game<'_> {
line,
None,
ChatSource::Player,
self.ticks,
self.visual_ticks(),
focused,
);
}
@@ -1239,8 +1239,9 @@ impl Game<'_> {
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, &labels);
let layout = self.link_prompt_layout(url, (w, h));
layout.declare_no(2);
layout.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -1330,7 +1331,7 @@ impl Game<'_> {
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
// The fractional tick makes the smooth chat extra's slide move every
// frame; vanilla's fade reads it too.
let now = self.ticks as f32 + self.clock.fraction() as f32;
let now = self.visual_ticks() as f32 + self.clock.fraction() as f32;
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
@@ -1670,7 +1671,7 @@ impl Game<'_> {
message,
None,
ChatSource::Client,
self.ticks,
self.visual_ticks(),
focused,
);
}
@@ -673,6 +673,14 @@ impl Game<'_> {
}
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
let action = match b {
Button::Done => Some(horizon_client::widgets::Action::Done),
Button::Cancel => Some(horizon_client::widgets::Action::Cancel),
_ => None,
};
if let Some(action) = action {
horizon_client::widgets::declare_action(action, [x, y, bw, 20.0], true);
}
ui::button_as(
batch,
atlas,
@@ -341,6 +341,48 @@ impl Game<'_> {
out.push_str(&record.to_string());
out.push('\n');
}
"player_loop" => {
let ticks = op["n"].as_u64().unwrap_or(1);
let count = |g: &mut Self| {
Ok(g.stats
.values
.get("minecraft:custom")
.and_then(|m| m.get("minecraft:play_time"))
.copied()
.unwrap_or(0))
};
let before = self.harness_owner(count)?;
self.chat_capture = Some(Vec::new());
for _ in 0..ticks {
self.tick_server(false)?;
}
let messages = self.chat_capture.take().unwrap_or_default();
let delta = self.harness_owner(count)? - before;
if delta != ticks as i32 {
return Err(format!(
"player loop did not advance play_time: {delta}/{ticks}"
)
.into());
}
let record = json!({"cmd": format!("#player_loop {}",op["label"].as_str().unwrap_or("")), "messages": messages.iter().map(spans).collect::<Vec<_>>(), "results": [[true,delta]]});
out.push_str(&record.to_string());
out.push('\n');
}
"player_state" => {
let state = self.harness_owner(|g| {
let p = g.player.as_ref().ok_or("player state: no actor")?;
let mut loaded = Vec::new();
let mut done = std::collections::BTreeMap::new();
for a in g.adv.tree.list.iter().filter(|a| a.id.starts_with("h311:")) {
loaded.push(a.id.clone()); done.insert(a.id.clone(),g.adv.progress.done(a));
}
loaded.sort();
Ok(json!({"health":p.health.health(),"poison_ticks":g.potions.effects.list.iter().find(|e|e.id=="poison").map_or(0,|e|e.duration),"loaded":loaded,"done":done}))
})?;
let record = json!({"cmd":format!("#player_state {}",op["label"].as_str().unwrap_or("")),"messages":[],"results":[],"state":state});
out.push_str(&record.to_string());
out.push('\n');
}
"tick" => {
self.chat_capture = Some(Vec::new());
let paced_start = Instant::now();
+9 -11
View File
@@ -697,11 +697,13 @@ impl Game<'_> {
let block = Text::translate(&format!("block.{block}"), &[]);
Text::translate("entity.minecraft.falling_block_type", &[block])
}
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()),
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|| Text::translate("entity.", &[]),
|m| match self.mob_team_of(m) {
Some(team) => team.formatted_name(m.command_name()),
None => m.command_name(),
},
),
other => {
// Entity.getDisplayName: the CustomName component, else the type.
let custom = match other {
@@ -2835,7 +2837,7 @@ impl Game<'_> {
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
save::consume_container_loot(&mut extras, key);
let taken = rolled.is_some();
extras.rolled_loot.extend(rolled);
chunk.saved = Some(std::sync::Arc::new(extras));
@@ -3342,10 +3344,6 @@ impl Game<'_> {
// reopen, so it follows the command.
self.level.set_difficulty(i);
self.sync_rules();
if i == 0 {
// Peaceful removes hostile mobs.
self.mobs.retain(|m| !m.hostile());
}
// MinecraftServer.setDifficulty: every player, guests included, is
// sent the new difficulty.
if let Some(host) = self.lan.as_ref() {
@@ -3991,7 +3989,7 @@ impl Game<'_> {
line,
None,
horizon_client::chat::ChatSource::Player,
self.ticks,
self.visual_ticks(),
focused,
);
Ok(1)
+160 -26
View File
@@ -354,6 +354,10 @@ impl Game<'_> {
slot.extra.tame = old.extra.tame;
slot.extra.owner = old.extra.owner;
}
// Entity.load mutates the existing living entity; its damage
// cooldown and previous-hit amount are not read from saved NBT.
slot.invulnerable = old.invulnerable;
slot.last_hurt = old.last_hurt;
slot.ai = old.ai;
// Mob.readAdditionalSaveData updates persistent home fields,
// while retaining the rest of the live AI/runtime state.
@@ -625,35 +629,34 @@ impl Game<'_> {
}
Ok(tag)
}
DataRef::Block(p) => {
if let Some(tag) = self.moving_piston_data(*p) {
return Ok(tag);
}
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
return Ok(tag);
}
let key = (p[0], p[1], p[2]);
let name = self.data_block_name(*p);
let e = self
.world
.block_entities
.get(&key)
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
// BlockEntity.saveWithFullMetadata: no chunk-only keepPacked,
// and the lock and custom name the model does not hold.
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
if let (Some(custom), Tag::Compound(map)) =
(self.world.block_names.get(&key), &mut tag)
{
map.entry("CustomName".into())
.or_insert_with(|| Tag::String(custom.as_str().into()));
}
self.restore_loot_table(key, &mut tag);
Ok(tag)
}
DataRef::Block(p) => self
.block_entity_nbt(*p)
.ok_or_else(|| err("commands.data.block.invalid", &[])),
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
}
}
/// `BlockEntity.saveWithFullMetadata` of the block entity at `p`: no
/// chunk-only keepPacked, and the lock and custom name the model does
/// not hold. `None` where there is no block entity.
pub(in crate::game) fn block_entity_nbt(&self, p: [i32; 3]) -> Option<Tag> {
// A live moving piston is the level's block entity there (#9617).
if let Some(tag) = self.moving_piston_data(p) {
return Some(tag);
}
if let Some(tag) = self.comparator_data(p).or_else(|| self.pot_data(p)) {
return Some(tag);
}
let key = (p[0], p[1], p[2]);
let name = self.data_block_name(p);
let e = self.world.block_entities.get(&key)?;
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
map.entry("CustomName".into())
.or_insert_with(|| Tag::String(custom.as_str().into()));
}
self.restore_loot_table(key, &mut tag);
Some(tag)
}
pub(in crate::game) fn set_data_of(&mut self, r: &DataRef, tag: Tag) -> R<()> {
match r {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
@@ -1182,3 +1185,134 @@ impl Game<'_> {
Ok(target)
}
}
#[cfg(test)]
mod loot_table_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::BlockEntity,
items::ItemStack,
save::{self, ChunkExtras, RolledLoot},
};
use horizon_world::nbt::Tag;
use std::sync::Arc;
const TABLE: &str = "minecraft:chests/simple_dungeon";
/// The chest as a chunk save writes it, and its slots when that save loads.
fn saved_and_reloaded(game: &Game<'_>, pos: (i32, i32, i32)) -> (Tag, Vec<Option<ItemStack>>) {
let live = game.world.chunks.get(&(0, 0)).unwrap().clone();
let chunk = game.with_entities([&live]).remove(0);
let registry = game.pack.assets.registry;
let encoded = save::encode(&chunk, registry, game.res.biomes());
let Some(Tag::List(list)) = encoded.tag.get("block_entities") else {
panic!("no block entities");
};
let saved = list
.iter()
.find(|t| save::number(t.get("x")) == Some(i64::from(pos.0)))
.unwrap()
.clone();
let reloaded = save::decode(&encoded, registry, game.res.biomes()).unwrap();
let (_, BlockEntity::Chest(slots)) = reloaded
.block_entities
.iter()
.find(|(p, _)| *p == pos)
.unwrap()
else {
panic!("chest");
};
(saved, slots.clone())
}
/// RandomizableContainer.tryLoadLootTable: a tag without a table loads an
/// empty chest that stays so, and one with a table keeps it.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn data_commands_on_an_unopened_loaded_chest_survive_a_save_and_reload() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
// Two chests as a load leaves them: rolled, with the table in their source.
let removed = (1, 64, 2);
let merged = (2, 64, 2);
let chest = game
.pack
.assets
.registry
.default_state("minecraft:chest")
.unwrap();
let mut chunk = Chunk::empty(0, 0);
let mut extras = ChunkExtras::default();
for pos in [removed, merged] {
let mut slots = vec![None; 27];
slots[0] = Some(ItemStack::new("minecraft:gold_ingot", 2));
chunk.set(pos.0, pos.1, pos.2, chest.0);
extras.rolled_loot.push(RolledLoot {
pos,
table: TABLE.into(),
seed: Some(42),
items: save::encode_items(&slots),
});
extras.block_entity_sources.push((
pos,
Tag::compound([
("id", Tag::String("minecraft:chest".into())),
("x", Tag::Int(pos.0)),
("y", Tag::Int(pos.1)),
("z", Tag::Int(pos.2)),
("LootTable", Tag::String(TABLE.into())),
("LootTableSeed", Tag::Long(42)),
]),
));
game.world
.block_entities
.insert(pos, BlockEntity::Chest(slots));
}
chunk.saved = Some(Arc::new(extras));
game.world.chunks.insert((0, 0), Arc::new(chunk));
assert!(game.run_command("data remove block 1 64 2 LootTable"));
let (saved, slots) = saved_and_reloaded(&game, removed);
assert!(
saved.get("LootTable").is_none(),
"the emptied chest rolls again on load: {saved:?}"
);
assert!(saved.get("LootTableSeed").is_none());
assert!(slots.iter().all(Option::is_none));
assert!(game.run_command("data merge block 2 64 2 {LootTableSeed:7L}"));
let (saved, _) = saved_and_reloaded(&game, merged);
assert_eq!(save::text(saved.get("LootTable")).as_deref(), Some(TABLE));
assert_eq!(save::number(saved.get("LootTableSeed")), Some(7));
assert!(saved.get("Items").is_none());
}
}
@@ -244,7 +244,7 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// Force-loaded chunks too, a mod's held ones as `/forceload`'s: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
// And the players' chunks while they load, as vanilla's server runs
// its chunk tasks while it waits for the next tick
@@ -255,7 +255,10 @@ impl Game<'_> {
// only asked for by the tick's own `update_world`.
let players = !paused && self.spawn_wait.is_empty();
while self.pregen_runs_here()
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|| ((players
|| !self.streaming.forced.is_empty()
|| !self.streaming.mod_forced.is_empty())
&& self.streaming.busy())
{
let now = Instant::now();
if now >= until {
+12 -6
View File
@@ -476,8 +476,13 @@ impl Game<'_> {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
None => {}
Some("on") => r.set_gpu_culling(true)?,
Some("off") => r.set_gpu_culling(false)?,
Some(mode @ ("on" | "off")) => {
// Refused, with the reason, where it isn't offered.
r.set_gpu_culling(mode == "on")?;
// The setting, as its switch sets it; not saved here (as
// `lod-distance`): settings changes and new renderers keep it.
self.settings.horizon.gpu_culling = mode == "on";
}
Some(other) => return Err(format!("unknown GPU culling mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_culling": r.gpu_culling() })))
@@ -917,11 +922,10 @@ impl Game<'_> {
}
"chat" => {
let text = line.trim_start().strip_prefix("chat").unwrap_or("").trim();
if self.remote.is_some() {
self.send_chat(text);
return ok();
}
self.require_world()?;
// As if typed: a client mod's command runs before the line
// reaches the server, and the Chat option's restrictions and
// the recent list apply to a connected server too.
self.handle_chat_input(text);
Ok(Some(
json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }),
@@ -1328,6 +1332,8 @@ impl Game<'_> {
"gpu_culling": r.gpu_culling(),
"gpu_frame": r.gpu_frame(),
"gpu_fading": r.gpu_fading(),
"gpu_culling_offer": format!("{:?}", r.gpu_culling_offer()),
"stats_lag": r.stats_lag(),
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
"arena_bytes": arena_bytes,
@@ -2,6 +2,8 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod pufferfish_player;
use super::{
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
native_projectiles::Impact, view_vector,
@@ -325,6 +327,9 @@ impl Game<'_> {
main_hand: self.held().map(|s| s.item.clone()),
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
head: self.armor[0].as_ref().map(|s| s.item.clone()),
chest: self.armor[1].as_ref().map(|s| s.item.clone()),
legs: self.armor[2].as_ref().map(|s| s.item.clone()),
feet: self.armor[3].as_ref().map(|s| s.item.clone()),
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
invisible: self.potions.effects.get("invisibility").is_some(),
spectator: self.spectator(),
@@ -0,0 +1,68 @@
//! Pufferfish.playerTouch in the real player's tick, independent of fish AI.
use super::super::{
Game,
entity::{Entity, with_entity},
};
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
use horizon_core::geometry::Vec3;
impl Game<'_> {
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
pub(in crate::game) fn touch_pufferfish(&mut self) {
if self.remote.is_some() || self.spectator() {
return;
}
let Some(player) = self.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
return;
};
let mut fish = self
.mobs
.iter()
.filter(|m| m.species() == Species::Pufferfish && m.alive() && m.extra.size > 0)
.peekable();
if fish.peek().is_none() {
return;
}
let own = player.bounds();
// Player.aiStep's contact query; the existing vehicle bounds are read,
// without moving or simulating that vehicle.
let vehicle = self.riding.and_then(|id| {
self.entity_handles().find_map(
|h| with_entity!(self,h,e => (e.id()==id && !e.is_removed()).then(||e.bounds())),
)
});
let query = vehicle.map_or_else(
|| own.inflate(Vec3::new(1.0, 0.5, 1.0)),
|b| own.union_bounds(b).inflate(Vec3::new(1.0, 0.0, 1.0)),
);
let touching: Vec<_> = fish
.filter(|m| {
query.intersects(m.bounds())
&& teams_allow_damage(self.mob_team_of(m), self.player_team())
})
.map(|m| {
(
m.id,
m.extra.size,
[m.position.x, m.position.y, m.position.z],
self.mob_entity(m),
)
})
.collect();
for (id, puff, at, source) in touching {
if !self.hurt_player_from(
(1 + puff) as f32,
Some(at),
"minecraft:mob_attack",
Some(Some(id)),
) {
continue;
}
let changes = self.potions.effects.changes;
self.give_player_effect("minecraft:poison", 60 * puff, 0);
if self.potions.effects.changes != changes {
self.trigger_effects_changed_by(Some(source));
}
}
}
}
@@ -289,6 +289,13 @@ impl Game<'_> {
];
for (i, label) in labels.iter().enumerate() {
let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0);
if i == 3 {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[xs[i], fy, FOOTER_BUTTONS[i], 20.0],
true,
);
}
ui::button(
batch,
atlas,
+30 -3
View File
@@ -38,6 +38,7 @@ pub(super) mod events;
mod management;
mod report;
mod status;
mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
@@ -985,9 +986,6 @@ impl Game<'_> {
};
self.level.set_difficulty(self.ui.difficulty);
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
}
// DedicatedServer.initServer: a legacy `announce-player-achievements`
// sets the show_advancement_messages rule once, as the file is read.
if let Some(announce) = props.announce_player_achievements {
@@ -1025,6 +1023,18 @@ impl Game<'_> {
.filter(|b| png_size(b) == Some((64, 64)));
// Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file.
let forwarding_secret = forwarding_secret(online.is_some())?;
// `[transfer]` on: a player's only way in must be the proxy, or one who joins this server
// directly finds the older copy of itself that a hand-over left here, and items double.
let transfers = self
.options
.transfers
.clone()
.map(horizon_client::lan::arrivals::Transfers::new);
if transfers.is_some() && forwarding_secret.is_none() {
return Err("server.toml's [transfer] is on, but players can join this server without the proxy: \
turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off"
.into());
}
let net = NetConfig {
bind: (!props.server_ip.is_empty())
.then(|| props.server_ip.parse().ok())
@@ -1053,6 +1063,7 @@ impl Game<'_> {
gate: Some(gate),
online,
forwarding_secret,
arrivals: transfers.as_ref().map(|t| t.expected.clone()),
log: Some(NetLog {
write: Arc::new(log_from),
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
@@ -1114,6 +1125,22 @@ impl Game<'_> {
log("WARN", "Perhaps a server is already running on that port?");
return Ok(());
}
if let Some(transfers) = transfers
&& let Some(host) = self.lan.as_mut()
{
let s = &transfers.settings;
log(
"INFO",
&format!(
"Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most",
s.expect.as_secs(),
s.max_expect.as_secs(),
s.max_expected,
s.max_freeze_ticks
),
);
host.hub.transfers = Some(transfers);
}
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
@@ -3,6 +3,8 @@
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
//! `player-expect <uuid> [seconds]` and `player-export <uuid> [freeze]` are a proxy's, for a player it moves
//! between two servers (transfer.rs).
use super::{ServerState, capture_log};
use crate::game::Game;
use serde_json::{Value, json};
@@ -47,6 +49,10 @@ impl Game<'_> {
super::events::since(since.trim().parse().unwrap_or(0))
}
Some(("management", rest)) => self.management(state, rest),
// A proxy moving a player here or away (transfer.rs, lan/arrivals.rs).
Some((verb, rest)) if verb.starts_with("player-") => {
self.player_request(verb, rest)
}
Some(("console", line)) => {
// Command feedback is logged as system chat; the reply carries the text alone.
let output: Vec<String> = capture_log(|| self.console_command(state, line))
@@ -0,0 +1,117 @@
//! A proxy's requests on the control endpoint, for a player it moves between two servers:
//! `player-expect <uuid> [seconds]` (the target is told who is coming) and
//! `player-export <uuid> [freeze]` (the source answers with the player's record). They run
//! between two ticks, on the server thread, so the player is never caught half-way through a
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
//! `[transfer]` is off.
use crate::game::{Dim, Game, lan::LanHost};
use horizon_client::lan::arrivals;
use horizon_protocol::packets::play::{self, Clientbound as C};
use serde_json::{Value, json};
use std::time::Instant;
impl Game<'_> {
/// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or
/// `{"error": "..."}`.
pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value {
let Some(host) = self.lan.as_ref() else {
return json!({ "error": "the server is not open to players" });
};
if host.hub.transfers.is_none() {
return json!({ "error": arrivals::OFF });
}
match verb {
"player-expect" => match self.lan.as_mut() {
Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()),
None => json!({ "error": "the server is not open to players" }),
},
"player-export" => self.player_export(args),
other => json!({ "error": format!("unknown request {other:?}") }),
}
}
/// `player-export <uuid> [freeze]`: closes the player's menus as vanilla does when a menu closes
/// (the cursor stack and the crafting grids go back into the inventory), then answers with the
/// player's record (`arrivals::export_record`). With `freeze`, a hand-over starts: the player's
/// own inputs stop being acted on until it ends, and a later export answers with the same record.
fn player_export(&mut self, args: &str) -> Value {
let mut parts = args.split_whitespace();
let Some(uuid) = parts.next().and_then(arrivals::parse_uuid) else {
return json!({ "error": "player-export needs the player's unique id" });
};
let freeze = match parts.next() {
None => false,
Some("freeze") => true,
Some(_) => return json!({ "error": "the only option of player-export is freeze" }),
};
if parts.next().is_some() {
return json!({ "error": "player-export takes a unique id and at most freeze" });
}
let Some(mut host) = self.lan.take() else {
return json!({ "error": "the server is not open to players" });
};
let reply = self.export_from(&mut host, uuid, freeze);
self.lan = Some(host);
reply
}
fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value {
let i = match arrivals::check_exportable(&host.hub, uuid) {
Ok(i) => i,
Err(why) => return json!({ "error": why }),
};
// A repeat while the hand-over lasts: the same record, and nothing touched.
if let Some(h) = &host.hub.guests[i].handover {
return arrivals::export_reply(&host.hub, i, h.record.clone());
}
let conn = host.hub.guests[i].conn;
let window = host.hub.guests[i].menu.as_ref().map(|m| m.window);
// The menu closes in the player's own level, as when it leaves (`game/lan.rs`'s close of
// every guest): a chest's lid, a villager's trade and a dropped cursor stack belong there.
let back = self.dim;
let level = Dim::from_id(&host.hub.guests[i].dimension);
if let Err(e) = self.enter_level(Some(host), level) {
return json!({ "error": format!("the player's level could not be opened: {e}") });
}
// Nothing is sent to the client by this: it is for a player who is leaving.
self.guest_close_all(host, conn);
// The inventory's own 2x2 grid too (`InventoryMenu.removed`): `guest_close_all` empties it
// only with a real `ContainerClose(0)`, and a frozen client's is dropped. Its items are
// not in the player compound, so they would be lost with `player-release moved`.
self.lan_crafting_closed(host, conn, Some(0));
if let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return json!({ "error": "that player is not connected to this server" });
};
// The player may stay (a hand-over that does not finish): its client closes the menu too
// and shows the inventory as it now is.
if let Some(container) = window {
host.hub
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
}
host.hub.send_inventory(i);
// What a mod attached to the player since it joined, as a save writes it.
self.guest_attachments_into(&mut host.hub.guests[i]);
let advancements = if self.adv.tree.list.is_empty() {
Value::Null
} else {
host.hub.guests[i]
.advancements
.to_json(&self.adv.tree, horizon_world::DATA_VERSION)
};
let stats = host
.play
.get(&conn)
.map_or(Value::Null, |p| p.stats.to_json());
let records = arrivals::Records {
advancements,
stats,
};
match arrivals::export_record(&mut host.hub, i, freeze, records) {
Ok(record) => arrivals::export_reply(&host.hub, i, record),
Err(e) => json!({ "error": e }),
}
}
}
+18 -7
View File
@@ -418,12 +418,9 @@ impl Game<'_> {
widgets::join_for_narration(&[&title.plain(), &message.plain()]),
)
});
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
batch,
atlas,
cursor,
[&disconnect, &back],
);
let confirm = Confirm::new(&atlas.font, (w, h), &title, &message);
confirm.declare_no();
confirm.draw(batch, atlas, cursor, [&disconnect, &back]);
return;
}
if let Some(since) = self.dialogs.waiting {
@@ -450,7 +447,18 @@ impl Game<'_> {
if visible {
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
ui::action_button(
batch,
atlas,
widgets::Action::Back,
bx,
by,
200.0,
20.0,
&label,
active,
hover,
);
}
return;
}
@@ -672,6 +680,9 @@ impl Game<'_> {
let hover =
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
let b = buttons[index];
if open.dialog.escape_button() == Some(index) {
widgets::declare_action(widgets::Action::Cancel, rect, true);
}
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
if let Some(t) = &b.tooltip {
widgets::last_narratable(|n| n.hint = Some(t.plain()));
+155 -6
View File
@@ -569,7 +569,9 @@ impl Dragon {
/// (the dragon's type is fire immune) before `EnderDragon.hurt`.
/// `direct` is the projectile that hit: a direct `AbstractArrow` or a
/// `WindCharge` is the arrow a perched dragon shrugs off, a breeze's
/// `BreezeWindCharge` is neither.
/// `BreezeWindCharge` is neither. Gameplay calls `hurt_part_from`; this
/// is the no-attacker form the tests use.
#[cfg(test)]
fn hurt_part(
&mut self,
part: usize,
@@ -577,6 +579,19 @@ impl Dragon {
counts: Option<HurtSource<'_>>,
kind: DamageKind,
direct: Option<&ProjectileKind>,
) -> Hurt {
self.hurt_part_from(part, damage, counts, kind, direct, None)
}
/// `hurt_part` with the melee attacker's position (`DamageSource.getSourcePosition`,
/// the direct entity's feet). `None` for a hit that is not a player's melee.
fn hurt_part_from(
&mut self,
part: usize,
damage: f32,
counts: Option<HurtSource<'_>>,
kind: DamageKind,
direct: Option<&ProjectileKind>,
from: Option<Vec3>,
) -> Hurt {
if is_fire(kind) {
return Hurt::Refused;
@@ -587,15 +602,27 @@ impl Dragon {
ProjectileKind::Arrow { .. } | ProjectileKind::Trident | ProjectileKind::WindCharge
)
);
self.hurt(part, damage, counts, arrow)
self.hurt_from(part, damage, counts, arrow, from)
}
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
/// (an arrow is spent, else it turns back). `counts` is the source
/// `reallyHurt` takes when `source.getEntity() instanceof Player` or its
/// type always hurts dragons (explosions), and none otherwise; `arrow` a
/// direct arrow or wind charge, which a perched dragon shrugs off
/// (`AbstractDragonSittingPhase.onHurt`).
/// (`AbstractDragonSittingPhase.onHurt`). Gameplay calls `hurt_from`.
#[cfg(test)]
fn hurt(&mut self, part: usize, damage: f32, counts: Option<HurtSource>, arrow: bool) -> Hurt {
self.hurt_from(part, damage, counts, arrow, None)
}
/// `hurt` with the position `dealDefaultKnockback` pushes away from.
fn hurt_from(
&mut self,
part: usize,
damage: f32,
counts: Option<HurtSource>,
arrow: bool,
from: Option<Vec3>,
) -> Hurt {
if matches!(self.phase, Phase::Dying { .. }) || !self.alive() {
return Hurt::Refused;
}
@@ -652,6 +679,19 @@ impl Dragon {
});
}
}
// `LivingEntity.hurtServer` calls `dealDefaultKnockback` (strength
// `0.4F`) on a full hit, before `EnderDragon.hurt` adds sitting damage
// and maybe takes off. `from` is the attacker's feet for a player's
// melee hit. Explosions are `#no_knockback`; their push is
// `explosion_knockback`.
if full && let Some(from) = from {
self.knockback(
f64::from(0.4_f32),
from.x - self.position.x,
from.z - self.position.z,
source,
);
}
if self.phase.sitting() {
self.sitting_damage += before - self.health.max(0.0);
if self.sitting_damage > 0.25 * MAX_HEALTH {
@@ -665,6 +705,43 @@ impl Dragon {
}
Hurt::Landed { full }
}
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
/// the source position minus this position). Knockback resistance is the
/// mob default, 0. `noPhysics` never sets `onGround`, so the vertical
/// component stays.
fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: HurtSource<'_>) {
if self.phase.sitting() {
return;
}
let mut knock = horizon_client::health::Knock {
strength: power,
x: xd,
z: zd,
};
if horizon_client::health::knockback_canceled(
horizon_client::health::Hurting::Dragon(self.id as i32, source),
&mut knock,
) {
return;
}
let horizon_client::health::Knock {
strength: power,
x: mut xd,
z: mut zd,
} = knock;
if power <= 0.0 {
return;
}
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
}
let len = (xd * xd + zd * zd).sqrt();
let (kx, kz) = (xd / len * power, zd / len * power);
let v = self.velocity;
self.velocity = Vec3::new(v.x / 2.0 - kx, v.y, v.z / 2.0 - kz);
}
/// `ServerExplosion.hurtEntities` on the dragon: the dragon itself (its
/// body takes it), then each part, in `Level.getEntities` order, each by
/// its distance and how much of its box the blast sees (`exposure`).
@@ -1307,12 +1384,19 @@ impl Game<'_> {
) -> Hurt {
let by_id = by.map(|who| self.fight_entity(who));
let source = counts.then(|| hurt_source(kind, by_id, direct));
// `dealDefaultKnockback` for a player's melee: away from the attacker's
// feet. The scope names that player to the knockback hook.
let _attacker = (kind == DamageKind::PlayerAttack)
.then(|| self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id)));
let from = (kind == DamageKind::PlayerAttack)
.then(|| self.player.as_ref().map(|p| p.position))
.flatten();
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
let was_down = d.down();
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
let hurt = d.hurt_part(part, damage, source, kind, direct);
let hurt = d.hurt_part_from(part, damage, source, kind, direct, from);
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
// (100 ticks); a hit the damage cooldown absorbs returns before that.
if counts
@@ -2476,12 +2560,31 @@ impl Game<'_> {
if drops {
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.2) as u32);
}
let (id, at) = self.dragon.as_ref().map_or((0, p), |d| (d.id, d.position));
self.dragon = None;
if fight {
self.dragon_killed();
}
// remove, then gameEvent(ENTITY_DIE): with no health left,
// `Warden.canTargetEntity` refuses the dragon, so no warden hears it.
self.dragon_died_event(id, at, false);
}
}
/// `Entity.gameEvent(GameEvent.ENTITY_DIE)` for the removed dragon: sculk
/// sensors (and, while it has health, wardens) hear it, and the nearest
/// catalyst blooms (`CatalystListener`). It takes no experience: the
/// dragon's `xpReward` is 0, its orbs come from `tickDeath`.
fn dragon_died_event(&mut self, id: u32, at: Vec3, targetable: bool) {
let at = [at.x, at.y, at.z];
let ctx = horizon_client::sculk::Context {
source: Some(Who::Mob(id)),
targetable,
living: true,
..Default::default()
};
self.post_game_event("minecraft:entity_die", at, ctx);
self.catalyst_takes_experience(at, |_| 0);
}
/// `EnderDragonFight.setDragonKilled`.
fn dragon_killed(&mut self) {
let _ = self.activate_exit_portal();
@@ -3154,7 +3257,7 @@ impl Game<'_> {
if let Some(c) = d.nearest_crystal.and_then(|i| self.crystals.get(i)) {
let bob = f64::from(horizon_client::end_crystal::y(c.time as f32 + a));
let to = glam::DVec3::new(c.position.x, c.position.y + bob, c.position.z);
self.crystal_beam(pos, to - pos, self.ticks as f32 + a, camera, out);
self.crystal_beam(pos, to - pos, self.visual_ticks() as f32 + a, camera, out);
}
}
/// `EnderDragonRenderer.submitRays`: untextured, additive death rays.
@@ -3594,7 +3697,10 @@ impl Game<'_> {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
// Only the fight's own dragon (`dragonFight`) ends it.
let fight = self.is_fight_dragon();
self.dragon = None;
if let Some(d) = self.dragon.take() {
// remove, then gameEvent(ENTITY_DIE), before the fight hears of it.
self.dragon_died_event(d.id, d.position, d.alive());
}
if fight {
self.dragon_killed();
}
@@ -4755,6 +4861,49 @@ mod tests {
assert_eq!(d.health, MAX_HEALTH - 9.0);
}
/// `EnderDragon.knockback` calls `LivingEntity.knockback` only when the
/// phase is not sitting. `dealDefaultKnockback` uses `0.4F` away from the
/// attacker's feet. Resistance is 0 and `onGround` stays false
/// (`noPhysics`), so from velocity `(1, -0.3, 0.5)` and a source 3 by 4
/// blocks away the push is `(0.5 - 0.6p, -0.3, 0.25 - 0.8p)` with
/// `p = (double) 0.4F`. A perched hit, including one that makes it take
/// off, leaves the velocity alone. A hit inside the damage cooldown is
/// not a full hit, so it does not push either.
#[test]
fn a_melee_hit_knocks_a_flying_dragon_and_not_a_perched_one() {
let from = Vec3::new(3.0, 70.0, 4.0);
let velocity = Vec3::new(1.0, -0.3, 0.5);
let power = f64::from(0.4_f32);
let pushed = Vec3::new(0.5 - 0.6 * power, -0.3, 0.25 - 0.8 * power);
let mut perched = dragon(Phase::Scanning { time: 0 });
perched.velocity = velocity;
assert_eq!(
perched.hurt_from(0, 9.0, HIT, false, Some(from)),
Hurt::Landed { full: true }
);
assert_eq!(perched.velocity, velocity);
let mut taking_off = dragon(Phase::Flaming { ticks: 0 });
taking_off.velocity = velocity;
taking_off.hurt_from(0, 60.0, HIT, false, Some(from));
assert!(matches!(taking_off.phase, Phase::Takeoff { .. }));
assert_eq!(taking_off.velocity, velocity);
let mut flying = dragon(flying());
flying.velocity = velocity;
assert_eq!(
flying.hurt_from(0, 9.0, HIT, false, Some(from)),
Hurt::Landed { full: true }
);
assert_eq!(flying.velocity, pushed);
assert_eq!(
flying.hurt_from(0, 20.0, HIT, false, Some(from)),
Hurt::Landed { full: false }
);
assert_eq!(flying.velocity, pushed);
}
#[test]
fn a_part_refuses_fire() {
// EnderDragonPart.hurtServer: the dragon is fire immune, so a
@@ -62,7 +62,7 @@ impl Game<'_> {
);
}
pub(super) fn sound_entity_state(&self, id: u32) -> Option<SoundEntityState> {
pub(super) fn sound_entity_state(&mut self, id: u32) -> Option<SoundEntityState> {
if self.remote.is_some() {
return self.remote_sound_entity_state(id as i32);
}
+108 -1
View File
@@ -345,13 +345,23 @@ impl Game<'_> {
self.particles
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
}
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
/// `FireworkRocketItem.use` while gliding: drop the player's leads, then a
/// rocket attached to the player.
pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:firework_rocket"
|| !self.player.as_ref().is_some_and(|p| p.fall_flying)
{
return false;
}
// Server path of `use`: `dropAllLeashConnections(null)`, and when that
// drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`.
// `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position
// is what the server thread forwards, so the window hears the snap.
if self.drop_all_leash_connections()
&& let Some(p) = self.player.as_ref()
{
self.lead_sound("break", [p.position.x, p.position.y, p.position.z]);
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
rocket.owner = Some(Who::Player);
@@ -759,5 +769,102 @@ mod tests {
assert!(super::use_on_passes(true));
assert!(!super::use_on_passes(false));
}
/// `FireworkRocketItem.use` on the server, while fall-flying: a lead on a
/// mob within the 32-block box drops as an item and one
/// `minecraft:item.lead.break` plays at the player's feet. A lead outside
/// that box, or held for another player, stays. Standing still does neither.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_gliding_firework_drops_leads_and_plays_lead_break() {
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::mobs::{Mob, leash::Holder};
use horizon_core::geometry::Vec3;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = super::Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, -3.25))
.unwrap(),
);
let feet = {
let p = game.player.as_ref().expect("a player").position;
[p.x, p.y, p.z]
};
let kind = game
.res
.mob_types()
.get("minecraft:cow")
.expect("cow")
.clone();
let me = game.acting_uuid();
let someone_else = [me[0].wrapping_add(1), me[1], me[2], me[3]];
let mut leashed = |id, at: Vec3, owner: [i32; 4]| {
let mut mob = Mob::new(id, kind.clone(), at, 0.0);
mob.extra.leash = Some(Holder::Player);
mob.extra.leash_player = Some(owner);
game.mobs.push(mob);
};
let here = Vec3::new(feet[0] + 1.0, feet[1], feet[2]);
leashed(1, here, me);
leashed(2, Vec3::new(feet[0] + 40.0, feet[1], feet[2]), me);
leashed(3, here, someone_else);
game.inventory[game.selected] = Some(super::ItemStack::new("minecraft:firework_rocket", 1));
let stack = game.inventory[game.selected].clone().unwrap();
game.audio.capture = Some(Vec::new());
assert!(!game.boost_firework(&stack));
assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player));
assert!(game.dropped.is_empty());
assert!(game.audio.capture.as_ref().unwrap().is_empty());
game.player.as_mut().unwrap().fall_flying = true;
assert!(game.boost_firework(&stack));
assert!(
game.mobs[0].extra.leash.is_none(),
"the mob on the lead comes free"
);
assert_eq!(game.mobs[1].extra.leash, Some(Holder::Player));
assert_eq!(game.mobs[2].extra.leash, Some(Holder::Player));
let leads: Vec<_> = game
.dropped
.iter()
.filter(|d| &*d.stack.item == "minecraft:lead")
.collect();
assert_eq!(leads.len(), 1);
let heard = game.audio.capture.take().unwrap_or_default();
let snaps: Vec<_> = heard
.into_iter()
.filter(|s| s.event == "minecraft:item.lead.break")
.map(|s| (s.position, s.volume, s.pitch))
.collect();
assert_eq!(snaps, vec![(feet, 1.0, 1.0)]);
}
}
horizon_client::impl_joined!(Rocket);
+16 -12
View File
@@ -10,8 +10,6 @@ use horizon_core::{
position::BlockPos,
random::RandomSource,
};
use std::sync::Arc;
/// A baked frame model: (corners in block units, unit UVs, sprite) per quad.
pub(super) type FrameModel = Vec<([[f32; 3]; 4], [[f32; 2]; 4], String)>;
@@ -165,12 +163,21 @@ impl Game<'_> {
"minecraft:glow_item_frame" => true,
_ => return false,
};
let invisible = stack.extra.as_ref().is_some_and(|extra| {
extra.other.iter().any(|(k, v)| {
(k == "minecraft:entity_data" || k == "entity_data")
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
let entity_data = stack.extra.as_ref().and_then(|extra| {
extra.other.iter().find_map(|(k, v)| {
(k == "minecraft:entity_data" || k == "entity_data").then_some(v)
})
});
let invisible = entity_data
.and_then(|v| horizon_client::save::number(v.get("Invisible")))
.unwrap_or(0)
!= 0;
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`, then `entity_data`
// loads (`updateCustomEntityTag`): a missing `CustomName` clears it.
let custom_name = match entity_data {
Some(data) => data.get("CustomName").cloned(),
None => stack.data().and_then(|d| d.custom_name.clone()),
};
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
// placed on the top face of the highest block or under the lowest one.
@@ -191,6 +198,7 @@ impl Game<'_> {
fixed: false,
invisible,
check_interval: 0,
custom_name,
};
if !self.frame_survives(&frame) {
return false;
@@ -384,12 +392,8 @@ impl Game<'_> {
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
let mut drops = Vec::new();
if with_frame {
drops.push(ItemStack {
item: Arc::from(f.kind()),
count: 1,
damage: 0,
extra: None,
});
// `ItemFrame.getFrameItemStackWithData` (glow frames share it).
drops.push(f.item_stack_with_data());
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
+12
View File
@@ -142,6 +142,7 @@ impl Game<'_> {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
@@ -165,8 +166,19 @@ impl Game<'_> {
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
sharing.notice = Some(text.clone());
stopped = Some(text);
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
+11 -1
View File
@@ -78,6 +78,12 @@ impl ItemEnv for Env<'_, '_> {
local_time(pattern)
}
fn random_float(&self) -> f32 {
if self.game.clock.still() {
// The same draw for a frame of the same world tick.
let mixed =
(self.game.visual_ticks() ^ 0x9e37_79b9).wrapping_mul(0x9e37_79b9_7f4a_7c15);
return (mixed >> 40) as f32 / (1u64 << 24) as f32;
}
let nanos = self.game.started.elapsed().subsec_nanos();
(nanos % 1_000_000) as f32 / 1_000_000.0
}
@@ -395,7 +401,11 @@ impl Game<'_> {
/// frame, not per item (`GameRenderer.extractOptions`).
pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame {
item_render::GlintFrame {
millis: self.started.elapsed().as_secs_f32() * 1000.0,
millis: if self.clock.still() {
self.visual_millis() as f32
} else {
self.started.elapsed().as_secs_f32() * 1000.0
},
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
sprite: self
@@ -2,7 +2,7 @@
//! state, priorities and joint, with the Generate button and its levels.
use super::{
Game,
block_editor::{Editor, Item, Outcome, Pos, cycle, on_off},
block_editor::{Editor, Item, Outcome, Pos, cycle, footer_action, on_off},
};
use horizon_client::{
containers::BlockEntity,
@@ -123,6 +123,7 @@ impl Editor for JigsawEditor {
w: bw,
label: text,
active,
action: footer_action(id, DONE, CANCEL),
};
let label = |key: &str, x: f32, y: f32| Item::Label {
text: tr(key),
+14 -4
View File
@@ -579,6 +579,13 @@ fn mob_meta(
}
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
// after AgeableMob's two). The total `boost()` stored in `counter2`.
// The default 0 is not registered: vanilla never writes it back, and the
// rider's client finishes the timer `onSynced` started.
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
meta.push((18, serializer::INT, Value::Int(e.counter2)));
}
mob_variant_meta(m, hub, meta, defaults);
mob_raider_meta(m, meta, defaults);
match kind {
@@ -2829,10 +2836,13 @@ impl Game<'_> {
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
// Event 53: a mob sliding down a honey block sheds honey particles
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
if event == 53 && self.options.execution_role.is_client() {
self.honey_slide_particles(at);
// Events 53 and 54: honey crumbs while sliding, and the landing burst
// (`HoneyBlock.showSlideParticles` / `showJumpParticles`), drawn by the
// client that runs the world.
if self.options.execution_role.is_client()
&& horizon_client::landing::honey_particle_count(event).is_some()
{
self.honey_entity_particles(at, event);
}
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
if event == 2 && self.options.execution_role.is_client() {
+23 -6
View File
@@ -2493,9 +2493,6 @@ impl Game<'_> {
// survives the reopen, so it follows the change.
self.level.set_difficulty(self.ui.difficulty);
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
}
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(self.ui.difficulty),
@@ -4585,6 +4582,13 @@ impl Game<'_> {
health: &game.health_resources,
}
.server_travel();
// Player.aiStep's entity contacts run with the player's body,
// even when the level and its mobs are frozen. Flush their damage
// events and marked velocity through the existing guest path.
game.touch_pufferfish();
let Some(player) = game.player.as_mut() else {
return (events, None);
};
// Landing damage from this travel reaches the tracker too.
events.append(&mut player.events);
// A hurt since the last tick marked the velocity (Entity.markHurt):
@@ -4662,20 +4666,33 @@ impl Game<'_> {
host.hub.sound(name, at, volume, pitch, source, None);
}
}
// LivingEntity.causeFallDamage: Player.playSound skips the
// guest, whose own client plays it; everyone else hears it.
// LivingEntity.causeFallDamage, and HoneyBlock.fallOn's
// `entity.playSound`: Player.playSound skips the guest, whose
// own client plays it; everyone else hears it.
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
sound,
volume,
pitch,
..
}) => {
if let Some(name) = sound.player_fall_sound() {
if let Some(name) = sound
.player_fall_sound()
.or_else(|| sound.honey_landing_sound())
{
let source = horizon_client::settings::sound_source(name) as i32;
self.audio.play_unshared(name, Some(at), volume, pitch);
host.hub.sound(name, at, volume, pitch, source, Some(conn));
}
}
// HoneyBlock.fallOn: entity event 54, including the player who
// landed (`broadcastEntityEvent` has no excluded player).
MotionEvent::Fall(horizon_client::landing::FallEvent::Broadcast(event))
if horizon_client::landing::honey_particle_count(event as i8).is_some() =>
{
if let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) {
host.hub.entity_event(id, event as i8, at);
}
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// PowderSnowBlock.entityInside: every player around sees the
// snowflake (`ServerLevel.sendParticles`).
@@ -476,6 +476,9 @@ mod tests {
main_hand: None,
off_hand: None,
head: None,
chest: None,
legs: None,
feet: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
@@ -960,6 +960,7 @@ mod tests {
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
},
1,
[5.0, 64.0, 5.0],
@@ -981,6 +982,7 @@ mod tests {
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25566".parse().unwrap(),
expected: false,
},
2,
[100.0, 64.0, 100.0],
+32 -2
View File
@@ -818,7 +818,18 @@ impl Game<'_> {
});
let hover = ui::hovered(self.cursor, dx, dy, 200.0, 20.0) || is_focused;
let label = Text::translate("gui.done", &[]);
ui::button(batch, atlas, dx, dy, 200.0, 20.0, &label, true, hover);
ui::action_button(
batch,
atlas,
horizon_client::widgets::Action::Done,
dx,
dy,
200.0,
20.0,
&label,
true,
hover,
);
}
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
@@ -887,6 +898,10 @@ impl Game<'_> {
}
if b == Button::Lock {
widgets::narratable(|| o.narration(b, [bx, by, 20.0, 20.0], tr).hovered(hover));
// An icon, but a button: the D-pad and Tab stop on it as on the others.
if active {
widgets::target([bx, by, 20.0, 20.0]);
}
let lock = if o.locked { "locked" } else { "unlocked" };
let state = match (active, hover) {
(false, _) => "_disabled",
@@ -971,7 +986,22 @@ impl Game<'_> {
let active = o.active(b);
let is_focused = o.focused_button == Some(b);
let hover = active && (ui::hovered(cursor, bx, by, bw, 20.0) || is_focused);
ui::button(batch, atlas, bx, by, bw, 20.0, &o.label(b), active, hover);
let action = match b {
Button::Apply => horizon_client::widgets::Action::Apply,
_ => horizon_client::widgets::Action::Cancel,
};
ui::action_button(
batch,
atlas,
action,
bx,
by,
bw,
20.0,
&o.label(b),
active,
hover,
);
}
}
+32
View File
@@ -197,6 +197,38 @@ impl Game<'_> {
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
self.drop_leash(id, drop, sound, false);
}
/// `Entity.dropAllLeashConnections`: every mob and boat leashed to the
/// acting player, inside the 32-block box around the player's centre,
/// drops its lead. Then `GameEvent.SHEAR`. Whether anything dropped.
/// The player is not `Leashable`, so there is no lead of their own.
pub(super) fn drop_all_leash_connections(&mut self) -> bool {
let center = self.player_center();
let mobs: Vec<u32> = self
.player_leashed(center)
.into_iter()
.map(|i| self.mobs[i].id)
.collect();
let boats = self.player_led_boats(center);
if mobs.is_empty() && boats.is_empty() {
return false;
}
for id in mobs {
// One snap is played by the caller (`SoundEvents.LEAD_BREAK`),
// not a break sound on each leashed entity.
self.break_leash(id, true, false);
}
self.drop_boat_leashes(&boats);
let at = self
.player
.as_ref()
.map_or([center.x, center.y, center.z], |p| {
[p.position.x, p.position.y, p.position.z]
});
// `gameEvent(GameEvent.SHEAR, null)`: the player is the position, not the source.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:shear", at, ctx);
true
}
/// `break_leash` by the acting player's use (untying it, shears).
fn break_leash_used(&mut self, id: u32, drop: bool) {
self.drop_leash(id, drop, false, true);
+2 -3
View File
@@ -492,9 +492,8 @@ impl Game<'_> {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
self.tick_world(running)
}
},
@@ -36,7 +36,7 @@ impl Game<'_> {
if progress != *last {
*last = progress;
if *seen {
self.experience_display.2 = self.ticks;
self.experience_display.2 = self.visual_ticks().max(1);
} else {
*seen = true;
}
@@ -48,13 +48,14 @@ impl Game<'_> {
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
)
}) {
self.experience_display.2 = self.ticks;
self.experience_display.2 = self.visual_ticks().max(1);
}
}
/// `Hud.willPrioritizeExperienceInfo`.
/// `Hud.willPrioritizeExperienceInfo`. A start of 0 is none: the stamps above are at least 1
/// (a new world under `HORIZON_STILL` is still at tick 0).
pub(super) fn experience_recent(&self) -> bool {
let (_, _, start) = self.experience_display;
start != 0 && start + 100 > self.ticks
start != 0 && start + 100 > self.visual_ticks()
}
/// The local player's UUID, as servers know it.
fn own_uuid(&self) -> u128 {
@@ -119,7 +120,7 @@ impl Game<'_> {
project: &project,
entity: &entity,
styles: &self.waypoint_styles,
tick: self.ticks,
tick: self.visual_ticks(),
};
self.waypoints.draw(&view)
}
+1 -1
View File
@@ -227,7 +227,7 @@ fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
let dd = xa * xa + za * za;
if dd < f64::from(1.0e-4f32) {
+79 -13
View File
@@ -148,19 +148,9 @@ impl Game<'_> {
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
/// carries on.
pub(super) fn tick_sound_engine(&mut self) {
for entity in self.audio.engine.bound_entities() {
let previous = entity.state();
if previous.removed {
continue;
}
let state = self.sound_entity_state(entity.id);
entity.update(
state.unwrap_or(horizon_client::sound_engine::SoundEntityState {
removed: true,
..previous
}),
);
}
update_bound_entities(self.audio.engine.bound_entities(), |id| {
self.sound_entity_state(id)
});
if self.pause != self.sound_paused {
if self.pause {
@@ -340,3 +330,79 @@ impl Game<'_> {
self.ambience.tick(&ctx, &mut self.audio);
}
}
/// Resolve each live entity once per sound tick, even when several sounds name it.
fn update_bound_entities(
entities: Vec<horizon_client::sound_engine::SoundEntity>,
mut resolve: impl FnMut(u32) -> Option<horizon_client::sound_engine::SoundEntityState>,
) {
use horizon_client::sound_engine::SoundEntityState;
let mut states = std::collections::HashMap::new();
for entity in entities {
let previous = entity.state();
if previous.removed {
continue;
}
let state = *states
.entry(entity.id)
.or_insert_with(|| resolve(entity.id));
entity.update(state.unwrap_or(SoundEntityState {
removed: true,
..previous
}));
}
}
#[cfg(test)]
mod bound_lookup_tests {
use super::update_bound_entities;
use horizon_client::sound_engine::{SoundEntity, SoundEntityState};
#[test]
fn simultaneous_sounds_share_a_lookup_but_follow_each_new_tick() {
let a = SoundEntity::new(7, [0.0; 3], false);
let b = SoundEntity::new(7, [1.0; 3], false);
let mut calls = Vec::new();
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
update_bound_entities(vec![a.clone(), b.clone()], |id| {
calls.push(id);
Some(SoundEntityState {
position,
silent: true,
removed: false,
})
});
assert_eq!(a.state().position, position);
assert_eq!(b.state().position, position);
assert!(a.state().silent && b.state().silent);
}
assert_eq!(calls, vec![7, 7]);
}
#[test]
fn missing_entities_stop_all_their_sounds_without_reviving_reused_ids() {
let a = SoundEntity::new(7, [1.0; 3], false);
let b = SoundEntity::new(7, [2.0; 3], false);
let mut calls = 0;
update_bound_entities(vec![a.clone(), b.clone()], |_| {
calls += 1;
None
});
assert_eq!(calls, 1);
assert!(a.state().removed && b.state().removed);
let replacement = SoundEntity::new(7, [3.0; 3], false);
update_bound_entities(vec![a.clone(), replacement.clone(), b.clone()], |_| {
calls += 1;
Some(SoundEntityState {
position: [4.0; 3],
silent: false,
removed: false,
})
});
assert_eq!(calls, 2);
assert!(a.state().removed && b.state().removed);
assert_eq!(a.state().position, [1.0; 3]);
assert_eq!(replacement.state().position, [4.0; 3]);
assert!(!replacement.state().removed);
}
}
@@ -248,9 +248,12 @@ impl BlockEntityDraw {
*pose *= m;
}
}
fn pose(&self) -> Mat4 {
pub(super) fn pose(&self) -> Mat4 {
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
}
pub(super) fn vertices_mut(&mut self) -> &mut Vec<Vertex> {
&mut self.vertices
}
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
/// vanilla's block entity models are (`Lighting`'s two lights).
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
@@ -18,13 +18,17 @@ impl Game<'_> {
/// stays open with nobody there. Tickets are kept until the owner lets go
/// or the server stops, not saved: keep what you hold (a level
/// attachment) and hold it again in `native_level_load`. True when the
/// set of owners changed; false for an owner without a namespace and off
/// a server. API @API:chunk_tickets@.
/// set of owners changed; false for an owner without a namespace, a chunk
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
let namespaced = owner
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced || !self.native_authoritative() {
// `/forceload`'s world: blocks -30,000,000 to 29,999,999, so a mod's
// corrupt saved list asks for nothing past the edge (where the ring's
// arithmetic would overflow).
let in_world = chunk.iter().all(|c| (-1_875_000..1_875_000).contains(c));
if !namespaced || !self.native_authoritative() || (hold && !in_world) {
return false;
}
let key = (chunk[0], chunk[1]);
@@ -392,4 +392,94 @@ mod client_tests {
assert_eq!(game.native_entity_position(stand), None);
assert_eq!(game.native_entity_kind(display), None);
}
/// #8589: the server sends entity event 42 (the raid sweat) for a villager;
/// the client must draw the particles, as it does for the events it let
/// through before. An event with no particle table row (2) draws nothing.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_villager_raid_sweat_event_draws_particles() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (pipe, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(pipe, "Steve").unwrap();
let builtin = game.remote.as_ref().unwrap().builtin.clone();
let kind = builtin
.get("minecraft:entity_type")
.unwrap()
.id("minecraft:villager")
.unwrap();
game.handle_packet(C::Login(play::Login {
player_id: 7,
hardcore: false,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: 8,
simulation_distance: 8,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: spawn("minecraft:overworld"),
online_mode: false,
enforces_secure_chat: false,
}))
.unwrap();
game.handle_packet(C::PlayerPosition(play::PlayerPosition {
teleport: 1,
position: [0.5, 65.0, 0.5],
delta: [0.0; 3],
yaw: 45.0,
pitch: 10.0,
relatives: 0,
}))
.unwrap();
game.handle_packet(C::AddEntity(play::AddEntity {
id: 21,
uuid: 0x0011_2233_4455_6677_8899_aabb_ccdd_eeff,
kind,
// Within 32 blocks of the camera, which starts at the origin: the
// particle engine drops a particle farther away than that.
position: [1.5, 1.0, 2.5],
velocity: [0.0; 3],
pitch: 0,
yaw: 64,
head_yaw: 64,
data: 0,
}))
.unwrap();
// `len` counts only particles already ticked in; `made` counts every
// particle created, which is what the event handler does at once.
let made = |g: &Game| g.particles.made().values().sum::<u64>();
let before = made(&game);
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 2 }))
.unwrap();
assert_eq!(made(&game), before, "event 2 has no particle row");
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 42 }))
.unwrap();
assert!(made(&game) > before, "event 42 draws the sweat");
}
}
+30 -1
View File
@@ -988,6 +988,7 @@ use super::{
native_mob_draw::{NativeMobRender, NativePlayerRender},
native_panel_recipes::RecipePage,
native_reload_listeners::NativeReloadListener,
native_world_draw::NativeWorldDraw,
};
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::{
@@ -1015,6 +1016,7 @@ use horizon_client::{
first_person::Hand,
health::{HurtSource, Hurting, Knock},
hud::{HudBatch, rgba},
hud_layout::HealthHud,
items::ItemStack,
keybinds,
lan::menu::Slots,
@@ -3304,6 +3306,14 @@ impl Game<'_> {
bounds,
);
}
/// Submit depth-tested world text and textured quads, after particles and
/// before the hand/HUD. The pose starts camera-relative; use
/// `draw.translate_world` for a world position. Called independently of
/// block entities and chunks, so transient overlays may outlive their
/// source block. API @API:world_canvas@.
pub(super) fn native_world_submit(&self, draw: &mut NativeWorldDraw) {
mod_api::dispatch_world_submit(self, mod_api::mods(), draw);
}
/// Draw the mob `mob` (its id, kind, custom name and the frame's body
/// and head turns and walk cycle, `LivingEntityRenderState`) on this
/// client with a model of your own, once a frame while it is in view
@@ -9100,6 +9110,24 @@ impl Game<'_> {
let screen = self.shown_native_screen()?;
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
}
/// Horizon's own health HUD state, for a mod answering
/// `native_health_hud`. `left_height`/`right_height` as they stand when
/// the hook returns place the selected item's name and the action bar
/// (vanilla's `Hud.leftHeight`/`rightHeight`): set them when you draw
/// more or fewer rows. `layout` draws Horizon's rows and advances their
/// frame state (the hearts' blink, the bubble pop sound), so call it at
/// most once a frame, and only when you keep some of its rows.
/// API @API:health_hud_state@.
pub(super) fn native_health_hud_state(&mut self) -> &mut HealthHud {
&mut self.health_hud
}
/// The current value, base plus modifiers, of this client's player's
/// attribute `name` (`minecraft:armor_toughness`), as vanilla's
/// `LivingEntity.getAttributeValue`. None with no player, or for an id
/// the player has no attribute for. API @API:health_hud_state@.
pub(super) fn native_local_attribute(&mut self, name: &str) -> Option<f64> {
self.player.as_mut()?.attribute(name).ok()
}
/// A data file of the game's resources, content mods' included
/// (`data/mymod/machines.json`), when there is one: what a mod's content
/// says, read at runtime. Only relative paths of plain names. API 20.
@@ -9244,10 +9272,11 @@ impl Game<'_> {
pub(super) fn native_chat(&mut self, text: &str) {
let focused = self.screen == Screen::Chat;
let line = Text::literal(text);
let ticks = self.visual_ticks();
let atlas = &self.gui.atlas;
self.ui
.chat
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
.add(atlas, line, None, ChatSource::Client, ticks, focused);
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
@@ -97,7 +97,9 @@ impl Game<'_> {
/// which a player has none of. API @API:host_bridge@.
pub(super) fn native_equipment(&self, slot: EquipmentSlot) -> Option<ItemStack> {
match slot {
EquipmentSlot::MainHand => self.held().cloned(),
// The selected slot, not `held`: that is the acting hand, the off hand's stack during
// the off-hand pass of a use (`with_hand(Hand::Off, ..)`).
EquipmentSlot::MainHand => self.inventory.get(self.selected).cloned().flatten(),
EquipmentSlot::OffHand => self.offhand.clone(),
EquipmentSlot::Head => self.armor[0].clone(),
EquipmentSlot::Chest => self.armor[1].clone(),
@@ -118,3 +120,64 @@ impl Game<'_> {
self.keys.contains(&KeyCode::ShiftLeft)
}
}
#[cfg(test)]
mod tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{first_person::Hand, items::ItemStack};
use horizon_world::player_inventory::EquipmentSlot;
/// `LivingEntity.getItemBySlot` doesn't depend on the hand acting: in the
/// off-hand pass of a use, the main hand is still the selected slot's.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn equipment_reads_the_main_hand_in_an_off_hand_pass() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let name = |stack: Option<ItemStack>| stack.map(|s| s.item.to_string());
game.inventory[game.selected] = Some(ItemStack::new("minecraft:stick", 1));
game.offhand = Some(ItemStack::new("minecraft:torch", 1));
for hand in [Hand::Main, Hand::Off] {
game.with_hand(hand, |g| {
assert_eq!(
name(g.native_equipment(EquipmentSlot::MainHand)).as_deref(),
Some("minecraft:stick"),
"{hand:?}"
);
assert_eq!(
name(g.native_equipment(EquipmentSlot::OffHand)).as_deref(),
Some("minecraft:torch"),
"{hand:?}"
);
});
}
game.inventory[game.selected] = None;
game.with_hand(Hand::Off, |g| {
assert!(g.native_equipment(EquipmentSlot::MainHand).is_none());
});
}
}
@@ -23,7 +23,9 @@ impl Game<'_> {
/// (`Block.dropResources(state, level, pos, blockEntity, null, tool)`):
/// the break particles and sound, the block's drops with what its block
/// entity copies into them (a shulker box's contents), experience for an
/// ore as with no tool, then the block's fluid in its place. `tool` picks
/// ore unless `tool` has silk touch (`EnchantmentHelper.processBlockExperience`),
/// then the block's fluid in its place. Blocks that pop off because of
/// the break drop as with no tool, and pop whatever `collect` says. `tool` picks
/// the drops as a player's held item does (silk touch, fortune, shears);
/// `None` is an empty hand. The mods' say on drops (`native_loot_drops`)
/// applies. With `collect`, the drops are returned and nothing is popped
@@ -47,7 +49,7 @@ impl Game<'_> {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let tool_kept = tool.cloned();
let mut drops = self.with_level(|b, level, ticks| {
level.drop_tool = tool_kept;
level.drop_tool = Some(((pos[0], pos[1], pos[2]), tool_kept));
level.drop_into = Some(Vec::new());
b.destroy_block(level, ticks, at, true);
level.drop_tool = None;
@@ -305,6 +305,7 @@ impl Game<'_> {
let (back, label) = self.back_button(layout);
let atlas = &self.gui.atlas;
let hover = inside(cursor, back);
horizon_client::widgets::declare_action(horizon_client::widgets::Action::Back, back, true);
let sprite = if hover {
"gui/sprites/widget/button_highlighted"
} else {
+29 -5
View File
@@ -21,6 +21,10 @@ struct SceneQuad {
uv: [[f32; 2]; 4],
color: u32,
normal: Vec3,
/// Dropped when it faces away: block model faces, and the quads a block
/// entity marks for culling as `entity.wgsl` does (`CULL_BACK`,
/// `SHADED_CULL_W`). Other quads draw both sides, as in the world.
cull: bool,
}
/// The GUI transform of `view` in the box `rect` (x0, y0, x1, y1): the view's
@@ -82,8 +86,14 @@ impl Game<'_> {
if !self.native_window() || !view.scale.is_finite() || view.scale <= 0.0 {
return;
}
let occupied: std::collections::HashMap<[i32; 3], _> =
blocks.iter().map(|b| (b.pos, b.state)).collect();
// A state the registry doesn't know (a mod's stale id) culls nothing
// instead of panicking in the block facts.
let registry = &self.pack.assets.registry;
let occupied: std::collections::HashMap<[i32; 3], _> = blocks
.iter()
.filter(|b| registry.state(b.state).is_some())
.map(|b| (b.pos, b.state))
.collect();
let solid = |pos: [i32; 3]| {
occupied.get(&pos).is_some_and(|&state| {
self.res.behaviours().facts(state).flags & state_flags::SOLID_RENDER != 0
@@ -140,6 +150,7 @@ impl Game<'_> {
}),
color,
normal,
cull: true,
});
}
if let Some(entity) = block.entity {
@@ -166,14 +177,27 @@ impl Game<'_> {
);
for q in placed.as_chunks::<4>().0 {
let corners = q.each_ref().map(|v| Vec3::from_array(v.pos));
let normal = (corners[1] - corners[0])
let [nx, ny, nz, w] = q[0].normal;
let mut normal = (corners[1] - corners[0])
.cross(corners[2] - corners[0])
.normalize_or_zero();
if normal == Vec3::ZERO {
normal = Vec3::new(f32::from(nx), f32::from(ny), f32::from(nz))
.normalize_or_zero();
}
// `entity.wgsl`: |w| >= 0.5 is shaded by the normal; smaller
// keeps the colour baked in (quads, cubes, items, text).
let unbaked = w.unsigned_abs() >= 64;
quads.push(SceneQuad {
corners,
uv: q.each_ref().map(|v| v.uv),
color: shaded(q[0].color, face_shade(normal)),
color: if unbaked {
shaded(q[0].color, face_shade(normal))
} else {
q[0].color
},
normal,
cull: (1..64).contains(&w) || (-126..0).contains(&w),
});
}
}
@@ -181,7 +205,7 @@ impl Game<'_> {
let m = scene_matrix(rect, view);
let mut seen: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = quads
.into_iter()
.filter(|q| m.transform_vector3(q.normal).z > 0.0)
.filter(|q| !q.cull || m.transform_vector3(q.normal).z > 0.0)
.map(|q| {
let p = q.corners.map(|c| m.transform_point3(c));
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
@@ -0,0 +1,255 @@
//! A native mod's world-space text and atlas quads, through the normal renderer.
use super::{Game, native_block_entity_draw::BlockEntityDraw};
use glam::{DVec3, Mat4, Vec3};
use horizon_client::{
font::Patch,
gfx::{WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY},
hud::{HudBatch, Rect, rgba},
mesher::Vertex,
text::Text,
};
use std::ops::{Deref, DerefMut};
/// One world frame, independently of block entities or source blocks. Its pose
/// starts camera-relative and shares `BlockEntityDraw`'s push/pop, translate,
/// rotate and scale operations, partial tick, game time and packed light.
/// `keep` does not cache a world canvas. API @API:world_canvas@.
pub struct NativeWorldDraw {
pub camera: [f64; 3],
draw: BlockEntityDraw,
patches: Vec<Patch>,
profiles: Vec<serde_json::Value>,
}
impl Deref for NativeWorldDraw {
type Target = BlockEntityDraw;
fn deref(&self) -> &Self::Target {
&self.draw
}
}
impl DerefMut for NativeWorldDraw {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.draw
}
}
impl NativeWorldDraw {
fn new(camera: [f64; 3], partial: f32, time: i64) -> Self {
Self {
camera,
draw: BlockEntityDraw::posed(
partial,
time,
240 | 240 << 8,
[0; 3],
Mat4::IDENTITY,
(Vec3::Y, Vec3::Y),
Vec::new(),
),
patches: Vec::new(),
profiles: Vec::new(),
}
}
/// Translate to a world position, subtracting the camera in f64 first.
/// Returns false without changing the pose for a non-finite offset.
pub fn translate_world(&mut self, position: [f64; 3]) -> bool {
let offset = (DVec3::from_array(position) - DVec3::from_array(self.camera)).as_vec3();
if !offset.is_finite() {
return false;
}
self.draw.translate(offset.to_array());
true
}
fn quad(&mut self, sprite: Rect, corners: [[f32; 3]; 4], uv: [f32; 4], color: [u8; 4]) -> bool {
let pose = self.draw.pose();
if !pose.is_finite()
|| !corners.iter().flatten().all(|v| v.is_finite())
|| !uv.iter().all(|v| v.is_finite())
{
return false;
}
let [u0, v0, u1, v1] = uv.map(|v| v / 16.0);
let points = corners.map(|point| pose.transform_point3(Vec3::from_array(point)));
if points.iter().any(|point| !point.is_finite()) {
return false;
}
let light = self.draw.light;
for (point, [u, v]) in points
.into_iter()
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
{
self.draw.vertices_mut().push(Vertex {
pos: point.to_array(),
uv: [
sprite.u0 + (sprite.u1 - sprite.u0) * u,
sprite.v0 + (sprite.v1 - sprite.v0) * v,
],
layer: WORLD_TEXT_OVERLAY,
color: u32::from_le_bytes(color),
light,
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
true
}
fn text(&mut self, batch: HudBatch, front: Option<usize>) {
let pose = self.draw.pose();
let light = self.draw.light;
if pose.is_finite() {
for (index, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
for i in horizon_client::text_display::CORNERS {
let vertex = quad[i];
self.draw.vertices_mut().push(Vertex {
pos: pose
.transform_point3(Vec3::new(vertex.pos[0], vertex.pos[1], vertex.depth))
.to_array(),
uv: vertex.uv,
color: vertex.color,
light,
layer: if front.is_some_and(|start| index * 6 >= start) {
WORLD_TEXT_POLYGON_OFFSET_OVERLAY
} else {
WORLD_TEXT_OVERLAY
},
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
}
}
self.patches.extend(batch.patches);
self.profiles.extend(batch.inline_profiles);
}
}
impl Game<'_> {
pub(super) fn native_world_geometry(
&mut self,
camera: DVec3,
partial: f32,
out: &mut Vec<Vertex>,
) {
if !horizon_client::gfx::native_render::world_drawn_hooked()
&& crate::mod_api::mods().is_empty()
{
return;
}
let mut draw = NativeWorldDraw::new(camera.to_array(), partial, self.world.game_time);
// NONE keeps these quads out of shader-pack entity programs.
self.mark_stage(out.len(), "NONE");
self.native_world_submit(&mut draw);
self.resolve_inline_profiles(&draw.profiles);
self.map_patches.extend(draw.patches);
out.extend(draw.draw.into_vertices());
}
/// Normal, depth-tested atlas quad under the current pose. UVs use 16
/// texels per sprite side; color is [red, green, blue, alpha]. No model
/// diffuse lighting is applied; `draw.light` still selects lightmap light.
/// False for an unknown sprite or non-finite geometry. API @API:world_canvas@.
pub(super) fn native_world_quad(
&self,
draw: &mut NativeWorldDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
color: [u8; 4],
) -> bool {
self.gui
.atlas
.sprites
.get(sprite)
.copied()
.is_some_and(|sprite| draw.quad(sprite, corners, uv, color))
}
/// Actual styled world font at (0,0), with ARGB default color and optional
/// eight-offset ARGB outline. Returns the text advance in font pixels.
/// The front of outlined text uses POLYGON_OFFSET. API @API:world_canvas@.
pub(super) fn native_world_text(
&self,
draw: &mut NativeWorldDraw,
text: &Text,
color: u32,
outline: Option<u32>,
) -> f32 {
let packed = |c: u32| rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, (c >> 24) as u8);
let mut batch = HudBatch::new([1, 1]);
let (advance, front) = if let Some(outline) = outline {
let (advance, front) = batch.text_styled_outline_world(
&self.gui.atlas,
text,
0.0,
0.0,
packed(color),
packed(outline),
);
(advance, Some(front))
} else {
(
batch.text_styled_world(&self.gui.atlas, text, 0.0, 0.0, packed(color), false),
None,
)
};
draw.text(batch, front);
advance
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn world_quad_preserves_sub_block_precision_rgba_uv_and_pose() {
let mut frame = NativeWorldDraw::new([30_000_000.125, 64.0, -30_000_000.5], 0.5, 123);
assert!(frame.translate_world([30_000_000.625, 65.0, -30_000_000.25]));
frame.scale([2.0, 3.0, 4.0]);
frame.light = 0x12_34;
assert!(frame.quad(
Rect {
w: 16,
h: 16,
u0: 0.25,
v0: 0.5,
u1: 0.75,
v1: 1.0
},
[
[0.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 0.0, 0.0]
],
[4.0, 0.0, 12.0, 8.0],
[12, 34, 56, 78]
));
let vertices = frame.draw.into_vertices();
assert_eq!(
vertices.iter().map(|v| v.pos).collect::<Vec<_>>(),
[
[0.5, 1.0, 0.25],
[0.5, 4.0, 0.25],
[2.5, 4.0, 0.25],
[2.5, 1.0, 0.25]
]
);
assert_eq!(
vertices.iter().map(|v| v.uv).collect::<Vec<_>>(),
[[0.375, 0.5], [0.375, 0.75], [0.625, 0.75], [0.625, 0.5]]
);
assert!(
vertices
.iter()
.all(|v| v.color.to_le_bytes() == [12, 34, 56, 78]
&& v.light == 0x12_34
&& v.normal == [0; 4])
);
}
#[test]
fn invalid_world_offsets_leave_the_pose_usable() {
let mut frame = NativeWorldDraw::new([0.0; 3], 0.0, 0);
assert!(!frame.translate_world([f64::NAN, 0.0, 0.0]));
assert!(!frame.translate_world([f64::MAX, 0.0, 0.0]));
assert!(frame.translate_world([1.0, 2.0, 3.0]));
assert_eq!(
frame.draw.pose().transform_point3(Vec3::ZERO),
Vec3::new(1.0, 2.0, 3.0)
);
}
}
@@ -501,6 +501,8 @@ impl Game<'_> {
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
);
r.set_hdr_lighting(s.horizon.hdr_lighting);
// Where it is offered (`Renderer::gpu_culling_offer`); unchanged, it does nothing.
let _ = r.set_gpu_culling(s.horizon.gpu_culling);
r.set_hdr(
s.horizon.hdr,
s.horizon.paper_white_nits() as f32,
+17
View File
@@ -28,6 +28,8 @@ pub(super) const HELP: &[&str] = &[
pub(super) struct Pads {
/// Splitscreen: this player's pad, read by `PadRouter` for every game, instead of the pads.
pub(super) feed: Option<PadState>,
/// The pad behind `feed`.
feed_source: Option<PadId>,
/// The real controllers and the DualSense's output.
#[cfg(feature = "controllers")]
devices: hardware::Devices,
@@ -70,6 +72,7 @@ struct Context {
focus_navigation: bool,
focused: bool,
osk: bool,
dialog: bool,
enabled: bool,
}
@@ -162,8 +165,18 @@ impl Pads {
pub(super) fn inject_button(&mut self, button: Button, down: bool) {
self.injected.set(button, down);
}
/// Splitscreen: this player's pad state, from `source`. Another pad, or
/// none: the old one's A going away isn't a release.
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
if self.feed_source != source {
self.feed_source = source;
self.menu.armed = None;
}
self.feed = Some(state);
}
/// A replay ended: let go of everything it held.
pub(super) fn clear_injected(&mut self) {
self.menu.armed = None;
self.injected = PadState::default();
}
@@ -193,6 +206,7 @@ impl Game<'_> {
focus_navigation: self.pad_navigation_mode(),
focused: self.focused,
osk: self.pad.menu.osk.is_some(),
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
enabled: self.settings.controller.enabled,
}
}
@@ -323,6 +337,8 @@ impl Game<'_> {
/// End held actions and captures before another context receives input.
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
// Before the releases below: a cancelled press must not click.
self.pad.menu.armed = None;
let held_sticks = [
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
!= [0.0; 2],
@@ -578,6 +594,7 @@ impl Game<'_> {
Ok(status)
}
"release" => {
self.pad.menu.armed = None;
self.pad.injected = PadState::default();
self.pad.taps.clear();
Ok(json!({ "ok": true }))
@@ -114,6 +114,7 @@ impl Pads {
let Some(gilrs) = self.devices.gilrs.as_mut() else {
return PadState::default();
};
let active = self.devices.active;
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
use gilrs::EventType as E;
match event {
@@ -141,6 +142,10 @@ impl Pads {
_ => {}
}
}
// Another pad, or none: its A going away isn't a release.
if self.devices.active != active {
self.menu.armed = None;
}
let Some(pad) = self
.devices
.active
@@ -0,0 +1,281 @@
//! The controller's side of the buttons screens declare (`widgets::Action`): which pad button does
//! which job, and the glyph on the button while the pad drives. A screen only declares; nothing
//! here names one (docs/controllers.md, "Adding a screen").
use super::{Game, Screen};
use horizon_client::{
gamepad::{Button, GlyphStyle},
hud::{HudAtlas, HudBatch, rgba},
lang,
widgets::{self, Action, DeclaredAction, Kind, Target},
};
/// The pad buttons that can do a declared job.
const ACTION_BUTTONS: [Button; 3] = [Button::West, Button::East, Button::North];
/// The declared button `button` does on this screen, if any.
fn action_for_button(button: Button, declared: &[DeclaredAction]) -> Option<DeclaredAction> {
let find = |action| {
declared
.iter()
.find(|d| d.action == action && d.enabled)
.copied()
};
let leave = || {
find(Action::Back)
.or_else(|| find(Action::Cancel))
.or_else(|| find(Action::Done))
};
match button {
Button::West => find(Action::Apply).or_else(|| find(Action::Options)),
Button::East => leave().or_else(|| find(Action::Exit)),
// B is taken by leaving the screen: Y goes to the exit.
Button::North => leave().and_then(|_| find(Action::Exit)),
_ => None,
}
}
/// Where a glyph `wide` goes on a button: inside its left end (or right), centred on its height.
/// `None` when the button is too small for it or isn't all in the view.
fn glyph_at(
[x, y, w, h]: [f32; 4],
wide: f32,
(view_w, view_h): (f32, f32),
right: bool,
) -> Option<(f32, f32)> {
const INSET: f32 = 4.0;
let fits = w >= wide + 2.0 * INSET && h >= 11.0;
let shown = x >= 0.0 && y >= 0.0 && x + w <= view_w && y + h <= view_h;
(fits && shown).then(|| {
let at = if right {
x + w - INSET - wide + 2.0
} else {
x + INSET
};
(at, y + ((h - 8.0) / 2.0).floor())
})
}
/// The button as printed on the pad, in its colour, on a dark key.
fn draw_glyph(
batch: &mut HudBatch,
atlas: &HudAtlas,
button: Button,
style: GlyphStyle,
x: f32,
y: f32,
) {
let (label, [r, g, b]) = (button.label(style), button.color(style));
let width = HudBatch::text_width(atlas, label).ceil() + 4.0;
batch.fill(atlas, x - 1.0, y - 2.0, width, 11.0, rgba(40, 40, 40, 255));
batch.text(atlas, label, x + 1.0, y, rgba(r, g, b, 255), false);
}
impl Game<'_> {
/// A key row waits for its key: B is Escape, which unbinds; its buttons aren't pressed.
fn pad_binding(&self) -> bool {
self.screen == Screen::Options
&& (self.options_waiting_for_key() || self.ui.options.selected_pad_action.is_some())
}
/// The declared buttons the pad can do now.
fn pad_actions(&self) -> Vec<DeclaredAction> {
if self.pad_binding() {
return Vec::new();
}
let mut declared = widgets::last_actions(self.key_screen());
// A split guest's Quit Game does nothing.
if self.options.split_guest && self.title.is_some() {
declared.retain(|d| d.action != Action::Exit);
}
declared
}
/// The job `button` does here, on the target that does it. `None` while it isn't on screen,
/// so B still falls back to Escape.
pub(super) fn pad_action_target(&self, button: Button) -> Option<(Action, Target)> {
let declared = action_for_button(button, &self.pad_actions())?;
let [x, y, w, h] = declared.rect;
let at = (x + w / 2.0, y + h / 2.0);
// A button that registers no focus target (a sign's Done) is still pressed.
let target = self
.pad_targets()
.into_iter()
.find(|t| {
let [x, y, w, h] = t.rect;
(x..x + w).contains(&at.0) && (y..y + h).contains(&at.1)
})
.unwrap_or(Target {
rect: declared.rect,
kind: Kind::Button,
});
Some((declared.action, target))
}
/// Declared buttons show their glyph: the pad drives, and a menu is open. Focus or crosshair.
pub(super) fn pad_action_glyphs_on(&self) -> bool {
self.pad.in_use
&& !self.pad.menu.keyboard
&& self.pad.menu.osk.is_none()
&& self.pad.menu.pos.is_some()
&& self.pad_in_menu()
}
/// Each pad button's glyph on the button it does, A on an exit the focus is already on: this
/// player's glyphs in this player's view. One that doesn't fit is left out.
fn pad_glyphs(&self, view: (f32, f32)) -> Vec<(Button, f32, f32)> {
if !self.pad_action_glyphs_on() {
return Vec::new();
}
let focus = self.pad.menu.focus.filter(|_| self.pad_focus_mode());
let (atlas, style) = (&self.gui.atlas, self.ui.options.pad_style);
let width = |button: Button| HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0;
let mut glyphs = Vec::new();
// A on a text field opens the keyboard: at its right end, its text stays in view.
if let Some(focus) = focus.filter(|f| f.kind == Kind::Field)
&& let Some((x, y)) = glyph_at(focus.rect, width(Button::South), view, true)
{
glyphs.push((Button::South, x, y));
}
let declared = self.pad_actions();
for button in ACTION_BUTTONS {
let Some(d) = action_for_button(button, &declared) else {
continue;
};
let glyph = if d.action == Action::Exit && focus.is_some_and(|f| f.rect == d.rect) {
Button::South
} else {
button
};
if let Some((x, y)) = glyph_at(d.rect, width(glyph), view, false) {
glyphs.push((glyph, x, y));
}
}
glyphs
}
pub(super) fn draw_pad_action_glyphs(&self, batch: &mut HudBatch) {
let (atlas, style) = (&self.gui.atlas, self.ui.options.pad_style);
for (button, x, y) in self.pad_glyphs((batch.width, batch.height)) {
draw_glyph(batch, atlas, button, style, x, y);
}
}
/// `pad status`: the glyphs drawn now and where.
pub(super) fn pad_glyphs_status(&self) -> Vec<serde_json::Value> {
let view = self.gui_scale().map_or((0.0, 0.0), |b| (b.width, b.height));
self.pad_glyphs(view)
.iter()
.map(|(button, x, y)| serde_json::json!({ "button": format!("{button:?}"), "at": [x, y] }))
.collect()
}
/// `pad status`: what this screen declared.
pub(super) fn pad_actions_status(&self) -> Vec<serde_json::Value> {
self.pad_actions()
.iter()
.map(|d| {
serde_json::json!({
"action": format!("{:?}", d.action),
"rect": d.rect,
"enabled": d.enabled,
})
})
.collect()
}
/// `pad status`: buttons labelled Done, Back or Cancel that didn't declare their job.
pub(super) fn pad_undeclared_buttons(&self) -> Vec<String> {
let names = [
("gui.done", "Done"),
("gui.back", "Back"),
("gui.cancel", "Cancel"),
]
.map(|(key, fallback)| lang::get_or(key, fallback));
let declared = widgets::last_actions(self.key_screen());
widgets::last_labelled()
.into_iter()
.filter(|w| names.contains(&w.label))
.filter(|w| !declared.iter().any(|d| d.rect == [w.x, w.y, w.w, w.h]))
.map(|w| w.label)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn declared(action: Action, enabled: bool) -> DeclaredAction {
DeclaredAction {
action,
rect: [0.0, 0.0, 10.0, 10.0],
enabled,
}
}
fn does(button: Button, on: &[DeclaredAction]) -> Option<Action> {
action_for_button(button, on).map(|d| d.action)
}
#[test]
fn b_leaves_the_screen_back_first() {
let on = [
declared(Action::Done, true),
declared(Action::Cancel, true),
declared(Action::Back, true),
];
assert_eq!(does(Button::East, &on), Some(Action::Back));
assert_eq!(does(Button::East, &on[..2]), Some(Action::Cancel));
assert_eq!(does(Button::East, &on[..1]), Some(Action::Done));
}
#[test]
fn a_disabled_button_does_nothing() {
let on = [
declared(Action::Apply, false),
declared(Action::Done, false),
];
assert_eq!(does(Button::West, &on), None);
assert_eq!(does(Button::East, &on), None);
}
#[test]
fn x_applies_or_opens_the_options() {
let on = [
declared(Action::Options, true),
declared(Action::Apply, true),
];
assert_eq!(does(Button::West, &on), Some(Action::Apply));
assert_eq!(does(Button::West, &on[..1]), Some(Action::Options));
}
#[test]
fn the_exit_is_b_with_nothing_to_leave_by_else_y() {
let title = [declared(Action::Exit, true)];
assert_eq!(does(Button::East, &title), Some(Action::Exit));
assert_eq!(does(Button::North, &title), None);
let pause = [declared(Action::Back, true), declared(Action::Exit, true)];
assert_eq!(does(Button::East, &pause), Some(Action::Back));
assert_eq!(does(Button::North, &pause), Some(Action::Exit));
}
#[test]
fn a_glyph_goes_on_a_button_it_fits_in_the_view() {
let view = (320.0, 240.0);
let button = [10.0, 20.0, 150.0, 20.0];
assert_eq!(glyph_at(button, 10.0, view, false), Some((14.0, 26.0)));
// A text field's is at its right end.
assert_eq!(glyph_at(button, 10.0, view, true), Some((148.0, 26.0)));
// Too narrow, too short, running off a small split view.
assert_eq!(glyph_at([10.0, 20.0, 15.0, 20.0], 10.0, view, false), None);
assert_eq!(glyph_at([10.0, 20.0, 150.0, 8.0], 10.0, view, false), None);
assert_eq!(glyph_at(button, 10.0, (120.0, 240.0), false), None);
}
#[test]
fn a_screen_that_declares_nothing_keeps_its_buttons() {
for button in ACTION_BUTTONS {
assert_eq!(does(button, &[]), None);
}
}
}
+75 -5
View File
@@ -60,7 +60,7 @@ struct Pending {
pub(super) struct MenuCursor {
/// Where the pad put the pointer (GUI pixels); `None` while the mouse
/// has it.
pos: Option<(f32, f32)>,
pub(super) pos: Option<(f32, f32)>,
last_poll: Option<Instant>,
/// The stick moved the cursor last poll (it settles when let go).
moving: bool,
@@ -73,12 +73,15 @@ pub(super) struct MenuCursor {
repeat: Option<(Button, Instant, bool)>,
/// Pointer buttons the pad holds down.
held: [bool; 2],
/// The focused button A went down on: letting go presses it, unless the
/// focus moved or the input was cancelled first.
pub(super) armed: Option<Rect>,
/// The OS pointer is hidden while the pad's cursor is drawn.
os_hidden: bool,
/// Polls since a menu opened (its widgets are drawn after one frame).
open_polls: u32,
/// Focus mode: the focused target as last drawn.
focus: Option<Target>,
pub(super) focus: Option<Target>,
/// The left stick's step direction last poll.
stick: Option<Button>,
pending: Option<Pending>,
@@ -92,12 +95,14 @@ pub(super) struct MenuCursor {
osk_dismissed: bool,
/// The keyboard drives the focus (`key_focus`: Tab, the arrows, Enter):
/// focus mode whatever the controller options say, the OS pointer shown.
keyboard: bool,
pub(super) keyboard: bool,
/// The focused slider's arrows change its value (`canChangeValue`).
slider_edit: bool,
/// What the last click landed on: vanilla focuses it, so the next Tab
/// or arrow goes on from there (`ContainerEventHandler.mouseClicked`).
origin: Option<Rect>,
/// A shortcut put the focus here: not where the screen reopens.
shortcut_focus: Option<Rect>,
/// A pointer event the pad or the keyboard sends is being handled.
synthetic: bool,
/// Where the real mouse last moved the pointer (window pixels).
@@ -120,9 +125,17 @@ impl MenuCursor {
self.origin = None;
self.slider_edit = false;
self.pending = None;
self.armed = None;
}
}
/// Where letting go of A clicks: the armed button, if the focus is still on it.
fn armed_click(armed: Option<Rect>, focus: Option<Target>) -> Option<(f32, f32)> {
focus
.filter(|focus| armed == Some(focus.rect))
.map(|focus| focus.center())
}
impl Game<'_> {
/// A menu, a screen, the title, a sign or a book is open: the pad drives
/// it instead of the player.
@@ -152,7 +165,7 @@ impl Game<'_> {
!pointer
}
fn pad_focus_mode(&self) -> bool {
pub(super) fn pad_focus_mode(&self) -> bool {
self.pad.menu.keyboard || self.pad_navigation_mode()
}
@@ -160,7 +173,7 @@ impl Game<'_> {
/// target (sliders, fields, rows, keyboard keys), and the slots
/// and tabs of inventories (GUI pixels), on screen. The keyboard's
/// focus skips what vanilla's keyboard can't reach (`key_targets`).
fn pad_targets(&self) -> Vec<Target> {
pub(super) fn pad_targets(&self) -> Vec<Target> {
if self.pad.menu.keyboard {
self.key_targets()
} else {
@@ -401,6 +414,8 @@ impl Game<'_> {
}
fn pad_wheel(&mut self, event_loop: &dyn EventLoopCtl, x: f32, y: f32) {
// Another row can scroll under the armed button's rect.
self.pad.menu.armed = None;
let Some(id) = self.window.as_ref().map(|w| w.id()) else {
return;
};
@@ -463,6 +478,12 @@ impl Game<'_> {
) -> Result<()> {
if !down {
match button {
Button::South if self.pad.menu.armed.is_some() => {
let armed = self.pad.menu.armed.take();
if let Some(at) = armed_click(armed, self.pad.menu.focus) {
self.pad_click_at(event_loop, at, false);
}
}
Button::South if self.pad.menu.held[0] => {
self.pad_pointer_button(event_loop, false, false)
}
@@ -519,6 +540,24 @@ impl Game<'_> {
}
return Ok(());
}
if let Some((action, target)) = self.pad_action_target(button) {
if action == widgets::Action::Exit {
self.pad_focus_on(event_loop, target);
self.pad.menu.shortcut_focus = Some(target.rect);
} else {
self.pad_click_at(event_loop, target.center(), false);
}
return Ok(());
}
// A with nothing focused lands the focus, as the D-pad does: nothing
// fires on a widget the player hasn't seen highlighted.
if button == Button::South && self.pad_focus_mode() && self.pad.menu.focus.is_none() {
let targets = self.pad_targets();
if let Some(first) = self.pad_first_target(&targets) {
self.pad_focus_on(event_loop, first);
return Ok(());
}
}
match button {
Button::East | Button::Start => return self.pad_back(),
Button::South if self.pad_focusing() => {
@@ -530,6 +569,10 @@ impl Game<'_> {
self.pad_click_at(event_loop, focus.center(), false);
self.pad_open_keyboard();
}
Kind::Button => {
self.pad_pointer_to(event_loop, focus.center());
self.pad.menu.armed = Some(focus.rect);
}
_ => {
self.pad_pointer_to(event_loop, focus.center());
self.pad_pointer_button(event_loop, false, true);
@@ -718,9 +761,11 @@ impl Game<'_> {
}
fn pad_focus_on(&mut self, event_loop: &dyn EventLoopCtl, target: Target) {
self.pad.menu.shortcut_focus = None;
let left = self.pad.menu.focus.replace(target);
if left.is_some_and(|l| l.rect != target.rect) {
self.clear_screen_click_focus();
self.pad.menu.armed = None;
}
self.pad.menu.pending = None;
self.pad.menu.nudge = None;
@@ -744,6 +789,9 @@ impl Game<'_> {
let Some(focus) = self.pad.menu.focus else {
return;
};
if self.pad.menu.shortcut_focus.take() == Some(focus.rect) {
return;
}
let remembered = &mut self.pad.menu.remembered;
if remembered.len() > 32 && !remembered.contains_key(&screen) {
remembered.clear();
@@ -1107,6 +1155,9 @@ impl Game<'_> {
"focus": self.pad.menu.focus.as_ref().map(target),
"keyboard": self.pad.menu.osk.is_some(),
"targets": self.pad_targets().iter().map(target).collect::<Vec<_>>(),
"actions": self.pad_actions_status(),
"glyphs": self.pad_glyphs_status(),
"undeclared": self.pad_undeclared_buttons(),
"screen_key": format!("{:016x}", self.key_screen()),
"remembered": self
.pad
@@ -1598,6 +1649,8 @@ impl Game<'_> {
/// The pad's cursor (pointer mode) or focus highlight (focus mode), and
/// the on-screen keyboard, over everything else.
pub(super) fn draw_pad_cursor(&self, batch: &mut HudBatch) {
widgets::set_action_glyphs(self.pad_action_glyphs_on());
widgets::set_action_screen(self.key_screen());
if !self.pad_in_menu() {
return;
}
@@ -1624,8 +1677,10 @@ impl Game<'_> {
&& let Some(focus) = self.pad.menu.focus
{
self.draw_pad_focus(batch, focus);
self.draw_pad_action_glyphs(batch);
return;
}
self.draw_pad_action_glyphs(batch);
// A crosshair centred on the point it clicks: inventories, and a
// screen with nothing to focus.
use horizon_client::pad_cursor;
@@ -1686,6 +1741,21 @@ mod tests {
assert!(!osk_auto_opens(false, false, true, 5, false));
}
#[test]
fn armed_button_clicks_only_while_still_focused() {
let rect = [10.0, 20.0, 100.0, 20.0];
let kind = Kind::Button;
let focus = Some(Target { rect, kind });
assert_eq!(armed_click(Some(rect), focus), Some((60.0, 30.0)));
let moved = Some(Target {
rect: [10.0, 44.0, 100.0, 20.0],
kind,
});
assert_eq!(armed_click(Some(rect), moved), None);
assert_eq!(armed_click(Some(rect), None), None);
assert_eq!(armed_click(None, focus), None);
}
#[test]
fn clear_focus_leaves_nothing_focused() {
let mut menu = MenuCursor {
@@ -498,7 +498,10 @@ impl Game<'_> {
m.previous_limb_speed = m.limb_speed;
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
m.age = m.age.wrapping_add(1);
// Its idle swing holds with the world under `HORIZON_STILL`.
if self.clock.runs_normally() || !self.clock.still() {
m.age = m.age.wrapping_add(1);
}
m.anim.attack_anim_o = attack.0;
m.anim.attack_anim = attack.1;
m.extra.set_main_hand_stack(main);
+189 -64
View File
@@ -171,8 +171,9 @@ fn hurt_sound_for(
fn entity_sound_state(
id: i32,
player: Option<(i32, [f64; 3], bool)>,
mobs: &[Mob],
mob: Option<horizon_client::sound_engine::SoundEntityState>,
tracked: &HashMap<i32, Tracked>,
dragon: Option<(i32, [f64; 3])>,
) -> Option<horizon_client::sound_engine::SoundEntityState> {
use horizon_client::sound_engine::SoundEntityState;
if let Some((player_id, position, silent)) = player
@@ -184,18 +185,20 @@ fn entity_sound_state(
removed: false,
});
}
if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) {
return Some(SoundEntityState {
position: [m.position.x, m.position.y, m.position.z],
silent: m.silent,
removed: m.removed,
});
let mut state = mob.or_else(|| {
tracked.get(&id).map(|t| SoundEntityState {
position: t.position,
silent: t.silent,
removed: false,
})
})?;
// The dragon's Mob is metadata storage; only Dragon follows its movement.
if let Some((dragon_id, position)) = dragon
&& dragon_id == id
{
state.position = position;
}
tracked.get(&id).map(|t| SoundEntityState {
position: t.position,
silent: t.silent,
removed: false,
})
Some(state)
}
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
@@ -489,6 +492,9 @@ pub(super) struct Remote {
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
/// The game's enchantments and the server's that it lacks (a data
/// pack's), from their synced data; `None` when the server has none.
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
pub(super) joined: bool,
/// `ServerReconfigScreen` is up since this tick.
pub(super) reconfiguring: Option<u64>,
@@ -776,7 +782,7 @@ impl Game<'_> {
}
}
pub(super) fn remote_sound_entity_state(
&self,
&mut self,
id: i32,
) -> Option<horizon_client::sound_engine::SoundEntityState> {
let r = self.remote.as_ref()?;
@@ -787,9 +793,29 @@ impl Game<'_> {
r.local_entity_silent,
)
});
let mut state = entity_sound_state(id, player, &self.mobs, &r.entities)?;
// Vehicles, projectiles and other simulated objects may have moved since
// the last tracking packet, just as the mob interpolation has.
// The local player is not in mobs, so avoid a guaranteed index miss.
if player.is_some_and(|p| p.0 == id) {
return entity_sound_state(id, player, None, &r.entities, None);
}
let mob = self.mob_index.get(&self.mobs, id as u32).map(|m| {
horizon_client::sound_engine::SoundEntityState {
position: [m.position.x, m.position.y, m.position.z],
silent: m.silent,
removed: m.removed,
}
});
let dragon = self
.dragon
.as_ref()
.map(|d| (d.id as i32, [d.position.x, d.position.y, d.position.z]));
let mut state = entity_sound_state(id, player, mob, &r.entities, dragon)?;
// Mob states already use the live position. In particular,
// level_entity would find the dragon's stale Mob before its Dragon.
if mob.is_some() || dragon.is_some_and(|d| d.0 == id) {
return Some(state);
}
// Vehicles, projectiles and other simulated objects may have moved
// since the last tracking packet.
if let Some(position) = self
.level_entity(id)
.and_then(|target| self.target_position(target))
@@ -1044,6 +1070,7 @@ impl Game<'_> {
.unwrap_or_else(|| "Connecting to the server...".into()),
lang,
registries: Arc::new(Dynamic::default()),
enchantments: None,
joined: false,
reconfiguring: None,
logged_in: false,
@@ -1456,7 +1483,7 @@ impl Game<'_> {
content,
signature,
trust.tag(),
self.ticks,
self.visual_ticks(),
focused,
);
} else {
@@ -1468,7 +1495,7 @@ impl Game<'_> {
content,
indicator,
source,
self.ticks,
self.visual_ticks(),
focused,
);
}
@@ -1594,9 +1621,10 @@ impl Game<'_> {
r.chat_queue
.retain(|chat| chat.signature.as_ref() != Some(&signature));
if r.chat_queue.len() == queued {
let ticks = self.visual_ticks();
self.ui
.chat
.delete_message(&self.gui.atlas, &signature, self.ticks);
.delete_message(&self.gui.atlas, &signature, ticks);
}
Ok(())
}
@@ -2151,7 +2179,25 @@ impl Game<'_> {
.collect()
})
.unwrap_or_default();
let entries: Vec<(&str, serde_json::Value)> = registries
.registries
.get("minecraft:enchantment")
.into_iter()
.flatten()
.filter_map(|(name, data)| {
Some((
name.as_str(),
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
let enchantments = self
.stations()
.enchantments
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
.map(Arc::new);
if let Some(r) = self.remote.as_mut() {
r.enchantments = enchantments;
let bits = ceil_log2(biomes.len());
let column = r.chunk_format.layout();
r.chunk_format =
@@ -3205,13 +3251,9 @@ impl Game<'_> {
false,
horizon_client::level_loading::Reason::Other,
);
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
if let Some(r) = self.remote.as_mut() {
r.sent_info = info.clone();
}
self.send(S::ClientInformation(info));
// No ClientInformation here: the configuration phase sent it,
// and ClientPacketListener.broadcastClientInformation sends it
// again only when an option changes it.
self.remote_chat_login();
}
C::Respawn(p) => {
@@ -4241,11 +4283,11 @@ impl Game<'_> {
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
self.poof_particles(p.id as u32);
}
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
// 7, Animal and Allay 18: the particles about the mob.
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18 | 40 | 41) {
self.entity_event_particles(p.id as u32, p.event);
}
// The particle bursts of Villager 12 to 14 and 42, TamableAnimal
// and AbstractHorse 6 and 7, Animal and Allay 18, Ocelot 40 and
// 41, Dolphin 38: the table in `entity_event_burst` is the one
// list of them; any other event draws nothing here.
self.entity_event_particles(p.id as u32, p.event);
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
// the projectile is (the server removes it right after).
if p.event == 3 {
@@ -4272,9 +4314,10 @@ impl Game<'_> {
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
// Entity events 53 and 54: honey crumbs while sliding, and the
// ten from a landing (`HoneyBlock.showSlideParticles` /
// `showJumpParticles`).
if horizon_client::landing::honey_particle_count(p.event).is_some() {
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.player
.as_ref()
@@ -4286,7 +4329,7 @@ impl Game<'_> {
.map(|m| [m.position.x, m.position.y, m.position.z])
};
if let Some(at) = at {
self.honey_slide_particles(at);
self.honey_entity_particles(at, p.event);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
@@ -5633,7 +5676,10 @@ impl Game<'_> {
return;
};
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
// ItemEntity's constructor draws bobOffs from the item's own random.
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
* std::f32::consts::PI
* 2.0;
self.dropped.push(ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
@@ -5929,7 +5975,7 @@ impl Game<'_> {
self.tick_chat_session();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
if self.remote.as_ref().is_some_and(Remote::has_level) {
self.send(S::ClientTickEnd(Empty));
}
Ok(())
@@ -5939,7 +5985,8 @@ impl Game<'_> {
self.ticks += 1;
self.repro_tick();
// Hud.tick ends with ChatComponent.tick.
self.ui.chat.tick(&self.gui.atlas, self.ticks);
let chat_ticks = self.visual_ticks();
self.ui.chat.tick(&self.gui.atlas, chat_ticks);
self.tick_sign_editor();
self.track_experience_display();
self.tick_enchanting_book();
@@ -5961,7 +6008,13 @@ impl Game<'_> {
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
// Under `HORIZON_STILL` the frame's countdowns (this one, the hotbar's pop, the HUD's
// below) wait for the world, as the border's size change does in any frozen level: a
// frozen client keeps ticking, as many ticks as the run took.
let hold = !running && self.clock.still();
if !hold {
self.tick_item_activation();
}
if running {
self.world.game_time += 1;
self.remote_clock_tick();
@@ -5986,11 +6039,15 @@ impl Game<'_> {
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
if !hold {
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
}
}
// ClientLevel.tick: the border's size change, while the level runs.
if running {
self.border.tick();
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_random_textures();
@@ -6018,12 +6075,22 @@ impl Game<'_> {
if running {
self.tick_particles();
}
self.tick_client_ambient();
self.tick_client_effects();
// Under `HORIZON_STILL` they wait for the world too: endermen's and blazes' particles
// draw on the particles' random, and effects count down, on every tick a frozen
// client runs, as many as the run took to get there.
if !hold {
self.tick_client_ambient();
self.tick_client_effects();
}
// Player.tick's sleep counter (the darkening and the wake-up fade).
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
if !hold {
self.tick_hud();
} else {
// Only its countdowns wait: lying down still opens the bed's chat, waking closes it.
self.tick_in_bed_chat();
}
self.tick_subtitles();
self.tick_client_load();
let entity_collisions = self.player_entity_collisions();
@@ -6291,9 +6358,10 @@ impl Game<'_> {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
// LivingEntity.causeFallDamage on the client's own level: the
// fall sound is heard here (LocalPlayer.playSound); the
// server's copy skips this player (Player.playSound).
// LivingEntity.causeFallDamage and HoneyBlock.fallOn on the
// client's own level: the fall and honey sounds are heard here
// (LocalPlayer.playSound); the server's copy skips this player
// (Player.playSound).
horizon_client::motion::MotionEvent::Fall(
horizon_client::landing::FallEvent::Sound {
sound,
@@ -6302,7 +6370,10 @@ impl Game<'_> {
..
},
) => {
if let Some(name) = sound.player_fall_sound() {
if let Some(name) = sound
.player_fall_sound()
.or_else(|| sound.honey_landing_sound())
{
self.audio.play(name, Some(feet), volume, pitch);
}
}
@@ -6976,6 +7047,8 @@ impl Game<'_> {
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.flash = self.flash.saturating_sub(1);
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
@@ -7039,8 +7112,7 @@ impl Game<'_> {
},
);
self.tick_remote_projectiles(&mut step);
// LightningBolt.tick on a client, and the sky flash fading.
self.flash = self.flash.saturating_sub(1);
// LightningBolt.tick on a client.
self.tick_bolts();
}
lerps.retain(|_, l| l.steps > 0);
@@ -7656,7 +7728,11 @@ impl Game<'_> {
.is_some_and(|r| r.level_players.contains_key(&id));
if !player && self.remote_mob_takes(i, hand) {
self.remote_feed_particles(i, hand);
self.with_hand(hand, Game::swing_arm);
// As for the objects above, only SUCCESS swings the hand: a
// CONSUME ends the click without one.
if self.remote_mob_swings(i, hand) {
self.with_hand(hand, Game::swing_arm);
}
return true;
}
}
@@ -8531,8 +8607,8 @@ impl Game<'_> {
}
// TridentItem.use: not about to break, Riptide only in water or rain.
"minecraft:trident" => {
self.use_trident(held);
return true;
// FAIL: no dip, and the off hand gets its turn.
return self.use_trident(held);
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
@@ -10635,6 +10711,13 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
}
impl Remote {
/// `Minecraft.level != null`: from `handleLogin` until the next
/// `handleConfigurationStart` (`clearClientLevel`). Minecraft.tick sends
/// CLIENT_TICK_END only then, so a client waiting for the server's Login
/// (a busy server takes seconds to place a new player) sends nothing.
pub(super) fn has_level(&self) -> bool {
client_has_level(self.logged_in, self.reconfiguring)
}
/// The epoch of this client level's entity handles (`native_epoch`).
pub(super) fn native_epoch(&self) -> Option<horizon_client::mod_api::NativeWorldEpoch> {
self.native_epoch
@@ -10810,11 +10893,18 @@ fn hold_server_fire(fire_ticks: i32, server_on_fire: bool) -> i32 {
}
}
/// [`Remote::has_level`]: logged in (a Login arrived) and no reconfiguration
/// since (`reconfiguring` is set by StartConfiguration, cleared by the next
/// Login).
fn client_has_level(logged_in: bool, reconfiguring: Option<u64>) -> bool {
logged_in && reconfiguring.is_none()
}
#[cfg(test)]
mod tests {
use super::{
boat_interact_passes, hold_server_fire, item_frame_interact_passes, keep_client_state,
remote_object_swings, remote_use_swings, synced_local_use,
boat_interact_passes, client_has_level, hold_server_fire, item_frame_interact_passes,
keep_client_state, remote_object_swings, remote_use_swings, synced_local_use,
};
use horizon_client::{
containers::{Beacon, BlockEntity},
@@ -10955,6 +11045,16 @@ mod tests {
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
}
/// Minecraft.tick: CLIENT_TICK_END only while a level exists. None before
/// the first Login (configuration has finished, the server has not placed
/// the player yet), and none from StartConfiguration until the next Login.
#[test]
fn tick_ends_need_the_level_the_login_makes() {
assert!(!client_has_level(false, None), "joined, waiting for Login");
assert!(client_has_level(true, None), "after Login");
assert!(!client_has_level(true, Some(40)), "reconfiguring");
}
/// The server's on-fire flag keeps the predicted countdown (which
/// `fluid_base_tick` runs down) from ending the fire; a clear flag leaves
/// the window's own contact fire alone.
@@ -11393,28 +11493,28 @@ mod tests {
);
let player = Some((2, [0.5, 70.0, 0.5], false));
assert_eq!(
super::entity_sound_state(2, player, &[], &tracked)
super::entity_sound_state(2, player, None, &tracked, None)
.filter(|s| !s.silent)
.map(|s| s.position),
Some([0.5, 70.0, 0.5]),
"the local player is named by Remote.entity_id"
);
assert_eq!(
super::entity_sound_state(7, player, &[], &tracked)
super::entity_sound_state(7, player, None, &tracked, None)
.filter(|s| !s.silent)
.map(|s| s.position),
Some([8.5, 64.0, -3.5]),
"another entity the server spawned"
);
assert_eq!(
super::entity_sound_state(9, player, &[], &tracked)
super::entity_sound_state(9, player, None, &tracked, None)
.filter(|s| !s.silent)
.map(|s| s.position),
None,
"getEntity is null: handleSoundEntityEvent returns"
);
assert_eq!(
super::entity_sound_state(2, None, &[], &tracked)
super::entity_sound_state(2, None, None, &tracked, None)
.filter(|s| !s.silent)
.map(|s| s.position),
None,
@@ -11433,15 +11533,40 @@ mod tests {
silent: true,
},
);
let state = super::entity_sound_state(7, None, &[], &tracked).unwrap();
let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap();
assert!(state.silent);
assert!(!state.removed);
assert_eq!(state.position, [8.5, 64.0, -3.5]);
let player =
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), &[], &tracked).unwrap();
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), None, &tracked, None)
.unwrap();
assert!(player.silent);
assert!(super::entity_sound_state(9, None, &[], &tracked).is_none());
assert!(super::entity_sound_state(9, None, None, &tracked, None).is_none());
}
#[test]
fn a_dragon_sound_uses_live_movement_without_losing_mob_metadata() {
use horizon_client::sound_engine::SoundEntityState;
let stale = SoundEntityState {
position: [0.0, 64.0, 0.0],
silent: true,
removed: false,
};
let tracked = std::collections::HashMap::new();
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
let state =
super::entity_sound_state(7, None, Some(stale), &tracked, Some((7, position)))
.unwrap();
assert_eq!(state.position, position);
assert!(state.silent);
assert!(!state.removed);
}
let unrelated =
super::entity_sound_state(7, None, Some(stale), &tracked, Some((8, [99.0; 3])))
.unwrap();
assert_eq!(unrelated.position, stale.position);
assert!(super::entity_sound_state(7, None, None, &tracked, Some((7, [99.0; 3]))).is_none());
}
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
@@ -412,8 +412,9 @@ impl Game<'_> {
&[],
),
];
self.command_prompt_layout(prompt, (w, h))
.draw(batch, atlas, self.cursor, &labels);
let layout = self.command_prompt_layout(prompt, (w, h));
layout.declare_no(1);
layout.draw(batch, atlas, self.cursor, &labels);
}
/// The confirmation's answer; on no, what was behind it is back.
@@ -148,6 +148,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::Mooshroom, 18, Value::Int(variant)) => {
m.extra.variant = (*variant == 1) as u8
}
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (the first entry
// after AgeableMob's two; the variant and DATA_SUFFOCATING are 19).
// ItemBasedSteering.onSynced: boosting starts, boostTime is 0, and
// the total is this int (140 to 980 from boost()).
(Species::Pig | Species::Strider, 18, Value::Int(total)) => {
m.extra.counter = 0;
m.extra.counter2 = *total;
}
// Piglin.DATA_IS_DANCING: the dance pose.
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
@@ -1430,4 +1438,66 @@ mod tests {
// Last fuse tick: still primed at 0 until the server removes it.
assert_eq!(horizon_client::mobs::sulfur_cube::fuse(&m), 0);
}
/// `Pig.onSyncedDataUpdated` and `Strider.onSyncedDataUpdated`: a synced
/// `DATA_BOOST_TIME` (index 18, after AgeableMob's two) calls
/// `ItemBasedSteering.onSynced`. Boosting starts, `boostTime` is 0, and
/// the total is the int (`boost()`'s minimum is 140). The next
/// `tickBoost` then `boostFactor` is `1 + 1.15 * sin(boostTime / total * PI)`.
#[test]
fn a_synced_boost_starts_the_client_timer() {
use horizon_client::mobs::MobTypes;
use horizon_core::geometry::trig::Mth;
use std::{fs, sync::Arc};
let root = std::env::temp_dir().join(format!("horizon-boost-sync-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("entity-types.json"),
r#"{"minecraft:pig": {}, "minecraft:strider": {}}"#,
)
.unwrap();
fs::write(
root.join("entity-models.json"),
r#"{"minecraft:pig#main": {}, "minecraft:strider#main": {}}"#,
)
.unwrap();
let mut types = MobTypes::load(&root).unwrap();
fs::remove_dir_all(root).unwrap();
// ItemBasedSteering: nextInt(841) + 140, and 1.15 in boostFactor.
const TOTAL: i32 = 140;
const FACTOR: f32 = 1.15;
for (kind, steer) in [
("minecraft:pig", 0.225_f64),
("minecraft:strider", 0.55_f64),
] {
let t = Arc::new(types.types.remove(kind).unwrap());
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
m.attrs.speed = 0.25;
m.extra.saddled = true;
m.extra.counter = 5;
m.extra.counter2 = 3;
// The next entry (pig variant, strider suffocating) is not the boost.
apply(&mut m, &[(19, s::INT, Value::Int(TOTAL))]);
assert_eq!((m.extra.counter, m.extra.counter2), (5, 3), "{kind}");
apply(&mut m, &[(18, s::INT, Value::Int(TOTAL))]);
assert_eq!(m.extra.counter, 0, "{kind}: onSynced sets boostTime to 0");
assert_eq!(m.extra.counter2, TOTAL, "{kind}: the synced total");
m.rider = Some(equine::Rider {
steer: true,
..Default::default()
});
equine::ridden_step(&mut m);
// tickBoost post-increments, then boostFactor reads boostTime 1.
let boost = 1.0 + FACTOR * (1.0_f32 / TOTAL as f32 * std::f32::consts::PI).msin();
let expected = (0.25_f64 * steer) as f32 * boost;
assert_eq!(m.extra.counter, 1, "{kind}");
assert!(
(m.ai.speed - expected).abs() < 1.0e-5,
"{kind}: {} != {expected}",
m.ai.speed
);
}
}
}
@@ -132,6 +132,23 @@ impl Game<'_> {
_ => false,
}
}
/// Whether that answer is `InteractionResult.SUCCESS`, the only result that
/// swings the hand: `CONSUME` ends the click without one. An adult animal's
/// food (`Animal.mobInteract`), a zombie villager's golden apple and a
/// sheep's shears (the server alone checks `readyForShearing`) answer
/// CONSUME on a client, and a horse's or panda's food SUCCESS_SERVER
/// (#9437). A tame wolf, cat or parrot sitting down is SUCCESS.
pub(super) fn remote_mob_swings(&self, i: usize, hand: Hand) -> bool {
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
return false;
};
let stack = match hand {
Hand::Main => self.stack_in(self.selected),
Hand::Off => self.offhand.as_ref(),
};
let item = stack.map_or("", |s| &*s.item);
mob_swings(m, item)
}
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
/// client's copy: a baby fed something that ages it draws one happy
/// villager particle. The server draws it on its own level only, so no
@@ -208,6 +225,48 @@ pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
}
}
/// [`Game::remote_mob_swings`] for mob `m` and the held `item`.
fn mob_swings(m: &Mob, item: &str) -> bool {
let food = m.kind.food.iter().any(|f| f.as_str() == item);
let hurt = m.health < m.attrs.max_health;
match m.species() {
// AbstractHorse.fedFood (Camel's, Llama's too): SUCCESS_SERVER,
// which swings on the server's side only.
s if food
&& (equine::is_equine(s)
|| llama::is_llama(s)
|| matches!(s, Species::Camel | Species::CamelHusk)) =>
{
return false;
}
// Panda.mobInteract: its bamboo answers SUCCESS_SERVER.
Species::Panda if food => return false,
// Cat.mobInteract: food tames an untamed cat, and heals a hurt
// tame one: SUCCESS before Animal.mobInteract is reached.
Species::Cat if food && (!m.extra.tame || hurt) => return true,
// Wolf.mobInteract: food heals a hurt tame wolf (SUCCESS).
Species::Wolf if food && m.extra.tame && hurt => return true,
_ => {}
}
// Animal.mobInteract: a baby's food is SUCCESS, since it ages up; an
// adult's is CONSUME, as only the server knows whether it can breed.
if m.kind.flags.animal
&& !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus)
&& food
{
return m.is_baby() && !m.extra.age_locked;
}
// A tame wolf, cat or parrot sits or stands up: SUCCESS
// (TamableAnimal's subclasses), so it swings.
let consumes = match m.species() {
// ZombieVillager.mobInteract: a golden apple cures it on the server.
Species::ZombieVillager => item == "minecraft:golden_apple",
// Sheep.mobInteract: shears, sheared or not.
Species::Sheep => item == "minecraft:shears",
_ => false,
};
!consumes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -230,6 +289,50 @@ mod tests {
))
}
/// Tamables' and horses' own `mobInteract` answer before `Animal.mobInteract`'s
/// CONSUME (#9450): sitting down and healing are SUCCESS, a horse's food
/// SUCCESS_SERVER.
#[test]
fn only_a_client_success_swings_the_hand() {
let Some(mut wolf) = mob("minecraft:wolf") else {
return;
};
wolf.extra.tame = true;
assert!(
mob_swings(&wolf, ""),
"a tame wolf sits: SUCCESS.withoutItem"
);
assert!(
!mob_swings(&wolf, "minecraft:beef"),
"full health: Animal's CONSUME"
);
wolf.health = wolf.attrs.max_health - 1.0;
assert!(
mob_swings(&wolf, "minecraft:beef"),
"hurt: Wolf.feed is SUCCESS"
);
let mut cat = mob("minecraft:cat").unwrap();
assert!(mob_swings(&cat, "minecraft:cod"), "taming: SUCCESS");
cat.extra.tame = true;
assert!(
!mob_swings(&cat, "minecraft:cod"),
"tame, full health: CONSUME"
);
assert!(mob_swings(&cat, ""), "a tame cat sits: SUCCESS");
let mut parrot = mob("minecraft:parrot").unwrap();
parrot.extra.tame = true;
assert!(mob_swings(&parrot, ""), "a tame parrot sits: SUCCESS");
let horse = mob("minecraft:horse").unwrap();
assert!(
!mob_swings(&horse, "minecraft:apple"),
"AbstractHorse.fedFood: SUCCESS_SERVER"
);
let cow = mob("minecraft:cow").unwrap();
assert!(!mob_swings(&cow, "minecraft:wheat"), "adult: CONSUME");
let sheep = mob("minecraft:sheep").unwrap();
assert!(!mob_swings(&sheep, "minecraft:shears"), "Sheep: CONSUME");
}
/// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming
/// items only, and a tame adult is mounted by any click.
#[test]
@@ -112,6 +112,7 @@ impl Game<'_> {
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
});
} else if kind == "minecraft:cushion" {
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff. Show more