Merge main into feat/native-shader-params

Resolves the conflicts with the water reflection fields: Frame.mods now
follows reflection_resolution at offset 528.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRonhDhVY6koHuncJx
This commit is contained in:
Claude committed 2026-10-31 19:10:34 +00:00
commit d356420e76
298 files changed
+21523 -1651

No files matched your search

+14 -2
View File
@@ -8,6 +8,9 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
# itself instead (launcher/src/backend.rs).
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
@@ -19,7 +22,7 @@ on:
push:
branches: [main, launcher, 'launcher-*']
paths:
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
@@ -29,6 +32,8 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- 'packaging/linux/**'
- 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -161,7 +166,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
@@ -254,6 +259,13 @@ jobs:
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
if [[ "$RUNNER_OS" == "Linux" ]]; then
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
# player downloads and double-clicks, and it replaces itself instead
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
fi
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
+55
View File
@@ -0,0 +1,55 @@
name: native hook stamp
# Opens a PR that numbers pending fragments after they merge. Only pushes to
# main run main's own tool; fragments are read as data, not PR code.
on:
push:
branches: [main]
paths:
- 'tools/native-hook-fragments/*.json'
permissions:
contents: read
concurrency:
group: native-hook-stamp
cancel-in-progress: false
jobs:
stamp:
if: github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Open a stamp pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
branch=automation/native-hook-stamp
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
echo "A stamp pull request is already open."
exit 0
fi
python3 tools/stamp_native_hooks.py
if git diff --quiet; then
echo "No fragments to stamp."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -b "$branch"
git add -u
git commit -m 'Stamp native hook API fragments'
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
+4
View File
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
Generated
+6
View File
@@ -1183,6 +1183,7 @@ dependencies = [
"cpal",
"flate2",
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1353,14 +1354,17 @@ dependencies = [
name = "horizon-server"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-core",
"horizon-release-sign",
"horizon-server-files",
"libc",
"serde",
"serde_json",
"sha1",
"sha2",
"toml",
"toml_edit",
@@ -1398,8 +1402,10 @@ dependencies = [
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"naga",
"png",
"zip",
]
+3 -3
View File
@@ -742,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
</details>
#### AppImage and a Nix flake
#### A Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
Install Horizon on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
</details>
@@ -118,6 +118,12 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
None => mod_effect(id),
}
}
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
/// without `minecraft:`.
#[inline]
pub fn is_mod_effect(id: &str) -> bool {
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
@@ -365,6 +371,8 @@ mod tests {
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
assert!(!is_mod_effect(namespaced("speed")));
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
+1
View File
@@ -10,5 +10,6 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod window;
@@ -0,0 +1,232 @@
//! OS modifier reconciliation and modifiers synthesized by agent control.
use crate::{
keybinds::{KeyBinds, key_code},
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
};
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
(
ModifiersState::SHIFT,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
),
(
ModifiersState::CONTROL,
[KeyCode::ControlLeft, KeyCode::ControlRight],
),
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
(
ModifiersState::SUPER,
[KeyCode::SuperLeft, KeyCode::SuperRight],
),
];
/// A synthesized key updates the same modifier word as window events.
/// Releasing one side must preserve the other side's hold.
pub fn set_key(
modifiers: &mut ModifiersState,
physical: &mut HashSet<KeyCode>,
key: KeyCode,
down: bool,
) {
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
return;
};
if down {
physical.insert(key);
} else {
physical.remove(&key);
}
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
}
/// A modifier held for one synthesized click (the pad's Y, an agent's
/// shift-click): what [`restore`] puts back once the click is done.
#[derive(Clone, Copy, Debug)]
pub struct Held {
key: KeyCode,
was_down: bool,
bit: bool,
}
/// Holds `key` in both the physical keys and the modifier word, which is
/// what screens read (`InputWithModifiers.hasShiftDown`).
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
let bit = MODIFIERS
.iter()
.find(|(_, pair)| pair.contains(&key))
.is_some_and(|(bit, _)| modifiers.contains(*bit));
let held = Held {
key,
was_down: physical.contains(&key),
bit,
};
set_key(modifiers, physical, key, true);
held
}
/// Undoes [`hold`]: a key or bit that was already down stays down.
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
if !held.was_down {
physical.remove(&held.key);
}
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
modifiers.set(*bit, held.bit);
}
}
/// Recover missing modifier releases without releasing a rebound key or a pad.
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
pub fn reconcile(
modifiers: ModifiersState,
physical: &mut HashSet<KeyCode>,
bindings: &KeyBinds,
toggles: &mut ToggleKeys,
options: [bool; 2],
pad_held: [bool; 2],
) {
for (bit, pair) in MODIFIERS {
if modifiers.contains(bit) {
continue;
}
for key in pair {
if !physical.remove(&key) {
continue;
}
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
toggles.key(slot, KeyAction::Release, false, options[slot]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_taps_release_both_bits() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, pair) in MODIFIERS {
for key in pair {
set_key(&mut modifiers, &mut physical, key, true);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, key, false);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
}
}
}
#[test]
fn releasing_one_side_keeps_the_other_modifier_held() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, [left, right]) in MODIFIERS {
set_key(&mut modifiers, &mut physical, left, true);
set_key(&mut modifiers, &mut physical, right, true);
set_key(&mut modifiers, &mut physical, left, false);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, right, false);
assert!(modifiers.is_empty());
}
}
#[test]
fn a_held_click_restores_the_previous_state() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
assert!(modifiers.shift_key());
restore(&mut modifiers, &mut physical, held);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
// Right Shift already held, or the OS word set with no physical key.
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
let mut modifiers = ModifiersState::SHIFT;
let mut physical = start.clone();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
restore(&mut modifiers, &mut physical, held);
assert_eq!(modifiers, ModifiersState::SHIFT);
assert_eq!(physical, start);
}
}
#[test]
fn lost_releases_respect_rebindings_and_pad_holds() {
for slot in [SNEAK, SPRINT] {
for rebound in [false, true] {
for pad in [false, true] {
for toggle in [false, true] {
let mut bindings = KeyBinds::default();
let (name, modifier) = if slot == SNEAK {
("key.sneak", KeyCode::ShiftLeft)
} else {
("key.sprint", KeyCode::ControlLeft)
};
if rebound {
bindings.set(name, "key.keyboard.c");
}
let mut physical = HashSet::from([modifier]);
if rebound {
physical.insert(KeyCode::KeyC);
}
let mut toggles = ToggleKeys::default();
toggles.key(slot, KeyAction::Press, false, toggle);
let mut options = [false; 2];
options[slot] = toggle;
let mut pad_held = [false; 2];
pad_held[slot] = pad;
reconcile(
ModifiersState::ALT,
&mut physical,
&bindings,
&mut toggles,
options,
pad_held,
);
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
assert!(!physical.contains(&modifier));
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
}
}
}
}
}
#[test]
fn unrelated_modifier_change_does_not_release_an_action() {
let mut toggles = ToggleKeys::default();
toggles.key(SNEAK, KeyAction::Press, false, false);
reconcile(
ModifiersState::ALT,
&mut HashSet::new(),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
#[test]
fn lost_modifier_release_preserves_a_controller_toggle() {
let mut toggles = ToggleKeys::default();
toggles.pad_toggle(SNEAK, false);
reconcile(
ModifiersState::empty(),
&mut HashSet::from([KeyCode::ShiftLeft]),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
}
+1
View File
@@ -33,6 +33,7 @@ glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
@@ -235,6 +235,9 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
crate::native_emissive::load(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -1336,6 +1336,13 @@ impl Audio {
self.stop_instance(id);
self.release(id);
}
/// `SoundBufferLibrary.enumerate`: the decoded buffers kept and their bytes.
pub fn cache_stats(&self) -> (usize, usize) {
self.backend
.cache
.lock()
.map_or((0, 0), |c| (c.entries.len(), c.bytes))
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
+284
View File
@@ -272,6 +272,192 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// The account's chat key pair (ProfileKeyPair): the private key that signs
/// its chat, the public key with the services' signature over it, and when
/// to fetch a new one (refreshedAfter, epoch milliseconds). Like the access
/// token it stays in memory: vanilla's profilekeys file is never written.
pub struct ChatKeyPair {
pub private: horizon_protocol::rsa::RsaPrivateKey,
pub public: horizon_protocol::secure_chat::ProfileKey,
pub refreshed_after: i64,
}
impl std::fmt::Debug for ChatKeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatKeyPair")
.field("expires_at", &self.public.expires_at)
.field("refreshed_after", &self.refreshed_after)
.finish_non_exhaustive()
}
}
impl ChatKeyPair {
/// ProfileKeyPair.dueRefresh.
pub fn due_refresh(&self, now: i64) -> bool {
self.refreshed_after < now
}
}
/// Epoch milliseconds now.
pub fn now_millis() -> i64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Instant.parse for the services' UTC times
/// (`2022-07-30T23:05:24.548227Z`): epoch milliseconds.
pub fn parse_instant(text: &str) -> Option<i64> {
let text = text.strip_suffix('Z')?;
let (date, time) = text.split_once('T')?;
let mut date = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
let (y, m, d) = (date.next()??, date.next()??, date.next()??);
let (hms, fraction) = time.split_once('.').unwrap_or((time, ""));
let mut hms = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
let (h, min, s) = (hms.next()??, hms.next()??, hms.next()??);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || min > 59 || s > 60 {
return None;
}
if !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let millis = format!("{fraction:0<3}")[..3].parse::<i64>().ok()?;
// Days from 1970-01-01 (Howard Hinnant's days_from_civil).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(((days * 24 + h) * 60 + min) * 60_000 + s * 1000 + millis)
}
/// Crypt.stringToPemRsaPrivateKey / stringToRsaPublicKey: the base64
/// between a PEM block's armour lines.
fn pem_der(pem: &str) -> Option<Vec<u8>> {
let body: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
crate::skins::base64_decode(&body)
}
/// KeyPairResponse from the certificates endpoint, as
/// AccountProfileKeyPairManager.fetchProfileKeyPair reads it: the PEM key
/// pair, publicKeySignatureV2 (the signature over the profile id too),
/// expiresAt and refreshedAfter. None for anything malformed, or a private
/// key that isn't the public key's other half.
pub fn parse_certificates(body: &str) -> Option<ChatKeyPair> {
use horizon_protocol::rsa::{RsaPrivateKey, RsaPublicKey};
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let field = |path: &str| v.pointer(path).and_then(|s| s.as_str());
let private = RsaPrivateKey::from_der(&pem_der(field("/keyPair/privateKey")?)?).ok()?;
let key = pem_der(field("/keyPair/publicKey")?)?;
if RsaPublicKey::from_der(&key).ok()? != private.public {
return None;
}
let signature = crate::skins::base64_decode(field("/publicKeySignatureV2")?)?;
Some(ChatKeyPair {
private,
public: horizon_protocol::secure_chat::ProfileKey {
expires_at: parse_instant(field("/expiresAt")?)?,
key,
signature,
},
refreshed_after: parse_instant(field("/refreshedAfter")?)?,
})
}
/// UserApiService.getKeyPair: the certificates endpoint, POSTed with the
/// access token.
pub fn fetch_chat_keys(session: &AuthSession) -> Result<ChatKeyPair, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "player", "getCertificates").ok_or(JoinError::Unavailable)?;
let body = match agent()
.post(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.set("Content-Type", "application/json; charset=utf-8")
.send_string("")
{
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
return Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
));
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
parse_certificates(&body)
.ok_or_else(|| JoinError::Other("Malformed profile key pair response".to_owned()))
}
/// AccountProfileKeyPairManager.MINIMUM_PROFILE_KEY_REFRESH_INTERVAL.
const KEY_RETRY: Duration = Duration::from_secs(60 * 60);
/// The chat key pair as AccountProfileKeyPairManager keeps it: fetched on a
/// background thread, kept until it is due a refresh, and on a failure kept
/// as it was (the next try waits an hour, or the next join).
#[derive(Default)]
struct ChatKeys {
current: Option<Arc<ChatKeyPair>>,
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
/// due a refresh. Nothing without a signed-in account.
pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
}
keys.next_try = Some(instant + KEY_RETRY);
if keys
.current
.as_ref()
.is_some_and(|k| !k.due_refresh(now_millis()))
{
return;
}
keys.fetching = true;
drop(keys);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
Err(e) => log::error!("Failed to retrieve profile key pair: {e:?}"),
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
CHAT_KEYS
.lock()
.unwrap()
.current
.clone()
.filter(|k| !k.public.has_expired(now))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
@@ -485,6 +671,104 @@ mod tests {
);
}
#[test]
fn instants_like_java() {
assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_instant("1970-01-01T00:00:01.5Z"), Some(1500));
// Instant.parse("2022-07-30T23:05:24.548227Z").toEpochMilli().
assert_eq!(
parse_instant("2022-07-30T23:05:24.548227Z"),
Some(1_659_222_324_548)
);
assert_eq!(
parse_instant("2024-02-29T12:00:00Z"),
Some(1_709_208_000_000)
);
assert_eq!(parse_instant("1969-12-31T23:59:59Z"), Some(-1000));
for bad in [
"2022-07-30T23:05:24",
"2022-13-01T00:00:00Z",
"2022-07-30 23:05:24Z",
"2022-07-30T23:05:24.5x1Z",
"",
] {
assert_eq!(parse_instant(bad), None, "{bad}");
}
}
/// A throwaway 1024-bit key (`openssl genrsa`, PKCS#8 as the services
/// send it under "RSA PRIVATE KEY") and its public half.
const PRIVATE_B64: &str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKSDqaLOF2Ltt4tzWblkFeNYhdss67BhXTbOMUYjpcPdtf4dzNtgZsTf4WEluvc8pItGWChdpq3xzPC9GL4PZ+Oh1o6VR/wSm20/50QT7V+YcSP1VrVKjDpDSg/UoC1Fmdc/vb9AO8Ip7TxuRYd/uyog/ThQ8aGcWtvvtwPqx4XrAgMBAAECgYA+ZGy2gQDflRZSPeoeZHXkphMF/7BXUWW/vk1zs1tPwEsgV/70iEgRjHL2nA+fapFg/Mm8bWAkghrehmjW1BLq5w8sGdGJCHfE/kauu78jJK0/ME38e9bsSEvv5HMMxyx0mlV8DQu2pzCT71ceT8VD/1pewUbpeZypj5pxtsCIMQJBANXgca/mnAQXTuowI8FqMiald6OuAcAWbOaNrCB5HHWuxH7hFatSqGrgp4YLqHv7bzeodU1y/uxwc+/01khd0rMCQQDE6mPfIdZxHhWkMUAKhMEnrLanfCL0dfyAe+lBpqgPwO3UurCfCaFX5RdYB3fCyvgy5foHN1/I8/Eobp+enrvpAkBgkZnjeXWiZz/Lf/b1AtUoG/g8IHpL2BRNuVQpyWVvQX0ZaNoW5BiFQXCClsr5SBr8f4SPdsfhzBidFOiX/jz/AkBDuk6Bd3T3UR6ma54YQBLjX38vWf330QD4/CV9RCH30Sa8DT4kZT6OKah+yYrCf+cgMoNtqfkS9/xRKMNT3krJAkAaFrAT6HF5Y9b5OLASbbFe7MZ4zNCzuEl6plKNUi+JCHESaU/VCCuYOLoWLeSDFYkZUoSmqiRxH8fVTsBz393x";
const PUBLIC_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkg6mizhdi7beLc1m5ZBXjWIXbLOuwYV02zjFGI6XD3bX+HczbYGbE3+FhJbr3PKSLRlgoXaat8czwvRi+D2fjodaOlUf8EpttP+dEE+1fmHEj9Va1Sow6Q0oP1KAtRZnXP72/QDvCKe08bkWHf7sqIP04UPGhnFrb77cD6seF6wIDAQAB";
fn pem(label: &str, b64: &str) -> String {
let lines: Vec<&str> = b64
.as_bytes()
.chunks(76)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
format!(
"-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
lines.join("\n")
)
}
fn certificates(private: &str, public: &str) -> String {
serde_json::json!({
"keyPair": {
"privateKey": pem("RSA PRIVATE KEY", private),
"publicKey": pem("RSA PUBLIC KEY", public),
},
"publicKeySignature": "djE=",
"publicKeySignatureV2": "c2ln",
"expiresAt": "2022-07-30T23:05:24.548227Z",
"refreshedAfter": "2022-07-30T15:05:24.548227Z",
})
.to_string()
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
assert_eq!(
pair.public.key,
crate::skins::base64_decode(PUBLIC_B64).unwrap()
);
assert_eq!(pair.public.signature, b"sig", "publicKeySignatureV2");
assert_eq!(pair.public.expires_at, 1_659_222_324_548);
assert_eq!(pair.refreshed_after, 1_659_193_524_548);
assert_eq!(pair.private.public_der(), pair.public.key);
assert!(pair.due_refresh(1_659_193_524_549));
assert!(!pair.due_refresh(1_659_193_524_548));
assert!(!format!("{pair:?}").contains("MII"));
// A public key that isn't the private key's, and broken fields.
let other = horizon_protocol::rsa::RsaPrivateKey::generate(1024).unwrap();
let other = base64(&other.public_der());
assert!(parse_certificates(&certificates(PRIVATE_B64, &other)).is_none());
assert!(parse_certificates(&certificates("bm9wZQ==", PUBLIC_B64)).is_none());
assert!(parse_certificates("{}").is_none());
assert!(parse_certificates("").is_none());
}
fn base64(bytes: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for c in bytes.chunks(3) {
let n = c
.iter()
.enumerate()
.fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= c.len() {
char::from(A[(n >> (18 - 6 * i) & 63) as usize])
} else {
'='
});
}
}
out
}
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -73,6 +73,9 @@ pub struct BlockEntities {
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
}
impl std::ops::Deref for BlockEntities {
@@ -162,6 +165,13 @@ impl BlockEntities {
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
self.mod_fluids = fluids;
}
/// The fluid tanks of a mod block's block entity in block state `state`.
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
+2 -1
View File
@@ -13,7 +13,8 @@ use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
+47
View File
@@ -1013,6 +1013,53 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
/// this, not what was typed: a line of spaces sends nothing, and a message the
/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
/// server.
pub fn normalize_chat_message(message: &str) -> String {
// String.trim: the code points up to U+0020, which is not `str::trim`'s
// Unicode whitespace (U+00A0 is not Java whitespace and stays).
let trimmed = message.trim_matches(|c: char| c <= ' ');
truncate_utf16(&normalize_space(trimmed), 256)
}
/// `StringUtils.normalizeSpace`: one space for every run of
/// `Character.isWhitespace`, and none at either end.
fn normalize_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if crate::ui::java_whitespace(c) {
space = !out.is_empty();
} else {
if space {
out.push(' ');
space = false;
}
out.push(c);
}
}
out
}
/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
/// `chars().take(256)` would keep all 129 (258 units) for the server to
/// reject. A cut between the halves of a surrogate pair keeps the character
/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
/// may hold and a Rust `String` may not.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut units = 0;
for (byte, c) in s.char_indices() {
units += c.len_utf16();
if units > max_units {
return s[..byte].to_owned();
}
}
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
@@ -578,3 +578,38 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
#[test]
fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
// ChatScreen.normalizeChatMessage: String.trim, then StringUtils
// .normalizeSpace, so a command gets the arguments the player meant.
assert_eq!(normalize_chat_message(" hello there "), "hello there");
assert_eq!(normalize_chat_message("/msg Alex\thi"), "/msg Alex hi");
assert_eq!(normalize_chat_message("a\n\nb\r\nc"), "a b c");
assert_eq!(normalize_chat_message("\t \n"), "");
assert_eq!(normalize_chat_message(""), "");
// Character.isWhitespace is not str::trim's Unicode whitespace: U+00A0 is
// no Java whitespace, so it is neither trimmed nor collapsed, while the
// ideographic space U+3000 and Java's U+001C separators are.
assert_eq!(normalize_chat_message(" \u{a0} hi"), "\u{a0} hi");
assert_eq!(normalize_chat_message("a\u{a0}\u{a0}b"), "a\u{a0}\u{a0}b");
assert_eq!(normalize_chat_message("a\u{3000}\u{3000}b"), "a b");
assert_eq!(normalize_chat_message("a\u{1c}b"), "a b");
}
#[test]
fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
// StringUtil.trimChatMessage counts String.length(), UTF-16 code units:
// 256 ASCII characters are all kept, 257 are cut to 256.
assert_eq!(normalize_chat_message(&"a".repeat(256)).len(), 256);
assert_eq!(normalize_chat_message(&"a".repeat(300)).len(), 256);
// An emoji is two units, so 128 of them fill the packet and the 129th is
// dropped: chars().take(256) would keep all 129 and the server would
// reject the message.
let emoji = normalize_chat_message(&"\u{1f600}".repeat(129));
assert_eq!(emoji, "\u{1f600}".repeat(128));
assert_eq!(emoji.encode_utf16().count(), 256);
// A cut between the halves of a pair keeps the character below it.
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
+5 -1
View File
@@ -212,7 +212,7 @@ impl Default for ColumnLayout {
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
/// (what vanilla servers and clients send), more when content mods add states
@@ -391,6 +391,9 @@ pub struct Chunk {
pub light_loaded: bool,
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Finished from a proto chunk just now, in any kind of world, not read
/// from a save (`ChunkEvent.Load`'s `isNewChunk`).
pub generated: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
@@ -428,6 +431,7 @@ impl Chunk {
lit: false,
light_loaded: false,
fresh: false,
generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
@@ -120,6 +120,21 @@ pub struct ModContainer {
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The fluid tanks of a mod block's block entity, in the container's shape:
/// each tank's capacity in millibuckets, the tanks each face reaches, and
/// the tanks a fill may reach and a drain may take. The tanks' contents are
/// not here: they live in the entity's saved compound, under `fluids`, so
/// they survive restarts and the block's own tick reads them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModFluidTanks {
/// Each tank's capacity in millibuckets, in tank order.
pub tanks: Vec<i32>,
pub faces: [Vec<usize>; 6],
/// The tanks a fill may reach (`canPlaceItemThroughFace`).
pub inputs: Vec<usize>,
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
+158 -4
View File
@@ -343,8 +343,15 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
/// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
/// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
/// asks the block entity, which has grown once the lid starts to open).
fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result<Option<bool>> {
Ok(self
.world
.shulkers
.get(&p)
.map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -373,11 +380,17 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
/// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
/// (`getShape`) is the free shape grown by the lid's progress, which is
/// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
_: [i32; 3],
p: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(None)
Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
previous_progress: s.progress_old,
progress: s.progress,
}))
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -497,6 +510,8 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
/// No block-entity data: a lookup world has no shulker box to ask, so the
/// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
@@ -505,6 +520,8 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
/// No block-entity data: the block table's own shape stands in for the lid
/// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -611,3 +628,140 @@ mod border_movement_tests {
);
}
}
#[cfg(test)]
mod shulker_collision_tests {
use super::*;
use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
/// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
#[test]
fn shulker_closed_collision_is_full_block() {
let shulker = Shulker {
status: ShulkerStatus::Closed,
progress: 0.0,
progress_old: 0.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Closed box: height 1.0
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
// Full horizontal extent
assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
}
/// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
#[test]
fn shulker_open_collision_grows_upward() {
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Fully open: height 1.5 (lid extends 0.5 above the block)
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
}
/// A shulker box halfway open: collision height is 1.25.
#[test]
fn shulker_half_open_collision_height() {
let shulker = Shulker {
status: ShulkerStatus::Opening,
progress: 0.5,
progress_old: 0.5,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Half open: height 1.25
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
}
/// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
/// moving DOWN is not stopped by the box (collision only triggers when
/// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
#[test]
fn shulker_grown_box_allows_clip_when_inside() {
// Box at progress 1.0: spans y=0..1.5
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
// Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
// Moving DOWN from inside the box: collide() should NOT stop it
// (the entity is already intersecting the box, so the downward movement
// sees no face to hit from above).
let down = -0.1;
let moved_down = box_shape.collide(Axis::Y, entity, down);
assert!(
(moved_down - down).abs() < 1.0e-9,
"entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
);
// Entity box whose feet are at y=1.6 (just ABOVE the grown box)
let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
// Moving DOWN from above: collide() SHOULD stop it at y=1.5
let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
// The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
assert!(
(moved_down_above - (-0.1)).abs() < 1.0e-9,
"entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
);
}
/// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
/// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
/// This is tested at the integration level via `push_shulker_entities`;
/// here we only verify the swept box geometry matches vanilla's
/// `Shulker.getProgressDeltaAabb`.
#[test]
fn shulker_swept_box_matches_vanilla_delta() {
// From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
// to y=0..1.5 (half open), so the swept volume in world coords at position
// [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
// From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
assert!((delta2[1] - 1.5).abs() < 1.0e-9);
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
PotentSulfur, Shelf, Skull, SkullSkin,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+177 -13
View File
@@ -75,6 +75,24 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
/// The `features.enabled` flag ids of `pack.mcmeta`
/// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
pub fn features(&self) -> Vec<String> {
self.mcmeta()["features"]["enabled"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
/// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
fn mcmeta(&self) -> serde_json::Value {
self.read("pack.mcmeta")
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -127,14 +145,43 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
/// selects them.
pub fn feature_packs(resources: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
else {
return Vec::new();
};
let mut packs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: e.file_name().to_string_lossy().into_owned(),
source: Source::Dir(path),
});
}
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The packs of `<world>/datapacks` plus the resources root's feature packs:
/// the world's whole repository, as `PackRepository.reload` sees it.
pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec<DataPack> {
let mut packs = discover(world_root);
packs.extend(feature_packs(resources));
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
/// built-in one (`resources` holds its `pack.png`), and the feature packs
/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
/// until picked — the Experiments.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -172,6 +219,23 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
position: Position::Bottom,
fixed: false,
});
for p in feature_packs(resources) {
let meta = p.mcmeta();
let (description, description_style) =
crate::packs::component_root(&meta["pack"]["description"]);
infos.push(PackInfo {
icon: None,
id: p.id.clone(),
title: Text::translate(&format!("dataPack.{}.name", p.id), &[]),
description,
description_style,
source: Source::Feature,
compat: crate::packs::compatibility_for(&meta, format),
required: false,
position: Position::Bottom,
fixed: false,
});
}
infos
}
/// A new empty folder for Create World's packs
@@ -185,6 +249,59 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
} else {
let _ = std::fs::copy(&path, &to);
}
}
}
let name = || {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
if path.is_file() {
return path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
&& {
let _ = std::fs::create_dir_all(dir);
std::fs::copy(path, dir.join(name())).is_ok()
};
}
if !path.is_dir() {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
_ => false,
}
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
@@ -230,7 +347,11 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
// No flags here: the recreated world keeps the original's
// `enabled_features`, and the flag-aware load re-adds the feature packs
// it asks for (`for_recreate`'s selection only seeds the new world's
// `datapacks.json`).
let mut selected = enabled_ids(world, &packs, &[]);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
@@ -461,17 +582,20 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "dialog" => kind,
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
return Some(format!("tags/{ns}:{}", rest.strip_suffix(".json")?));
}
_ => return None,
};
@@ -504,6 +628,8 @@ impl DataStore {
"predicate",
"recipe",
"tags",
"trade_set",
"villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -617,10 +743,25 @@ fn hash_normalize(s: &str) -> String {
}
}
/// Whether every flag `pack` asks for (`Pack.getRequestedFeatures`) is among
/// the world's `enabled_features`.
fn features_enabled(pack: &DataPack, enabled_features: &[String]) -> bool {
pack.features()
.iter()
.all(|f| enabled_features.iter().any(|e| e == f))
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
///
/// `enabled_features` are the world's feature flags
/// (`WorldDataConfiguration.enabledFeatures`): a pack a pack file selected
/// while asking for missing features comes off
/// (`MinecraftServer.configurePackRepository`), and a feature pack whose
/// features the world enables is forced on however the world's selection
/// feels about it (`MinecraftServer.enableForcedFeaturePacks`).
pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
@@ -639,9 +780,32 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.filter(|id| {
packs
.iter()
.find(|p| &p.id == id)
.is_none_or(|p| features_enabled(p, enabled_features))
})
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
// A pack nobody mentions comes on (`PackRepository.reload` selects
// every new pack), but a feature pack stays off until the world
// enabled its flag (`PackSource.FEATURE` starts unselected).
if !enabled.contains(&p.id)
&& !disabled.contains(&p.id)
&& p.id.starts_with("file/")
&& features_enabled(p, enabled_features)
{
enabled.push(p.id.clone());
}
}
for p in packs {
let feature_pack = !p.id.starts_with("file/") && p.id != "vanilla";
if feature_pack
&& !p.features().is_empty()
&& features_enabled(p, enabled_features)
&& !enabled.contains(&p.id)
{
enabled.push(p.id.clone());
}
}
@@ -752,7 +916,7 @@ mod tests {
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
let enabled = enabled_ids(&dir, &packs, &[]);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
@@ -13,6 +13,37 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
pub fn select_colored(
sources: impl Iterator<Item = ([f64; 3], u32, f32, f32)>,
camera: DVec3,
) -> Vec<Colored> {
let mut nearest: Vec<_> = sources.collect();
nearest.sort_by(|a, b| {
(DVec3::from(a.0) - camera)
.length_squared()
.total_cmp(&(DVec3::from(b.0) - camera).length_squared())
});
nearest
.into_iter()
.take(MAX)
.map(|(at, rgb, radius, intensity)| {
let at = (DVec3::from(at) - camera).as_vec3();
[
[at.x, at.y, at.z, radius],
[
((rgb >> 16) & 255) as f32 / 255.0,
((rgb >> 8) & 255) as f32 / 255.0,
(rgb & 255) as f32 / 255.0,
intensity,
],
]
})
.collect()
}
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
@@ -87,6 +118,17 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
(0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
DVec3::new(2.0, 64.0, 0.0),
);
assert_eq!(lights.len(), MAX);
assert_eq!(lights[0][0][0], 0.0);
assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
}
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
+39 -9
View File
@@ -235,10 +235,12 @@ impl PlayerEffects {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
/// (on its last tick) and a content mod's effect (every tick, with the
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
/// absorption is used up. Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -247,7 +249,7 @@ impl PlayerEffects {
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -269,8 +271,9 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"bad_omen" => omen(e, t),
"raid_omen" if t == 1 => omen(e, t),
id if potions::is_mod_effect(id) => omen(e, t),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -508,7 +511,7 @@ mod tests {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
let mut convert = |e: &EffectInstance| match e.id {
let mut convert = |e: &EffectInstance, _: i32| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -550,12 +553,39 @@ mod tests {
.is_empty()
);
}
/// A content mod's effect answers each tick before it counts down (an
/// infinite one with the tick count), and goes at once on Remove.
#[test]
fn a_mod_effect_answers_before_it_counts_down() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("ruby:glow", 3, 1));
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
let mut seen = Vec::new();
let mut answer = |e: &EffectInstance, t: i32| {
seen.push((e.id, t));
match (e.id, t) {
("ruby:glow", 2) => OmenTick::Remove,
_ => OmenTick::Continue,
}
};
fx.tick_with(&mut answer, false);
fx.tick_with(&mut answer, false);
let ticks = [
("ruby:glow", 3),
("ruby:bleed", 1),
("ruby:glow", 2),
("ruby:bleed", 2),
];
assert_eq!(seen, ticks);
assert!(fx.get("ruby:glow").is_none());
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
let mut watch = |e: &EffectInstance| {
let mut watch = |e: &EffectInstance, _: i32| {
seen.push(e.duration);
OmenTick::Remove
};
@@ -22,6 +22,10 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@@ -90,6 +94,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -103,6 +116,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
+413 -15
View File
@@ -69,6 +69,15 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
.collect()
}
/// Whether the renderer should initialize its GPU occlusion pass.
///
/// Software adapters spend more time running the culling pass than drawing
/// the sections it removes. Keep the pass available for explicit A/B checks,
/// while leaving hardware adapters on the existing default.
fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
}
mod animate;
mod arena;
pub mod compositor;
@@ -91,9 +100,11 @@ mod pack_atlas;
pub mod pack_draw;
mod pack_entities;
mod pack_hand;
mod pack_images;
mod pack_post;
mod pack_shadow;
mod pack_sky;
mod pack_textures;
mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
@@ -225,12 +236,23 @@ struct Globals {
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
colored_lights: [crate::dynamic_light::Colored; crate::dynamic_light::MAX],
colored_count: [u32; 4],
emissive_layers: [[u32; 4]; crate::native_emissive::MAX],
emissive_count: [u32; 4],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
filtering: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
/// it, at half the world's resolution (the material stage's `reflection`).
struct MirrorTarget {
size: [u32; 2],
color: wgpu::TextureView,
depth: wgpu::TextureView,
}
struct SectionGpu {
/// Arena page and first vertex in it: solid quads, then translucent ones.
page: u32,
@@ -293,6 +315,10 @@ pub struct Frame<'a> {
/// `Camera.depthFar`: the far plane (`crate::camera::depth_far`).
pub depth_far: f32,
pub underwater: bool,
/// The y of the horizontal water plane around the camera, in world blocks, while one
/// of Horizon's own packs reads the water mirror (`Game::water_plane`,
/// `Renderer::wants_water_plane`).
pub water_plane: Option<f32>,
/// Skip sections the camera cannot see through open faces (vanilla's
/// occlusion graph; off in spectator-like views inside blocks).
pub cave_culling: bool,
@@ -347,6 +373,8 @@ pub struct Frame<'a> {
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
pub dynamic_lights: &'a [[f32; 4]],
pub colored_lights: &'a [crate::dynamic_light::Colored],
pub emissive_layers: &'a [[u32; 4]],
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
pub clouds: Option<crate::clouds::Input<'a>>,
/// Retained lightning bolts, drawn before the other additive entities.
@@ -487,6 +515,12 @@ pub struct Renderer {
/// (`crosshair_globals`).
crosshair_group: wgpu::BindGroup,
crosshair_globals: wgpu::Buffer,
/// The water mirror: its solid terrain pipeline, its camera's globals and their group,
/// and its targets once drawn.
solid_mirror: wgpu::RenderPipeline,
mirror_globals: wgpu::Buffer,
mirror_group: wgpu::BindGroup,
mirror: Option<MirrorTarget>,
entity_pipeline: wgpu::RenderPipeline,
hdr_mark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
@@ -1191,7 +1225,9 @@ async fn open_backend(
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
// stand-in neither clamps nor drops NaN. Subgroups: shader packs' compute programs
// (`MC_GL_KHR_shader_subgroup_*`); storage buffers in vertex shaders, clearing and
// atomics on images: their buffers and images.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
@@ -1200,7 +1236,11 @@ async fn open_backend(
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
| wgpu::Features::RG11B10UFLOAT_RENDERABLE
| wgpu::Features::SUBGROUP
| wgpu::Features::VERTEX_WRITABLE_STORAGE
| wgpu::Features::CLEAR_TEXTURE
| wgpu::Features::TEXTURE_ATOMIC));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
@@ -1624,6 +1664,19 @@ impl Renderer {
true,
None,
);
// The water mirror's camera sees the world from the other side of the plane, which
// flips the winding: its solid terrain culls the front.
let solid_mirror = pipeline(
"Terrain solid (mirrored)",
"vs_terrain",
"fs_solid_mirror",
&terrain_buffers,
tri,
Some(wgpu::Face::Front),
true,
true,
None,
);
let translucent = pipeline(
"Terrain translucent",
"vs_terrain",
@@ -1720,6 +1773,12 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mirror_globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Water mirror globals"),
size: std::mem::size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let prepared = prepared.unwrap_or_else(|| {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
@@ -1832,6 +1891,16 @@ impl Renderer {
&lightmap_view,
&lightmap_sampler,
);
let mirror_group = terrain_group(
&device,
"Water mirror resources",
&bindings,
&mirror_globals,
&sprite_views,
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
);
let index_quads = 1 << 16;
let indices = Self::quad_indices(&device, index_quads);
let instance_capacity = 4096;
@@ -2780,7 +2849,7 @@ impl Renderer {
&bindings,
arena.page_vertices(),
min_sections,
|layout, label, fragment, blend| {
|layout, label, fragment, blend, cull| {
pipeline_in(
layout,
label,
@@ -2788,7 +2857,7 @@ impl Renderer {
fragment,
&buffers,
tri,
Some(wgpu::Face::Back),
cull,
true,
true,
blend,
@@ -2822,8 +2891,9 @@ impl Renderer {
// frame however few its draws and little it hides, `shader` tests in
// the shader as on Metal (A/B checks).
let cull_env = std::env::var("HORIZON_GPU_CULL");
let adapter_info = adapter.get_info();
let cull = cull::Cull::supported(&adapter, &device)
.filter(|_| cull_env.as_deref() != Ok("0"))
.filter(|_| gpu_culling_requested(&adapter_info, cull_env.as_deref().ok()))
.map(|depth_test| match cull_env.as_deref() {
Ok("shader") => cull::DepthTest::InShader,
_ => depth_test,
@@ -2832,7 +2902,7 @@ impl Renderer {
let mut cull =
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
cull.always = cull_env.as_deref() == Ok("all");
if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
if !cull.always && adapter_info.backend == wgpu::Backend::Metal {
cull.min_sections = cull::TILED_MIN_SECTIONS;
}
cull
@@ -2901,6 +2971,10 @@ impl Renderer {
debug_line_capacity: 4096,
crosshair_group,
crosshair_globals,
solid_mirror,
mirror_globals,
mirror_group,
mirror: None,
entity_pipeline,
hdr_mark_pipeline,
hdr_mark_lightning_pipeline,
@@ -3209,6 +3283,11 @@ impl Renderer {
self.flat_on = on;
Ok(())
}
/// Whether one of Horizon's own packs reads the water mirror, which needs the frame's
/// `water_plane`.
pub fn wants_water_plane(&self) -> bool {
self.native.as_ref().is_some_and(|n| n.frame.reflects)
}
/// Whether solid quads facing away from the eye are left out.
pub fn section_sides(&self) -> bool {
self.sides_on
@@ -3944,6 +4023,23 @@ impl Renderer {
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
/// has them.
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
if self.device.features().contains(wgpu::Features::SUBGROUP) {
for ext in [
"basic",
"vote",
"arithmetic",
"ballot",
"shuffle",
"shuffle_relative",
] {
macros.define(format!("MC_GL_KHR_shader_subgroup_{ext}"), "");
}
}
}
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
@@ -4106,8 +4202,26 @@ impl Renderer {
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
// A frame that was not drawn still submits the section copies.
if let Some(copies) = self.take_section_copies() {
// A frame that was not drawn still submits the section copies and
// atlas patches (glyphs, skins, maps), which the caller already took.
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
let section_copies = self.section_copies.take();
let has_section_copies = section_copies.is_some();
let mut encoder = section_copies.unwrap_or_else(|| {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Horizon atlas patches"),
})
});
self.hud
.upload_patches(&mut self.uploads, &mut encoder, &frame.hud.patches);
self.uploads.finish(&encoder);
if has_section_copies {
self.section_copy_bytes = 0;
self.section_uploads.finish(&encoder);
}
self.queue.submit([encoder.finish()]);
} else if let Some(copies) = self.take_section_copies() {
self.queue.submit([copies]);
}
rendered
@@ -4318,6 +4432,32 @@ impl Renderer {
}
lights
},
colored_lights: {
let mut lights = [[[0.0; 4]; 2]; crate::dynamic_light::MAX];
for (to, from) in lights.iter_mut().zip(frame.colored_lights) {
*to = *from;
}
lights
},
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
0,
0,
0,
],
emissive_layers: {
let mut layers = [[0; 4]; crate::native_emissive::MAX];
for (to, from) in layers.iter_mut().zip(frame.emissive_layers) {
*to = *from;
}
layers
},
emissive_count: [
frame.emissive_layers.len().min(crate::native_emissive::MAX) as u32,
0,
0,
0,
],
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
filtering: [
self.texture_filtering as f32,
@@ -4366,9 +4506,24 @@ impl Renderer {
};
let camera = frame.camera;
let camera_section = camera_section(camera, self.layout.min_y());
// The water mirror draws the same lists: they take in what its camera sees too
// (above the screen's top, the far shore, the trees against the sky).
let mirror_plane = frame
.water_plane
.filter(|_| !frame.title && self.wants_water_plane());
let mirror = mirror_plane.map(|plane| Frustum {
planes: frustum(mirror_view_projection(
cull_proj,
plane - frame.camera.y as f32,
)),
});
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
// the eye off `camera`, so it is found from the projection. With the mirror, those
// facing its eye are kept too: it sits straight below, at this camera-relative y.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
let mirror_low = mirror_plane
.zip(eye)
.map(|(plane, eye)| 2.0 * (plane - frame.camera.y as f32) - eye.y);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -4390,7 +4545,7 @@ impl Renderer {
self.sections.visible(
camera,
(frame.detail_distance / 16.0) as i32,
&view,
&Frusta(view, mirror),
|k| !culling || reachable.contains(k),
|x, z| {
(!cutout.is_empty() && cutout.contains(&(x, z)))
@@ -4521,6 +4676,8 @@ impl Renderer {
&& !frame.title
&& !pack_terrain
&& self.multi_draw
// Occlusion is found from the camera, not the mirror's eye.
&& mirror.is_none()
&& visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
@@ -4539,7 +4696,7 @@ impl Renderer {
let start = draws.len();
let instances = u32::from(first_phase);
let mut indices = 0;
facing_runs(&s.sides, eye, v.origin, |first, quads| {
facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
@@ -4582,7 +4739,7 @@ impl Renderer {
let s = self.lods[&v.key].drawn();
let start = self.draws.len();
let instance = (visible.len() + i) as u32;
facing_runs(&s.sides, eye, v.origin, |first, quads| {
facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
@@ -5259,7 +5416,18 @@ impl Renderer {
}
let oit = self.oit.as_ref().filter(|_| oit_on);
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
let pack_sky_clear = pack_draws_sky
.then(|| self.pack_post.as_ref().and_then(|p| p.1.as_ref()))
.flatten()
.and_then(|p| p.sky_clear());
let clear = if let Some([r, g, b, a]) = pack_sky_clear {
wgpu::Color {
r: f64::from(r),
g: f64::from(g),
b: f64::from(b),
a: f64::from(a),
}
} else if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
} else {
wgpu::Color {
@@ -5321,6 +5489,118 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
sky.draw(&mut pass, &native.frame);
}
// The water mirror, for one of Horizon's own packs that reads it: the world as a
// camera mirrored about the water plane sees it, into a half-resolution target the
// material stage samples as `reflection`.
let mut reflection = None;
if let Some(plane) = mirror_plane {
let world = self
.world_post
.size()
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
let size = native_post::reflection_size(world);
if self.mirror.as_ref().is_none_or(|m| m.size != size) {
let target = |label: &'static str, format, usage| {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
self.mirror = Some(MirrorTarget {
size,
color: target(
"Water mirror",
self.config.format.remove_srgb_suffix(),
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
),
depth: target(
"Water mirror depth",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
),
});
}
if let Some(mirror) = &self.mirror {
// Mirrored about y = `plane` (camera-relative).
let plane = plane - frame.camera.y as f32;
let inverse_view = mirror_inverse_view(globals.inverse_view, plane);
// fog_ends.w: the plane, below which fs_solid_mirror cuts the world away
// (only entity.wgsl reads the glint strength there, and no entity draws here).
let mut fog_ends = globals.fog_ends;
fog_ends[3] = plane;
let mirror_globals = Globals {
view_proj: mirror_view_projection(view_proj, plane).to_cols_array_2d(),
inverse_view,
fog_ends,
..globals
};
self.uploads.buffer(
&mut encoder,
&self.mirror_globals,
0,
bytemuck::bytes_of(&mirror_globals),
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Water mirror"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &mirror.color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &mirror.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(0, &self.mirror_group, &[]);
if sky_visible && frame.skybox {
pass.set_pipeline(&self.sky);
pass.draw(0..3, 0..1);
}
// The solid terrain: occlusion culling is off with a mirror, so every draw
// is phase 1's.
if flat_frame && let Some(flat) = &self.flat {
pass.set_pipeline(&flat.solid_mirror);
let vertices = |p| self.arena.buffer(p);
flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
} else {
pass.set_pipeline(&self.solid_mirror);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(&mut pass, &self.indirect, 0, 0, solid_draws);
}
drop(pass);
reflection = Some(mirror.color.clone());
}
}
if let Some(native) = &mut self.native {
native
.frame
.set_reflection(&self.device, reflection.as_ref());
}
// The material stage's copies of the world before translucent terrain, the size of
// this frame's targets.
if let Some(native) = &mut self.native
@@ -6018,12 +6298,14 @@ impl Renderer {
&& let Some(post) = &mut native.post
{
let size = self.world_post.size().unwrap_or([1, 1]);
let history_reset = native.frame.take_history_reset();
post.run(
&self.device,
&mut encoder,
(color, depth),
&view_target,
size,
history_reset,
native.timing.as_mut(),
);
} else if scaled.is_some() {
@@ -6515,6 +6797,31 @@ pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u3
}
out
}
/// The union of two frusta (the camera's and its water mirror's), for `Resident::visible`.
#[derive(Clone, Copy, Debug)]
pub struct Frusta(pub Frustum, pub Option<Frustum>);
/// What `Resident::visible` culls with: a frustum or a union of them.
pub trait Cull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool;
fn contains(&self, min: Vec3, max: Vec3) -> bool;
}
impl Cull for Frustum {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
Frustum::intersects(self, min, max)
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
Frustum::contains(self, min, max)
}
}
impl Cull for Frusta {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
self.0.intersects(min, max) || self.1.is_some_and(|f| f.intersects(min, max))
}
/// Entirely inside one of them is entirely inside the union.
fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -6563,6 +6870,24 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
}
/// `view_proj` mirrored about the horizontal plane at the camera-relative height `plane`:
/// with F the mirror (y flips and moves to 2 * plane), `view_proj * F` negates column 1
/// and moves column 3 by 2 * plane times column 1.
///
/// `mirror_inverse_view` is the inverse's other side: F * `inverse_view`.
fn mirror_view_projection(view_proj: Mat4, plane: f32) -> Mat4 {
let mut m = view_proj.to_cols_array_2d();
let col1 = m[1];
m[1] = col1.map(|v| -v);
m[3] = std::array::from_fn(|i| m[3][i] + 2.0 * plane * col1[i]);
Mat4::from_cols_array_2d(&m)
}
/// The mirror's `inverse_view` (the sky's rays): what the view sees, mirrored about the
/// horizontal plane at the camera-relative height `plane`, so F * `inverse_view`.
fn mirror_inverse_view(inverse_view: [[f32; 4]; 4], plane: f32) -> [[f32; 4]; 4] {
inverse_view.map(|[x, y, z, w]| [x, 2.0 * plane * w - y, z, w])
}
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
/// space: the point whose clip x, y and w are all 0. `None` for a
/// projection without one (orthographic).
@@ -6584,9 +6909,24 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
facing_runs_from(sides, eye, None, origin, draw)
}
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
/// camera-relative height (the water mirror's eye).
fn facing_runs_from(
sides: &Sides,
eye: Option<Vec3>,
low: Option<f32>,
origin: [f32; 3],
mut draw: impl FnMut(u32, u32),
) {
match eye {
Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
Some(eye) => {
let eye = eye - Vec3::from(origin);
let low = low.map_or(eye.y, |y| (y - origin[1]).min(eye.y));
sides.visible_from(eye.to_array(), low, draw)
}
None if sides.quads() > 0 => draw(0, sides.quads()),
None => {}
}
@@ -6606,6 +6946,64 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "test adapter".into(),
vendor: 0,
device: 0,
device_type,
device_pci_bus_id: String::new(),
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
#[test]
fn software_occlusion_culling_is_opt_in() {
let cpu = adapter(wgpu::DeviceType::Cpu);
let discrete = adapter(wgpu::DeviceType::DiscreteGpu);
assert!(!super::gpu_culling_requested(&cpu, None));
assert!(super::gpu_culling_requested(&discrete, None));
assert!(super::gpu_culling_requested(&cpu, Some("all")));
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
}
#[test]
fn the_mirror_sees_each_point_where_its_reflection_is() {
use glam::{Mat4, Vec3, Vec4};
let view_proj = super::world_projection(70.0, 1.5, 512.0)
* Mat4::look_to_rh(Vec3::ZERO, Vec3::new(0.3, -0.4, -1.0), Vec3::Y);
let plane = -3.25;
let mirror = super::mirror_view_projection(view_proj, plane);
for p in [Vec3::new(1.0, 2.0, -9.0), Vec3::new(-4.0, -1.5, -20.0)] {
let reflected = Vec3::new(p.x, 2.0 * plane - p.y, p.z);
let a = mirror * Vec4::from((p, 1.0));
let b = view_proj * Vec4::from((reflected, 1.0));
assert!((a - b).abs().max_element() < 1e-4, "{a} {b}");
}
}
#[test]
fn the_mirrors_sky_rays_are_the_cameras_reflected() {
use glam::{Mat4, Vec3, Vec4};
// A camera looking down: its mirror looks up, by as much.
let forward = Vec3::new(0.3, -0.64, -1.0).normalize();
let view = Mat4::look_to_rh(Vec3::ZERO, forward, Vec3::Y);
let mirrored = super::mirror_inverse_view(view.inverse().to_cols_array_2d(), -3.25);
let ray = Mat4::from_cols_array_2d(&mirrored) * Vec4::new(0.0, 0.0, -1.0, 0.0);
let reflected = Vec3::new(forward.x, -forward.y, forward.z);
assert!((ray.truncate() - reflected).length() < 1e-5, "{ray}");
// The view's origin, the camera, goes to the mirror's eye.
let eye = Mat4::from_cols_array_2d(&mirrored) * Vec4::W;
assert!(
(eye.truncate() - Vec3::new(0.0, -6.5, 0.0)).length() < 1e-5,
"{eye}"
);
}
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
+41 -9
View File
@@ -63,6 +63,8 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -127,7 +129,7 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
}
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11's Vulkan
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
@@ -135,15 +137,34 @@ pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|| is_broken_intel_vulkan(info)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -265,7 +286,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -277,6 +298,7 @@ impl Flat {
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -330,13 +352,23 @@ impl Flat {
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
flatten,
compute_layout,
@@ -28,6 +28,9 @@ struct HorizonMaterialOut {
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -45,6 +48,19 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
// The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
// high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
// is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
// interpolated value rounds back exactly.
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
out.face = select(0u, high & 7u, fluid);
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -65,9 +81,13 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
in.clip.xy / frame.resolution)).color;
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
@@ -320,7 +340,10 @@ mod tests {
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
let t = vec2<i32>(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
return Surface(apply_fog(sprite(lit)) + behind * 0.0);\n}\n";
let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
@@ -29,7 +29,20 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let frame = NativeFrame::new(device);
let mut frame = NativeFrame::new(device);
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.is_some_and(|s| s.reads("reflection"))
&& option.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -24,8 +24,14 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
history_blank: wgpu::Texture,
/// One bind group per pass, for these world views and either the valid history or black.
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -43,11 +49,21 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
/// Last frame's view-projection, camera and time (`write`).
last: Option<([[f32; 4]; 4], glam::DVec3, std::time::Instant)>,
/// The eased eye light (block, sky) and being in water and lava (`write`); NaN until the
/// first frame sets them.
eye: [f32; 4],
/// A post stage must use black history after a long gap until it has rendered a frame.
history_reset: bool,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
@@ -105,6 +121,11 @@ fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
}
}
/// The size of the water mirror for a world of `size`: half of it.
pub fn reflection_size(size: [u32; 2]) -> [u32; 2] {
size.map(|s| s.div_ceil(2).max(1))
}
/// Seconds between frames past which the last one is forgotten (`NativeFrame::write`).
const MAX_FRAME_GAP: f32 = 0.5;
@@ -130,21 +151,48 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let group = Self::group(device, &layout, &buffer, &opaque_depth, &opaque_color);
let blank = copy_target(
device,
"Native shader pack reflection blank",
[1, 1],
wgpu::TextureFormat::Rgba8Unorm,
)
.create_view(&Default::default());
let reflection_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack reflection sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
);
Self {
buffer,
layout,
group,
opaque_depth,
opaque_color,
reflection: blank.clone(),
blank,
reflection_sampler,
reflects: false,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
@@ -155,6 +203,7 @@ impl NativeFrame {
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
@@ -166,6 +215,14 @@ impl NativeFrame {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
@@ -208,10 +265,25 @@ impl NativeFrame {
if !depth_fits {
self.opaque_depth = depth_copy(device, size);
}
self.regroup(device);
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
let view = view.unwrap_or(&self.blank);
if *view != self.reflection {
self.reflection = view.clone();
self.regroup(device);
}
}
fn regroup(&mut self, device: &wgpu::Device) {
self.group = Self::group(
device,
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
);
@@ -225,6 +297,9 @@ impl NativeFrame {
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
}
let view_proj = (Mat4::from_cols_array_2d(&inputs.projection)
* Mat4::from_cols_array_2d(&inputs.view))
.to_cols_array_2d();
@@ -237,7 +312,10 @@ impl NativeFrame {
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
}
_ => self.eye = [f32::NAN; 4],
_ => {
self.eye = [f32::NAN; 4];
self.history_reset = true;
}
}
self.last = Some((view_proj, frame.camera, now));
// The eye's light and medium eased by half-lives in seconds, from where they are at
@@ -259,6 +337,14 @@ impl NativeFrame {
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
/// skipped for a frame, so the first frame it does render still sees black history.
pub(super) fn take_history_reset(&mut self) -> bool {
let reset = self.history_reset;
self.history_reset = false;
reset
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
@@ -320,6 +406,7 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -387,7 +474,10 @@ impl NativePost {
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
history: None,
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
format,
})
}
@@ -402,6 +492,7 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
@@ -417,8 +508,10 @@ impl NativePost {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
// COPY_SRC: the last pass's input is kept as next frame's `history`.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
@@ -430,15 +523,28 @@ impl NativePost {
target("Native shader pack B"),
],
));
self.history = Some(history_target(device, "Native shader pack history", size));
self.history_valid = false;
self.groups = None;
}
let blank_history = history_reset || !self.history_valid;
if self
.groups
.as_ref()
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
})
{
return;
}
let history = if blank_history {
&self.history_blank
} else {
self.history
.as_ref()
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
@@ -474,11 +580,15 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -490,13 +600,14 @@ impl NativePost {
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size);
self.prepare(device, world, size, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
let Some((_, _, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
@@ -523,9 +634,40 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
self.history_valid = true;
}
}
}
/// A target of `size` the post stage's `history` is copied into.
fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -603,6 +745,9 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
eye_light_smooth: [level(pack.eye_light[0]), level(pack.eye_light[1])],
in_water: f32::from(u8::from(pack.eye_in as u32 == 1)),
in_lava: f32::from(u8::from(pack.eye_in as u32 == 2)),
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
}
}
@@ -723,7 +868,15 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
true,
None,
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
@@ -754,4 +907,111 @@ mod tests {
"{pixel:?}"
);
}
/// `history` is black on the first frame, then what the pass before the last wrote the
/// frame before.
#[test]
fn the_last_pass_reads_last_frames_input_as_history() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1
id = \"t\"
name = \"T\"
version = \"1\"
api = \"0.1\"
[[post]]
file = \"a.wgsl\"
[[post]]
file = \"b.wgsl\"
";
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, 1.0); }
";
let b = "fn post(input: PostInput) -> vec4<f32> { return textureLoad(history, vec2<i32>(input.pixel), 0); }
";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut frame_pixel = |history_reset| {
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
history_reset,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
assert_eq!(
frame_pixel(true)[..3],
[0, 0, 0],
"black on the first frame"
);
let second = frame_pixel(false);
assert!(
second[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{second:?}"
);
assert_eq!(frame_pixel(true)[..3], [0, 0, 0], "black after a frame gap");
let fourth = frame_pixel(false);
assert!(
fourth[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{fourth:?}"
);
}
}
@@ -27,12 +27,11 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
inputs
}
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
/// `MC_HAND_DEPTH`.
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
/// The hand's projection in GL's conventions: `hud_fov` degrees, depth scaled by
/// `MC_HAND_DEPTH` (the view bob is in `gbufferModelView`).
pub fn projection(hud_fov: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
* bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
@@ -153,7 +152,7 @@ mod tests {
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
let p = projection(70.0, 16.0 / 9.0, 512.0);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
@@ -0,0 +1,238 @@
//! A shader pack's own images (`image.<name>`) and storage buffers (`bufferObject.<n>`) on the
//! GPU: created at the render size they depend on, kept across frames, images cleared before a
//! frame when the pack asks.
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
pub(super) format: wgpu::TextureFormat,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) view: Option<wgpu::TextureView>,
/// The render size the image was made for (relative images follow it).
made_for: [u32; 2],
}
impl PackImage {
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false })
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
},
def,
view: None,
made_for: [0; 2],
})
.collect()
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
(Kind::Unorm, _) => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
}
}
/// Whether this device can make `def`: its format's features, and a size in pixels within the
/// device's limits.
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
let format = format(def.format);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
"{format:?} needs {missing:?}, which this GPU lacks"
));
}
let limits = device.limits();
let largest = if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
};
let [width, height, depth] = def.extent([1, 1]);
let relative = matches!(def.size, horizon_shaders::ImageSize::Relative(_));
if !relative && width.max(height).max(depth) > largest {
return Err(format!(
"{width}x{height}x{depth} is larger than this GPU's {largest}"
));
}
Ok(())
}
/// Makes the images that are missing or sized for another render size; whether it made any.
pub(super) fn resize_images(
device: &wgpu::Device,
images: &mut [PackImage],
size: [u32; 2],
) -> bool {
let largest = device.limits().max_texture_dimension_2d;
let mut made = false;
for image in images {
let relative = matches!(image.def.size, horizon_shaders::ImageSize::Relative(_));
if image.view.is_some() && (!relative || image.made_for == size) {
continue;
}
made = true;
// A relative image larger than the render size stops at the device's limit.
let [width, height, depth] = image.def.extent(size);
let [width, height] = [width.min(largest), height.min(largest)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&image.def.name),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
image.view = Some(texture.create_view(&Default::default()));
image.made_for = size;
}
made
}
/// Clears the images the pack clears before each frame.
pub(super) fn clear(encoder: &mut wgpu::CommandEncoder, images: &[PackImage]) {
for view in images
.iter()
.filter(|i| i.def.clear)
.filter_map(|i| i.view.as_ref())
{
encoder.clear_texture(view.texture(), &Default::default());
}
}
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
pub(super) fn new(device: &wgpu::Device, objects: BTreeMap<u32, BufferObject>) -> Self {
let entries: Vec<_> = objects
.keys()
.map(|&binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack buffers"),
entries: &entries,
});
Self {
layout,
objects,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
}
}
/// Makes the buffers (again, when one depends on the render size and it changed): their
/// contents start zeroed and persist across frames.
pub(super) fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
let relative = self
.objects
.values()
.any(|o| matches!(o, BufferObject::PerPixel { .. }));
if self.group.is_some() && (!relative || self.made_for == size) {
return;
}
let limits = device.limits();
let largest = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size)
& !3;
self.buffers = self
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
.objects
.keys()
.zip(&self.buffers)
.map(|(&binding, b)| wgpu::BindGroupEntry {
binding,
resource: b.as_entire_binding(),
})
.collect();
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack buffers"),
layout: &self.layout,
entries: &entries,
}));
self.made_for = size;
}
/// Whether the pack declares buffer `binding`.
pub(super) fn has(&self, binding: u32) -> bool {
self.objects.contains_key(&binding)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,339 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
View(wgpu::TextureView),
Lightmap,
Atlas,
}
pub(super) struct PackTexture {
pub(super) stage: String,
pub(super) name: String,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
pub(super) image: Image,
}
/// Uploads `textures`, with a note for each the game can't provide.
pub(super) fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Vec<CustomTexture>,
notes: &mut Vec<String>,
) -> Vec<PackTexture> {
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
continue;
}
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureViewDimension::D1,
TextureDim::D2 => wgpu::TextureViewDimension::D2,
TextureDim::D3 => wgpu::TextureViewDimension::D3,
};
(
Image::View(view),
dimension,
sample_type,
texture.blur,
texture.clamp,
)
}
TextureSource::Resource(location) => {
let path = location.strip_prefix("minecraft:").unwrap_or(&location);
let image = if path.starts_with("dynamic/lightmap") {
Image::Lightmap
} else if path.starts_with("textures/atlas/blocks") || path == "dynamic/atlas" {
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
));
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
}
};
out.push(PackTexture {
stage: t.stage,
name: t.name,
dimension,
sample_type,
blur,
clamp,
image,
});
}
out
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
Group::ShadowComp => "shadowcomp",
}
}
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
} else if ty.contains("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
})
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
.iter()
.find(|t| t.stage == "noise" && t.dimension == wgpu::TextureViewDimension::D2)
.and_then(|t| match &t.image {
Image::View(v) => Some(v),
_ => None,
})
}
/// The wgpu format a texture of `texture.format` is stored in: sampled, never rendered to, so
/// any format whose texels hold the values.
fn format(texture: &Texture) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let f = texture.format;
let pick = |r, rg, rgba| match texture.components() {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
(Kind::Snorm, 8) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
// 16-bit and packed normalized formats, half floats and the small floats: halves hold
// them closely enough.
_ => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
}
}
/// `texture`'s texels as the bytes of `format`, row after row.
fn bytes(texture: &Texture, format: wgpu::TextureFormat) -> Vec<u8> {
match (&texture.texels, format) {
(Texels::Float(v), f) if f.block_copy_size(None) == Some(texture.components() as u32) => {
if matches!(
f,
wgpu::TextureFormat::R8Snorm
| wgpu::TextureFormat::Rg8Snorm
| wgpu::TextureFormat::Rgba8Snorm
) {
v.iter()
.map(|x| (x.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8)
.collect()
} else {
v.iter()
.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect()
}
}
(Texels::Float(v), f)
if f.block_copy_size(None) == Some(4 * texture.components() as u32) =>
{
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
(Texels::Float(v), _) => v
.iter()
.flat_map(|&x| half::f16::from_f32(x).to_le_bytes())
.collect(),
(Texels::Int(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
(Texels::Uint(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
}
}
fn create(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &Texture,
features: wgpu::Features,
) -> Result<(wgpu::TextureView, wgpu::TextureSampleType), String> {
let format = format(texture);
let bytes = bytes(texture, format);
let [width, height, depth] = texture.size;
let limits = device.limits();
let largest = match texture.dim {
TextureDim::D1 => limits.max_texture_dimension_1d,
TextureDim::D2 => limits.max_texture_dimension_2d,
TextureDim::D3 => limits.max_texture_dimension_3d,
};
if [width, height, depth].iter().any(|&n| n > largest) {
return Err(format!(
"{width}x{height}x{depth} is larger than this device's {largest}"
));
}
let row = width * format.block_copy_size(None).unwrap_or(4);
if bytes.len() as u64 != u64::from(row) * u64::from(height) * u64::from(depth) {
return Err(format!(
"{} bytes of texels for {width}x{height}x{depth}",
bytes.len()
));
}
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureDimension::D1,
TextureDim::D2 => wgpu::TextureDimension::D2,
TextureDim::D3 => wgpu::TextureDimension::D3,
};
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack custom texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
tex.as_image_copy(),
&bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(height),
},
size,
);
let sample_type = format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false });
Ok((tex.create_view(&Default::default()), sample_type))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shaders::Format;
fn lightmap(stage: &str, name: &str, dimension: wgpu::TextureViewDimension) -> PackTexture {
PackTexture {
stage: stage.into(),
name: name.into(),
dimension,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
image: Image::Lightmap,
}
}
fn texture(format: &str, texels: Texels) -> Texture {
Texture {
dim: TextureDim::D2,
size: [1, 1, 1],
format: Format::parse(format).unwrap(),
texels,
blur: false,
clamp: false,
}
}
#[test]
fn samplers_find_their_stage_name_and_dimension() {
use wgpu::TextureViewDimension as D;
let textures = [
lightmap("composite", "colortex4", D::D2),
lightmap("composite", "noisetex", D::D3),
lightmap("gbuffers", "gaux2", D::D2),
lightmap("composite", "noisetex", D::D1),
];
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), Some(0));
assert_eq!(
find(&textures, Group::Composite, "sampler3D", "noisetex"),
Some(1)
);
assert_eq!(
find(&textures, Group::Composite, "sampler1D", "noisetex"),
Some(3)
);
assert_eq!(
find(&textures, Group::Composite, "sampler2D", "noisetex"),
None
);
assert_eq!(
find(&textures, Group::Shadow, "sampler2D", "colortex5"),
Some(2)
);
assert_eq!(
find(&textures, Group::Deferred, "sampler2D", "colortex4"),
None
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
assert_eq!(format(&t), wgpu::TextureFormat::Rgba8Unorm);
assert_eq!(bytes(&t, format(&t)), [0, 128, 255, 255]);
let t = texture("RG8_SNORM", Texels::Float(vec![-1.0, 0.5]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg8Snorm);
assert_eq!(bytes(&t, format(&t)), [0x81, 64]);
let t = texture("R32F", Texels::Float(vec![1.0]));
assert_eq!(bytes(&t, format(&t)), 1.0f32.to_le_bytes());
let t = texture("R16F", Texels::Float(vec![1.0]));
assert_eq!(format(&t), wgpu::TextureFormat::R16Float);
assert_eq!(bytes(&t, format(&t)), [0x00, 0x3c]);
let t = texture("RG16I", Texels::Int(vec![-2, 3]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg16Sint);
assert_eq!(bytes(&t, format(&t)), [0xfe, 0xff, 3, 0]);
let t = texture("R8UI", Texels::Uint(vec![7]));
assert_eq!(format(&t), wgpu::TextureFormat::R8Uint);
assert_eq!(bytes(&t, format(&t)), [7]);
}
}
@@ -30,6 +30,8 @@ pub struct PackInputs {
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
/// Whether the biome at the camera is known (its chunk is loaded).
pub biome_known: bool,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
@@ -284,14 +286,15 @@ impl FrameUniforms {
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
// 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.
let view = frame.bob * frame.spin * crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
) * frame.bob
* frame.spin;
);
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
@@ -2,7 +2,9 @@
//! per-frame culling walk: a column whose box misses the frustum or lies past
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
use super::{Frustum, SectionKey};
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
@@ -226,7 +228,7 @@ fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
frustum: &impl Cull,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
@@ -258,7 +260,7 @@ impl<T> Resident<T> {
&self,
camera: DVec3,
radius: i32,
frustum: &Frustum,
frustum: &impl Cull,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec<Visible>,
@@ -147,6 +147,34 @@ impl Style {
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
+462 -6
View File
@@ -474,6 +474,19 @@ impl H {
_ => None,
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
H::ChunkUpdates => Some(64),
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
}
}
fn label(self, s: &Settings) -> Text {
let g = &s.horizon;
match self {
@@ -777,6 +790,39 @@ impl Item {
s.horizon.preset = Preset::Custom;
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
Item::Vanilla(o) => o.slider_steps(),
Item::Horizon(h) => h.slider_steps(),
}
}
pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
if !self.available() || self.blocked(s).is_some() {
return;
}
let Some(pos) = self.slider(s) else {
return;
};
let next_pos = if let Some(steps) = self.slider_steps() {
let steps = steps as f32;
let current = (pos * steps).round();
let next = if forward {
current + 1.0
} else {
current - 1.0
};
(next / steps).clamp(0.0, 1.0)
} else {
let delta = 1.0 / (width - 8.0).max(1.0);
if forward {
(pos + delta).min(1.0)
} else {
(pos - delta).max(0.0)
}
};
self.set_slider(s, next_pos);
self.touched(s);
}
}
/// What the options screens must do after an interaction here.
@@ -795,8 +841,8 @@ pub enum Action {
Done,
}
#[derive(Clone, Copy)]
enum Target {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Item(Item),
Button(Action),
}
@@ -814,6 +860,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
pub focus: Option<Target>,
pub can_change_value: bool,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
@@ -822,6 +870,8 @@ impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = ui::Scroll::default();
self.dragging = None;
self.focus = None;
self.can_change_value = false;
}
/// The scrollbar's column: right of the descriptions.
fn scrollbar_x(w: f32) -> f32 {
@@ -939,8 +989,10 @@ impl GraphicsUi {
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
let is_focused = self.focus == Some(wd.target);
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom)))
|| is_focused;
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
@@ -961,13 +1013,15 @@ impl GraphicsUi {
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
Some(value) => STYLE.slider_ext(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
value,
hover || self.dragging == Some(item),
is_focused,
self.can_change_value && is_focused,
live,
self.dragging == Some(item),
),
@@ -1072,11 +1126,16 @@ impl GraphicsUi {
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
continue;
}
self.focus = Some(wd.target);
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
Target::Button(action) => {
self.can_change_value = false;
vec![Action::ButtonClick, action]
}
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
self.can_change_value = false;
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
@@ -1095,10 +1154,12 @@ impl GraphicsUi {
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = true;
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
self.can_change_value = false;
item.cycle(s, right, framebuffer);
vec![Action::ButtonClick, Action::Changed]
}
@@ -1118,6 +1179,246 @@ impl GraphicsUi {
self.scroll.clamp(top, bottom, content);
self.scroll.wheel(amount, 18.0, top, bottom, content);
}
/// Mouse wheel over a control cycles it (cycle button). Sliders and disabled
/// rows fall through to scroll the page, matching Vanilla list behavior.
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle item is keyboard-focused (`self.focus`), the wheel steps/cycles it
/// regardless of cursor position. Vanilla 26.3 does not intercept the
/// wheel for sliders at all.
pub fn wheel(
&mut self,
level: Level,
s: &mut Settings,
cursor: (f32, f32),
(w, h): (f32, f32),
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Action> {
// Horizon improvement: focused slider/cycle intercepts wheel first.
if amount != 0.0
&& self.can_change_value
&& let Some(Target::Item(item)) = self.focus
&& item.available()
&& item.blocked(s).is_none()
{
if item.slider(s).is_some() {
item.step_slider(s, amount > 0.0, w);
return vec![Action::SliderDrag, Action::Changed];
} else if !matches!(item, Item::Horizon(H::HdrSettings)) {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
}
let (widgets, _, _) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let hovered = widgets.iter().find(|wd| {
ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list
|| (cursor.1 >= top
&& cursor.1 < bottom
&& wd.y >= top
&& wd.y + 20.0 <= bottom))
});
if let Some(wd) = hovered
&& let Target::Item(item) = wd.target
&& item.available()
&& item.blocked(s).is_none()
&& item.slider(s).is_none()
&& !matches!(item, Item::Horizon(H::HdrSettings))
{
if amount != 0.0 {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
return Vec::new();
}
self.scroll(level, s, h, amount);
Vec::new()
}
/// Keyboard navigation and value adjustments.
pub fn key_modifiers(
&mut self,
level: Level,
s: &mut Settings,
key: winit::keyboard::KeyCode,
shift: bool,
(w, h): (f32, f32),
framebuffer: [i32; 2],
) -> (Vec<Action>, bool) {
use winit::keyboard::KeyCode as K;
let (widgets, _, content) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let focusables: Vec<(Target, [f32; 4], bool)> = widgets
.iter()
.filter_map(|wd| match wd.target {
Target::Item(item) if item.available() && item.blocked(s).is_none() => {
Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list))
}
Target::Button(_) => Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list)),
_ => None,
})
.collect();
if focusables.is_empty() {
return (Vec::new(), false);
}
// 1. Tab / Shift+Tab navigation
if key == K::Tab {
let cur = self
.focus
.and_then(|f| focusables.iter().position(|&(t, ..)| t == f));
let next_idx = match cur {
Some(i) => {
if shift {
if i == 0 { focusables.len() - 1 } else { i - 1 }
} else {
(i + 1) % focusables.len()
}
}
None => {
if shift {
focusables.len() - 1
} else {
0
}
}
};
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
let is_slider = matches!(target, Target::Item(item) if item.slider(s).is_some());
self.can_change_value = is_slider;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
// 2. Left / Right Arrow keys
if matches!(key, K::ArrowLeft | K::ArrowRight) {
let forward = key == K::ArrowRight;
if let Some(Target::Item(item)) = self.focus
&& item.slider(s).is_some()
&& self.can_change_value
{
let width = widgets
.iter()
.find(|wd| wd.target == Target::Item(item))
.map_or(STYLE.control_width, |wd| wd.w);
item.step_slider(s, forward, width);
return (vec![Action::SliderDrag], true);
}
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if forward {
crate::gamepad::Button::DPadRight
} else {
crate::gamepad::Button::DPadLeft
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
}
return (Vec::new(), false);
}
// 3. Up / Down Arrow keys
if matches!(key, K::ArrowUp | K::ArrowDown) {
let down = key == K::ArrowDown;
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if down {
crate::gamepad::Button::DPadDown
} else {
crate::gamepad::Button::DPadUp
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
} else if !focusables.is_empty() {
let first = if down { 0 } else { focusables.len() - 1 };
let (target, [_, wy, _, wh], is_list) = focusables[first];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
return (Vec::new(), false);
}
// 4. Space / Enter / NumpadEnter
if matches!(key, K::Space | K::Enter | K::NumpadEnter)
&& let Some(target) = self.focus
{
match target {
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
return (Vec::new(), true);
} else if item == Item::Horizon(H::HdrSettings) {
return (vec![Action::ButtonClick, Action::OpenHdr], true);
} else {
item.cycle(s, shift, framebuffer);
return (vec![Action::ButtonClick, Action::Changed], true);
}
}
Target::Button(action) => {
return (vec![Action::ButtonClick, action], true);
}
}
}
// 5. Escape: the screen closes on the first press, as `Screen.keyPressed` handles it
// before the focused slider.
if key == K::Escape {
self.can_change_value = false;
return (Vec::new(), false);
}
(Vec::new(), false)
}
}
#[cfg(test)]
@@ -1514,4 +1815,159 @@ mod tests {
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
}
#[test]
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 600.0);
let fb = [854, 480];
s.horizon.chunk_updates = 10;
s.horizon.resolution_scale = 100;
// 1. Tab navigates down to Chunk Updates slider (in Level::Advanced)
let mut found_chunk_updates = false;
for _ in 0..15 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ChunkUpdates))) {
found_chunk_updates = true;
break;
}
}
assert!(found_chunk_updates);
assert!(
ui.can_change_value,
"Tab onto slider activates can_change_value"
);
// 2. Right Arrow steps chunk updates from 10 to 11
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert!(handled);
assert_eq!(actions, vec![Action::SliderDrag]);
assert_eq!(s.horizon.chunk_updates, 11);
// 3. Left Arrow steps back to 10
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.chunk_updates, 10);
// 4. Space toggles can_change_value to false
ui.key_modifiers(Level::Advanced, &mut s, K::Space, false, size, fb);
assert!(!ui.can_change_value);
// 5. Space toggles can_change_value back to true
ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, fb);
assert!(ui.can_change_value);
// 6. Escape is left to the screen, which closes, even while a slider changes values
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::Escape, false, size, fb);
assert!(!handled);
assert!(actions.is_empty());
assert!(!ui.can_change_value);
// 8. Tab to ResolutionScale (steps by 5%)
let mut found_res = false;
for _ in 0..20 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ResolutionScale))) {
found_res = true;
break;
}
}
assert!(found_res);
assert!(ui.can_change_value);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 105);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 100);
}
#[test]
fn graphics_cycle_wheel_cycles_and_slider_falls_through_to_scroll() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 300.0);
let fb = [854, 480];
// 1. Wheel on a Cycle button: Far Detail cycles mode
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let far = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::FarDetail))))
.unwrap();
let cursor = (far.x + 5.0, far.y + 5.0);
s.horizon.far_detail = FarDetail::Full;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
s.horizon.chunk_updates = 16;
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let cu = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::ChunkUpdates))))
.unwrap();
let cursor = (cu.x + 5.0, cu.y + 5.0);
let scroll_before = ui.scroll.amount;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
assert!(actions.is_empty());
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.scroll.amount > scroll_before);
}
#[test]
fn graphics_step_slider_matches_set_slider_for_all_options() {
let options = [
H::ChunkUpdates,
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
H::HdrUiBrightness,
];
for h in options {
let item = Item::Horizon(h);
let steps = h.slider_steps().expect("steps defined");
for i in 0..steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, true, 200.0);
let next_pos = (i + 1) as f32 / steps as f32;
h.set_slider(&mut s2, next_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step forward from {i}"
);
}
for i in 1..=steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, false, 200.0);
let prev_pos = (i - 1) as f32 / steps as f32;
h.set_slider(&mut s2, prev_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step backward from {i}"
);
}
}
}
}
+33 -20
View File
@@ -1898,6 +1898,38 @@ impl HudRenderer {
*previous = state;
}
}
/// Stage `patches` into the shared atlas texture.
pub fn upload_patches(
&self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
patches: &[crate::font::Patch],
) {
if let Some(texture) = &self.atlas_texture {
for p in patches {
if p.x.saturating_add(p.width) <= texture.width()
&& p.y.saturating_add(p.height) <= texture.height()
{
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
}
}
/// Stage the batch's atlas patches, vertices and uniform: before the
/// frame's first pass, so the copies share one command buffer.
pub fn prepare(
@@ -1908,26 +1940,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
) {
if let Some(texture) = &self.atlas_texture {
for p in &batch.patches {
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
self.upload_patches(uploads, encoder, &batch.patches);
if batch.vertices.is_empty() || size.contains(&0) {
return;
}
+1
View File
@@ -54,6 +54,7 @@ pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
pub mod native_emissive;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
+9 -2
View File
@@ -2,7 +2,7 @@
use super::{Column, Span, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
streaming::WorkerContext,
};
use horizon_assets::model::Direction;
@@ -253,6 +253,13 @@ type Faces = BTreeMap<FaceKey, Vec<Rect>>;
fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, material: Material) {
for (overlay, paint) in material.faces.into_iter().enumerate() {
let Some(paint) = paint else { continue };
// The same fluid flags the section mesher sets, so the material stage's water
// reaches the distant tiles too and the LOD boundary keeps one look.
let color = if material.fluid != 0 {
fluid_face(paint.color, direction as usize)
} else {
paint.color
};
faces
.entry(FaceKey {
direction,
@@ -260,7 +267,7 @@ fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, mat
overlay: overlay as u8,
layer: paint.layer,
light: paint.light,
color: paint.color,
color,
translucent: paint.translucent,
})
.or_default()
+43 -1
View File
@@ -147,6 +147,36 @@ mod vertex_tests {
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
#[test]
fn fluid_faces_keep_the_colour_and_the_emission() {
let lit = super::emitting(super::pack_rgb([0x12, 0x34, 0x56]), super::INCANDESCENT);
assert_eq!(super::fluid_face(lit, 0), 0x8D56_3412);
assert_eq!(super::fluid_face(lit, 5), 0xDD56_3412);
// Another face replaces the first.
assert_eq!(super::fluid_face(super::fluid_face(lit, 5), 1), 0x9D56_3412);
}
/// What `gfx/native_material.rs` reads back from the colour's alpha byte: the face of a
/// fluid quad, and the HDR tier.
fn decoded(color: u32) -> (Option<u32>, u32) {
let flags = color >> 24;
let high = flags >> 4;
let fluid = high >= 8 && (high & 7) < 6;
(fluid.then_some(high & 7), 15 - (flags & 15))
}
#[test]
fn the_material_stage_reads_back_what_the_mesher_encodes() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
for tier in 0..=super::VERY_BRIGHT {
let lit = super::emitting(rgb, tier);
assert_eq!(decoded(lit), (None, u32::from(tier)));
for face in 0..6 {
assert_eq!(
decoded(super::fluid_face(lit, face)),
(Some(face as u32), u32::from(tier))
);
}
}
}
}
#[cfg(test)]
@@ -794,7 +824,8 @@ mod reuse_tests;
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
/// byte's other four bits stay set.
/// byte's high nibble carries the fluid face flags (`fluid_face`); the shaders ignore the
/// vertex alpha, so both travel to the material stage untouched.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
@@ -807,6 +838,17 @@ pub const VERY_BRIGHT: u8 = 4;
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
/// The high nibble of `TerrainVertex::color`'s alpha byte: 8 plus the face (`Direction`
/// as usize) on a fluid quad, 0xF on every other. `terrain.wgsl` reads only the low nibble
/// (the emission), so only the material stage of Horizon's own packs reads it
/// (`gfx/native_material.rs`); whether the fluid is water or lava follows from the pass
/// that draws it.
pub const FLUID_FACE_BITS: u32 = 0xF000_0000;
/// `color` marked as a fluid quad of face `face` (`Direction` as usize, 0 to 5).
pub fn fluid_face(color: u32, face: usize) -> u32 {
debug_assert!(face < 6);
color & !FLUID_FACE_BITS | 0x8000_0000 | (face as u32) << 28
}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
@@ -3,7 +3,8 @@
//! beside glass and leaves, back faces, cardinal shading and max(self, above)
//! light.
use super::{
Info, MeshTables, Padded, SectionMesh, TerrainVertex, lighting, pack_coords, pack_rgb, pi,
Info, MeshTables, Padded, SectionMesh, TerrainVertex, fluid_face, lighting, pack_coords,
pack_rgb, pi,
};
use horizon_core::{
geometry::{Axis, Direction, VoxelShape, trig},
@@ -319,14 +320,19 @@ pub(super) fn tesselate(
} else {
&mut out.solid
};
let mut quad =
|v: [([f32; 3], [f32; 2]); 4], layer: u16, color: u32, light: u16, back: bool| {
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
layer: u16,
color: u32,
light: u16,
back: bool,
face: usize| {
let color = fluid_face(color, face);
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
let bottom = if render_down { EPSILON } else { 0.0 };
if render_up && !occluded_by(Direction::Up, nw.min(sw).min(se.min(ne)), up) {
@@ -375,6 +381,7 @@ pub(super) fn tesselate(
color(cardinal(D::Up)),
light([x, y, z]),
backward,
Direction::Up as usize,
);
}
if render_down {
@@ -390,6 +397,7 @@ pub(super) fn tesselate(
color(cardinal(D::Down)),
light([x, y - 1, z]),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
@@ -444,6 +452,7 @@ pub(super) fn tesselate(
color(up_shade * side),
side_light,
!is_overlay,
d as usize,
);
}
}
@@ -74,7 +74,13 @@ impl Sides {
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
self.visible_from(eye, eye[1], draw);
}
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
/// its water mirror's eye below the plane, which differ only in y.
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
@@ -84,6 +90,7 @@ impl Sides {
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
@@ -312,6 +319,24 @@ mod tests {
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
}
#[test]
fn an_eye_and_its_mirror_below_see_the_faces_of_both() {
let mut solid = cube([0.0; 3], 0);
let sides = sort(&mut solid);
let both = |eye: [f32; 3], low: f32| {
let mut out = Vec::new();
sides.visible_from(eye, low, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
assert_eq!(both([8.0, 8.0, 8.0], -8.0), [(0, 3), (5, 1)]);
// A mirror above the block's bottom adds nothing.
assert_eq!(both([8.0, 8.0, 8.0], 0.5), [(0, 3)]);
// The sides depend on x and z alone.
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
@@ -0,0 +1,61 @@
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
pub const MAX: usize = 32;
/// Load only bounded, explicitly named emissive textures from the active pack.
pub fn load(root: &Path, sprites: &mut Sprites) {
let mut names: Vec<_> = crate::atlas_sources::list_textures(root)
.into_iter()
.filter(|name| {
name.split_once(':')
.is_some_and(|(_, p)| p.starts_with("emissive/"))
})
.take(MAX)
.collect();
names.sort();
for name in names {
let file = crate::atlas_sources::texture_path(root, &name, "textures");
if std::fs::metadata(file).is_ok_and(|m| m.len() <= 1 << 20) {
let _ = sprites.insert(root, &name);
}
}
}
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
pub fn mappings<'a>(
layers: &BTreeMap<String, u32>,
requests: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Vec<[u32; 4]> {
let mut pairs = BTreeMap::new();
for (base, overlay) in requests.into_iter().take(MAX) {
if let (Some(&b), Some(&o)) = (layers.get(base), layers.get(overlay)) {
pairs.entry(b).or_insert(o);
}
}
pairs.into_iter().map(|(b, o)| [b, o, 0, 0]).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_existing_pairs_are_mapped() {
let layers = [
("minecraft:block/ore".into(), 3),
("mine:emissive/ore".into(), 7),
]
.into();
assert_eq!(
mappings(
&layers,
[
("minecraft:block/ore", "mine:emissive/ore"),
("missing", "mine:emissive/ore")
]
),
vec![[3, 7, 0, 0]]
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
//! Overlay reuse validated by source topology and filesystem stamps.
//! Published output is an owned cache artifact; hits do not traverse its tree.
use super::{Pack, Source, build_overlay_uncached};
use crate::zip::Zip;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
@@ -77,6 +78,24 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
/// The largest zip hashed by its bytes: vanilla's limit on a server pack.
const WHOLE_ZIP: u64 = 250 << 20;
/// A zip up to `whole` by its bytes, which also catches an edit its checksums
/// don't show (a pack may leave them empty, nothing reads them). A larger one
/// would take minutes on every check, so by its stamp and its directory: what a
/// local pack changes when it is replaced, and the directory reads in a moment.
fn zip_content(hash: &mut Sha256, path: &Path, whole: u64) -> io::Result<()> {
let meta = fs::metadata(path)?;
match Zip::open(path) {
Ok(zip) if meta.len() > whole => {
hash.update(meta.len().to_le_bytes());
field(hash, format!("{:?}", meta.modified()?).as_bytes());
hash.update(zip.directory());
Ok(())
}
_ => content(hash, path),
}
}
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
@@ -250,8 +269,8 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
// time rebuilt the whole overlay each time.
zip_content(&mut hash, path, WHOLE_ZIP)?;
}
}
}
@@ -632,6 +651,36 @@ mod tests {
assert!(!marker.exists());
}
#[test]
fn a_large_zip_is_stamped_by_its_directory_and_time() {
let f = Fixture::new();
let path = f.0.join("large.zip");
let at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1 << 30);
let write = |name: &str, first: u8, modified| {
let mut body = vec![0u8; 1 << 18];
body[0] = first;
fs::write(&path, archive(&[(name, body)], false)).unwrap();
let file = fs::File::options().write(true).open(&path).unwrap();
file.set_modified(modified).unwrap();
};
let signature = |whole| {
let mut hash = Sha256::new();
zip_content(&mut hash, &path, whole).unwrap();
finish(hash)
};
write("a", 0, at);
let (bytes, directory) = (signature(u64::MAX), signature(0));
// Above the limit the entries' bytes go unread; at or under it they count.
write("a", 1, at);
assert_eq!(signature(0), directory);
assert_ne!(signature(u64::MAX), bytes);
write("b", 0, at);
assert_ne!(signature(0), directory);
write("a", 0, at + std::time::Duration::from_secs(60));
assert_ne!(signature(0), directory);
assert_eq!(signature(u64::MAX), bytes);
}
#[test]
fn zip_content_and_failed_build_invalidate() {
let f = Fixture::new();
+40 -1
View File
@@ -1075,6 +1075,8 @@ pub struct ParticleEngine {
}
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
const SPORE_BLOSSOM_LIMIT: usize = 1000;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
@@ -1480,11 +1482,27 @@ impl ParticleEngine {
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned.
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
@@ -4346,6 +4364,27 @@ mod tests {
assert!(tall.iter().all(|p| p[1] < 1.0));
}
#[test]
fn spore_blossom_air_stops_at_its_particle_limit() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let spore = ParticleOptions::simple(ParticleType::SporeBlossomAir);
for i in 0..1001 {
engine.create(&spore, [f64::from(i % 16), 70.0, 0.0], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1000);
assert!(
engine
.pending
.iter()
.all(|p| p.ty == ParticleType::SporeBlossomAir)
);
// A type without a `ParticleLimit` is not capped.
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..10 {
engine.create(&smoke, [0.0; 3], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1010);
}
#[test]
fn particle_names_round_trip() {
for &ty in ParticleType::ALL {
assert_eq!(
+12 -1
View File
@@ -311,6 +311,9 @@ impl Mailbox {
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
@@ -656,7 +659,11 @@ impl Streaming {
generator.decorate(&mut worker, &mut region);
Done::Decorated(x, z, region)
}
Job::Finish(proto) => Done::Generated(generator.finish(proto)),
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
@@ -734,6 +741,7 @@ impl Streaming {
protos: Default::default(),
light_views: Default::default(),
decorated: Default::default(),
dropped_new: Default::default(),
decorating: 0,
saving: 0,
decoration_order: DecorationOrder::default(),
@@ -943,6 +951,7 @@ impl Streaming {
// not give it back.
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
if chunk.fresh {
self.dropped_new.insert(key);
self.saves.save_finished(chunk);
}
return;
@@ -966,6 +975,8 @@ impl Streaming {
// A chunk saved with its light needs no light job; its unlit
// neighbours still light themselves from it.
let loaded = chunk.light_loaded;
let new = self.dropped_new.remove(&key) || chunk.generated;
world.loaded_chunks.push((key.0, key.1, new));
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
@@ -24,6 +24,10 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
@@ -151,6 +155,9 @@ struct TerrainOut {
@location(7) local: vec3<f32>,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
// The camera-relative height, for the water mirror's cut at the plane.
@location(9) height: f32,
@location(10) @interpolate(flat) emissive_layer: u32,
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -221,6 +228,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
let uv = vec2(block, sky) * 16.0;
return textureSampleLevel(lightmap, lightmap_sampler,
@@ -282,9 +298,20 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.local = position;
out.uv = uv;
out.layer = layer;
out.emissive_layer = 0xffffffffu;
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
out.emissive_layer = globals.emissive_layers[i].y;
break;
}
}
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
}
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.height = pos.y;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
out.fade = fade_code(w);
@@ -304,6 +331,15 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
if in.height < globals.fog_ends.w { discard; }
return solid_color(in);
}
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -316,6 +352,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
if in.emissive_layer != 0xffffffffu {
let mask = sprite(in.uv, in.emissive_layer);
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// Water, glass and other see-through faces draw whole as they fade in, the
+37 -5
View File
@@ -391,6 +391,27 @@ pub fn slider(
hover: bool,
active: bool,
dragging: bool,
) {
slider_ext(
batch, atlas, x, y, w, label, value, hover, false, false, active, dragging,
);
}
/// `AbstractSliderButton.extractWidgetRenderState`: track, 8px handle at `value` (0..1),
/// with support for keyboard focus and `canChangeValue`.
pub fn slider_ext(
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
w: f32,
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -402,7 +423,7 @@ pub fn slider(
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
let (track, handle) = slider_sprites(hover, active, dragging);
let (track, handle) = slider_sprites_ext(hover, active, focused, can_change_value, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
nine_slice(
batch,
@@ -421,15 +442,26 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
/// Track and handle sprites: the track outline shows only while the slider is
/// dragged, hovering highlights just the handle.
#[cfg(test)]
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
let track = if dragging {
slider_sprites_ext(hover, active, false, false, dragging)
}
/// Track and handle sprites: the track outline shows while dragged or focused with
/// keyboard navigation (!can_change_value), hovering or editing highlights the handle.
fn slider_sprites_ext(
hover: bool,
active: bool,
focused: bool,
can_change_value: bool,
dragging: bool,
) -> (&'static str, &'static str) {
let track = if dragging || (active && focused && !can_change_value) {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let handle = if hover && active {
let handle = if active && (hover || can_change_value || dragging) {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
@@ -70,6 +70,9 @@ pub struct World {
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks that just came into this level from a worker, and whether each
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
@@ -109,6 +112,10 @@ pub struct World {
/// The moving-piston block entities of the level, by position, copied
/// from the block ticks after each tick: collision and rendering read them.
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
/// The `ShulkerBoxBlockEntity` lid state of the level, by position, copied
/// from the block entities after each tick: the collision queries read it
/// (`ShulkerBoxBlockEntity.getBoundingBox`), as `moving_pistons` for pistons.
pub shulkers: FxHashMap<[i32; 3], crate::block_entity_anim::Shulker>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Weather {
@@ -188,6 +195,7 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
@@ -203,6 +211,7 @@ impl World {
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
shulkers: FxHashMap::default(),
pois: Default::default(),
}
}
+5
View File
@@ -146,6 +146,11 @@ impl Zip {
len,
})
}
/// The central directory on, as read at opening: every entry's name, sizes
/// and checksum, so what changes with the archive's content.
pub fn directory(&self) -> Arc<[u8]> {
self.archive.clone().into_inner().tail
}
/// The files, as `FilePackResources.listResources` lists them (folder
/// entries skipped).
pub fn names(&self) -> impl Iterator<Item = &str> {
@@ -12,6 +12,191 @@
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.menu_navigation": "Navegación de menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Retardo del chat",
"horizon.options.lightbar.health": "Salud",
"horizon.options.lightbar.dimension": "Dimensión",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Puntero",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
"horizon.options.search": "Buscar ajustes...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.controller": "Ajustes del mando",
"horizon.pages.controller_binds": "Asignaciones del mando",
"horizon.pages.extras": "Extras de Horizon",
"horizon.pages.settings": "Ajustes de Horizon",
"horizon.pages.agent": "Agente de IA",
"horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
"horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
"horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
"horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
"horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
"horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
"horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
"horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
"horizon.pages.open.graphics": "Gráficos...",
"horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
"horizon.pages.open.shader_packs": "Paquetes de shaders...",
"horizon.pages.open.controller": "Ajustes del mando...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
"horizon.pages.open.controller_binds": "Asignaciones del mando...",
"horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
"horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
"horizon.mods.stopped": " (Detenido)",
"horizon.mods.not_built": " (Sin compilar)",
"horizon.mods.dev": " (Desarrollo)",
"horizon.mods.settings": "Ajustes",
"horizon.mods.open_folder": "Abrir carpeta de mods",
"horizon.shader_packs.empty": "No hay paquetes de shaders: ponlos en la carpeta shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copiar datos de conexión",
"horizon.multiplayer.join": "Unirse",
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
"horizon.multiplayer.join_friend": "Unirse a un amigo",
"horizon.multiplayer.invite_code": "Código de invitación",
"horizon.multiplayer.relay": "Relay (opcional)",
"Preset": "Preajuste",
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lejano",
"Cave Culling": "Oclusión de cuevas",
"Entity Culling": "Oclusión de entidades",
"Chunk Updates": "Actualizaciones de chunks",
"Upload Budget": "Presupuesto de subida",
"Sky": "Cielo",
"Fog": "Niebla",
"Weather": "Clima",
"Animated Textures": "Texturas animadas",
"Present Mode": "Modo de presentación",
"Frame Pacing": "Ritmo de fotogramas",
"Worker Threads": "Hilos de trabajo",
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"HDR Settings...": "Ajustes HDR...",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
"UI Brightness": "Brillo de la interfaz",
"Battery Mode": "Modo batería",
"Graphics API": "API de gráficos",
"Performance Overlay": "Superposición de rendimiento",
"Full Detail Distance": "Distancia de detalle completo",
"On": "Activado",
"Off": "Desactivado",
"Unlimited": "Ilimitado",
"Auto": "Automático",
"Custom": "Personalizado",
"Performance": "Rendimiento",
"Balanced": "Equilibrado",
"Quality": "Calidad",
"Ultra": "Ultra",
"Ultra Performance": "Rendimiento ultra",
"Native": "Nativo",
"Full": "Completo",
"Reduced": "Reducido",
"Efficient": "Eficiente",
"Precise": "Preciso",
"Auto (VSync)": "Automático (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Inmediato",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS y gráfico",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Sin coste",
"Low cost": "Coste bajo",
"Medium cost": "Coste medio",
"High cost": "Coste alto",
"Saves time": "Ahorra tiempo",
"Rendering": "Renderizado",
"Display": "Pantalla",
"Preferences": "Preferencias",
"Overlay": "Superposición",
"HDR": "HDR",
"Graphics": "Gráficos",
"Graphics: Advanced": "Gráficos: avanzados",
"Graphics: HDR": "Gráficos: HDR",
"Advanced...": "Avanzados...",
"Simple...": "Simples...",
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
"%s chunks": "%s chunks",
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"%s nits": "%s nits",
"Auto (%s)": "Automático (%s)",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reducido dibuja el terreno lejano con menos detalle, para GPU a las que ralentiza.",
"Field of view. Wider shows more terrain at once.": "Campo de visión. Más amplio muestra más terreno a la vez.",
"Caps frames per second; a cap saves power and heat.": "Limita los fotogramas por segundo; un límite ahorra energía y calor.",
"Waits for the display's refresh: no tearing, a little latency.": "Espera al refresco de la pantalla: sin desgarro, algo de latencia.",
"Size of menus and the HUD.": "Tamaño de los menús y del HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ajusta juntas las opciones siguientes. Cambiar una lo pasa a Personalizado.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el fuego y la luz del sol brillan más que el blanco. Requiere una pantalla HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "El negro más oscuro de tu pantalla. 0 para OLED; las LCD brillan un poco.",
"How bright highlights get. Auto: the peak your system reports.": "Cuánto brillan los reflejos. Automático: el pico que informa tu sistema.",
"How bright white paper and menus look. Auto: your desktop's white.": "Qué tan brillante se ve el blanco del papel y los menús. Automático: el blanco de tu escritorio.",
"How bright the HUD and menus are, against paper white.": "Qué tan brillantes son el HUD y los menús respecto al blanco de papel.",
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
"Blends grass and water colours across biomes when building chunks.": "Mezcla los colores del césped y el agua entre biomas al construir chunks.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Capa de nubes: desactivada, plana (rápida) o 3D (detallada).",
"How far clouds reach, in chunks.": "Hasta dónde llegan las nubes, en chunks.",
"Draws the sky, sun, moon and stars.": "Dibuja el cielo, el sol, la luna y las estrellas.",
"Distance fog. Off shows the edge of the loaded world.": "Niebla de distancia. Desactivada muestra el borde del mundo cargado.",
"Draws rain and snow.": "Dibuja la lluvia y la nieve.",
"How far rain and snow fall around you.": "Hasta dónde caen la lluvia y la nieve a tu alrededor.",
"How many particles effects spawn.": "Cuántas partículas generan los efectos.",
"Round shadows under mobs and items.": "Sombras redondas bajo mobs y objetos.",
"Leaves with see-through gaps. Off draws them solid.": "Hojas con huecos transparentes. Desactivado las dibuja sólidas.",
"Sorts water and glass more exactly behind each other.": "Ordena el agua y el cristal con más precisión unos tras otros.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Qué reconstrucciones de chunks esperan al fotograma: ninguna, cercanas o la tuya.",
"Animates water, lava, fire and other moving textures.": "Anima el agua, la lava, el fuego y otras texturas en movimiento.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxeles del mundo por píxel de pantalla. Por debajo del 100 % es más rápido en la GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
"How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
"Fills the screen (borderless).": "Llena la pantalla (sin bordes).",
"Brightens dark places.": "Ilumina los lugares oscuros.",
"Lowers the frame rate when minimised, or idle too.": "Reduce los fotogramas por segundo al minimizar o al estar inactivo.",
"Sways the view while walking.": "Balancea la vista al caminar.",
"Fades newly loaded chunks in.": "Hace aparecer gradualmente los chunks recién cargados.",
"Darkens the screen's edges.": "Oscurece los bordes de la pantalla.",
"Shows when the world saves.": "Muestra cuándo se guarda el mundo.",
"Shows attack cooldown.": "Muestra el enfriamiento del ataque.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
"Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
@@ -1194,6 +1194,7 @@ pub fn main() -> horizon_client::Result<()> {
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
}
};
}
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -168,7 +168,7 @@ fn scene(
z,
u,
w,
argb(v.color | EMISSION_BITS),
argb(v.color | EMISSION_BITS | FLUID_FACE_BITS),
light_coords(v.light)
])
})
@@ -241,7 +241,7 @@ pub fn main() -> horizon_client::Result<()> {
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
let trades = Trades::load(root);
let trades = Trades::load(root, &PackOverlay::NONE);
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
@@ -204,6 +204,9 @@ pub fn launch(socket: &Path, args: &[String]) -> Result<Value, Value> {
let stderr = file
.try_clone()
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
// Windows: the game would otherwise hold our caller's pipes open (horizon-threads).
#[cfg(windows)]
horizon_threads::keep_stdio_from_children();
let mut child = cmd
.args(&o.game_args)
.arg("--control-socket")
+115 -4
View File
@@ -13,6 +13,7 @@ pub struct Confirm {
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
pub focus: Option<usize>,
}
impl Confirm {
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
@@ -20,6 +21,28 @@ impl Confirm {
Self {
lines,
buttons: [buttons[0], buttons[1]],
focus: None,
}
}
/// Tab cycles focus between yes (0) and no (1).
pub fn tab(&mut self, reverse: bool) {
self.focus = match self.focus {
Some(0) => Some(1),
Some(1) => Some(0),
None => Some(if reverse { 1 } else { 0 }),
_ => Some(0),
};
}
/// Left or right arrow key focuses yes or no.
pub fn arrow(&mut self, left: bool) {
self.focus = Some(if left { 0 } else { 1 });
}
/// The answer of the currently focused button.
pub fn answer(&self) -> Option<bool> {
match self.focus {
Some(0) => Some(true),
Some(1) => Some(false),
_ => None,
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -56,8 +79,9 @@ impl Confirm {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
for (i, ((bx, by), label)) in self.buttons.into_iter().zip(labels).enumerate() {
let is_focused = self.focus == Some(i);
let hover = active && (ui::hovered(cursor, bx, by, 150.0, 20.0) || is_focused);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
@@ -71,6 +95,7 @@ pub struct Choice {
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
pub focus: Option<usize>,
}
impl Choice {
pub fn new(
@@ -111,8 +136,47 @@ impl Choice {
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
focus: None,
}
}
/// Tab cycles focus across the buttons.
pub fn tab(&mut self, reverse: bool) {
let count = self.buttons.len();
if count == 0 {
return;
}
self.focus = match self.focus {
Some(cur) => {
if reverse {
Some(if cur == 0 { count - 1 } else { cur - 1 })
} else {
Some((cur + 1) % count)
}
}
None => Some(if reverse { count - 1 } else { 0 }),
};
}
/// Left or right arrow key moves focus.
pub fn arrow(&mut self, left: bool) {
let count = self.buttons.len();
if count == 0 {
return;
}
self.focus = match self.focus {
Some(cur) => {
if left {
Some(cur.saturating_sub(1))
} else {
Some((cur + 1).min(count - 1))
}
}
None => Some(if left { 0 } else { count - 1 }),
};
}
/// The index of the currently focused button.
pub fn pressed(&self) -> Option<usize> {
self.focus
}
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
self.buttons
@@ -130,8 +194,9 @@ impl Choice {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
for (i, (&(bx, by), label)) in self.buttons.iter().zip(labels).enumerate() {
let is_focused = self.focus == Some(i);
let hover = ui::hovered(cursor, bx, by, self.width, 20.0) || is_focused;
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
@@ -177,4 +242,50 @@ mod tests {
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
#[test]
fn confirm_and_choice_keyboard_navigation() {
let (title, message) = (Text::literal("Title"), Text::literal("Message"));
let mut confirm = Confirm::new(&font(), (480.0, 270.0), &title, &message);
assert_eq!(confirm.focus, None);
assert_eq!(confirm.answer(), None);
confirm.tab(false);
assert_eq!(confirm.focus, Some(0));
assert_eq!(confirm.answer(), Some(true));
confirm.tab(false);
assert_eq!(confirm.focus, Some(1));
assert_eq!(confirm.answer(), Some(false));
confirm.tab(true);
assert_eq!(confirm.focus, Some(0));
confirm.arrow(false);
assert_eq!(confirm.focus, Some(1));
confirm.arrow(true);
assert_eq!(confirm.focus, Some(0));
let mut choice = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
assert_eq!(choice.focus, None);
assert_eq!(choice.pressed(), None);
choice.tab(false);
assert_eq!(choice.focus, Some(0));
assert_eq!(choice.pressed(), Some(0));
choice.tab(false);
assert_eq!(choice.focus, Some(1));
choice.tab(false);
assert_eq!(choice.focus, Some(2));
choice.tab(false);
assert_eq!(choice.focus, Some(0));
choice.arrow(false);
assert_eq!(choice.focus, Some(1));
choice.arrow(true);
assert_eq!(choice.focus, Some(0));
}
}
+28 -2
View File
@@ -117,8 +117,8 @@ impl Clicks {
self.quick_crafting
}
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
/// release clicks nothing more.
/// A press another screen control handled (such as a book button or
/// double click's `PICKUP_ALL`): its release clicks nothing more.
pub fn skip_release(&mut self) {
self.quick_crafting = None;
self.skip_next_release = true;
@@ -269,6 +269,32 @@ mod tests {
);
}
#[test]
fn a_recipe_book_click_does_not_throw_a_carried_stack_after_the_layout_shifts() {
let mut clicks = Clicks::default();
let button = Target::<usize>::Nothing;
// The book button consumes the press while the player carries a stack.
assert_eq!(
clicks.press(&button, Button::Left, false, false, false),
None
);
clicks.skip_release();
// Closing the book shifts the inventory before mouseReleased tests the
// pointer again; it may now look like an outside click.
assert_eq!(
clicks.release(
&Target::<usize>::Outside,
Button::Left,
false,
false,
false,
false,
),
Release::Nothing
);
}
#[test]
fn a_creative_page_release_does_not_throw_a_carried_stack() {
let mut clicks = Clicks::default();
@@ -71,6 +71,12 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
}
}
}
let components: Vec<_> = horizon_protocol::item::custom::entries()
.map(|(id, name)| (id as u64, name.to_owned()))
.collect();
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
}
identities.insert(root.to_owned(), hash);
Ok(hash)
}
+1 -1
View File
@@ -468,7 +468,7 @@ fn gradient(
}
}
/// `ARGB.srgbLerp`: each channel by `Mth.lerpInt`.
fn srgb_lerp(alpha: f32, p0: u32, p1: u32) -> u32 {
pub fn srgb_lerp(alpha: f32, p0: u32, p1: u32) -> u32 {
let channel = |shift: u32| {
let (a, b) = (((p0 >> shift) & 255) as i32, ((p1 >> shift) & 255) as i32);
(a + (alpha * (b - a) as f32).floor() as i32) as u32
+77 -1
View File
@@ -243,7 +243,8 @@ impl DebugEntries {
/// `SerializedOptions.CODEC`: an optional `profile` name and an optional
/// `custom` map of identifiers to statuses.
fn parse(text: &str) -> Option<(Option<Profile>, BTreeMap<String, Status>)> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
let mut json: serde_json::Value = serde_json::from_str(text).ok()?;
upgrade(&mut json);
let object = json.as_object()?;
let profile = match object.get("profile") {
Some(name) => Some(Profile::parse(name.as_str()?)?),
@@ -350,6 +351,54 @@ impl DebugEntries {
}
}
/// `DataFixTypes.DEBUG_PROFILE.wrapCodec(..., 4649)`: a file without a
/// `DataVersion` is from 4649, the version the file first had.
const FIRST_VERSION: i64 = 4649;
/// The `DataFixers` rules for `References.DEBUG_PROFILE`, each run when the
/// file is older than its schema, on the `custom` map.
fn upgrade(json: &mut serde_json::Value) {
let version = json
.get("DataVersion")
.and_then(serde_json::Value::as_i64)
.unwrap_or(FIRST_VERSION);
let Some(custom) = json.get_mut("custom").and_then(|c| c.as_object_mut()) else {
return;
};
// 4650 DebugProfileOverlayReferenceFix: the status `inF3` is `inOverlay`.
if version < 4650 {
for status in custom.values_mut() {
if status.as_str() == Some("inF3") {
*status = "inOverlay".into();
}
}
}
// 4763 DebugProfileLookingAtSplitFix: the targeted block and fluid
// entries became their `_state` halves; a key already there wins.
if version < 4763 {
for (old, new) in [
(
"minecraft:looking_at_block",
"minecraft:looking_at_block_state",
),
(
"minecraft:looking_at_fluid",
"minecraft:looking_at_fluid_state",
),
] {
if let Some(status) = custom.remove(old) {
custom.entry(new).or_insert(status);
}
}
}
// 5001 DebugProfileRenameEntryFix: `post_effect` is `post_effects`.
if version < 5001
&& let Some(status) = custom.remove("minecraft:post_effect")
{
custom.insert("minecraft:post_effects".into(), status);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -410,6 +459,33 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn old_files_are_upgraded_by_their_data_version() {
// No DataVersion: every fix runs.
let (_, custom) = DebugEntries::parse(
r#"{"custom":{"minecraft:fps":"inF3","minecraft:looking_at_block":"alwaysOn",
"minecraft:looking_at_fluid":"inF3","minecraft:looking_at_fluid_state":"never",
"minecraft:post_effect":"alwaysOn"}}"#,
)
.unwrap();
assert_eq!(custom["minecraft:fps"], Status::InOverlay);
assert_eq!(custom["minecraft:looking_at_block_state"], Status::AlwaysOn);
assert_eq!(custom["minecraft:looking_at_fluid_state"], Status::Never);
assert_eq!(custom["minecraft:post_effects"], Status::AlwaysOn);
assert!(!custom.contains_key("minecraft:looking_at_block"));
assert!(!custom.contains_key("minecraft:post_effect"));
// From 4763 on, only the post effects rename is left.
let (_, custom) = DebugEntries::parse(
r#"{"DataVersion":4763,"custom":{"minecraft:looking_at_block":"alwaysOn",
"minecraft:post_effect":"alwaysOn"}}"#,
)
.unwrap();
assert_eq!(custom["minecraft:looking_at_block"], Status::AlwaysOn);
assert_eq!(custom["minecraft:post_effects"], Status::AlwaysOn);
// `inF3` is no status any more once the file is past 4650.
assert!(DebugEntries::parse(r#"{"DataVersion":4650,"custom":{"fps":"inF3"}}"#).is_none());
}
#[test]
fn the_list_sorts_text_before_renderers() {
let all = search("");
+333 -4
View File
@@ -1,6 +1,5 @@
//! `DebugScreenOverlay`: the F3 text. The entries of the default profile
//! (`DebugScreenEntries.PROFILES`) report priority lines, regular lines and
//! groups; the overlay splits them into a left and a right column and draws
//! `DebugScreenOverlay`: the F3 text. The enabled entries
//! (`debug-profile.json`) report priority lines, regular lines and groups; the overlay splits them into a left and a right column and draws
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
@@ -242,7 +241,12 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
}
for (line, left, top, _) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
// Entries colour their text with legacy codes.
text: if line.contains('§') {
Text::legacy(line)
} else {
Text::literal(line.clone())
},
x: left,
y: top,
color: TEXT,
@@ -254,6 +258,234 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
out
}
/// `ChatFormatting.UNDERLINE`, `GREEN` and `RED` as legacy codes.
pub const UNDERLINE: &str = "§n";
pub const GREEN: &str = "§a";
pub const RED: &str = "§c";
/// `DebugEntryLookingAtState.extractInfo`: the underlined heading with the
/// position, the state's registered name, then one line per property
/// (`true` green, `false` red).
pub fn state_lines<'a>(
prefix: &str,
pos: [i32; 3],
name: &str,
properties: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Vec<String> {
let mut out = vec![
format!("{UNDERLINE}{prefix}: {}, {}, {}", pos[0], pos[1], pos[2]),
name.to_owned(),
];
out.extend(properties.into_iter().map(|(key, value)| {
let color = match value {
"true" => GREEN,
"false" => RED,
_ => "",
};
format!("{key}: {color}{value}")
}));
out
}
/// `DebugEntryLookingAt.addTagEntries`: `#` and each tag holding `id`.
/// Vanilla walks a hash set; these are sorted, so the order holds still.
pub fn tag_lines<'a>(
tags: impl IntoIterator<Item = (&'a String, &'a std::collections::HashSet<String>)>,
id: &str,
) -> Vec<String> {
let mut out: Vec<String> = tags
.into_iter()
.filter(|(_, members)| members.contains(id))
.map(|(tag, _)| format!("#{tag}"))
.collect();
out.sort();
out
}
/// `DebugEntryHeightmap.HEIGHTMAP_NAMES`.
fn heightmap_name(kind: horizon_world::heightmap::HeightmapType) -> &'static str {
use horizon_world::heightmap::HeightmapType::*;
match kind {
WorldSurfaceWg => "SW",
WorldSurface => "S",
OceanFloorWg => "OW",
OceanFloor => "O",
MotionBlocking => "M",
MotionBlockingNoLeaves => "ML",
}
}
/// `DebugEntryHeightmap`: the client chunk's heightmaps (`sendToClient`),
/// then the server chunk's (`keepAfterWorldgen`, `??` without one), each
/// type's height from `height`.
pub fn heightmap_lines(
client: impl Fn(horizon_world::heightmap::HeightmapType) -> i32,
server: Option<&dyn Fn(horizon_world::heightmap::HeightmapType) -> i32>,
) -> [String; 2] {
use horizon_world::heightmap::HeightmapType;
let mut ch = String::from("CH");
for kind in HeightmapType::ALL
.into_iter()
.filter(|k| k.send_to_client())
{
ch.push_str(&format!(" {}: {}", heightmap_name(kind), client(kind)));
}
let mut sh = String::from("SH");
for kind in HeightmapType::ALL
.into_iter()
.filter(|k| k.keep_after_worldgen())
{
let height = server.map_or_else(|| "??".to_owned(), |h| h(kind).to_string());
sh.push_str(&format!(" {}: {height}", heightmap_name(kind)));
}
[ch, sh]
}
/// `MobCategory.getDebugAbbreviation`, in `MobCategory.values()` order.
const CATEGORY_ABBREVIATIONS: [&str; 8] = ["MO", "C", "AM", "AX", "UWC", "WC", "WA", "MI"];
/// `DebugEntrySpawnCounts`: the spawnable chunks of the last spawn state,
/// then each category's count.
pub fn spawn_counts_line(chunks: i32, counts: &[i32; 8]) -> String {
let categories: Vec<String> = CATEGORY_ABBREVIATIONS
.iter()
.zip(counts)
.map(|(abbreviation, n)| format!("{abbreviation}: {n}"))
.collect();
format!("SC: {chunks}, {}", categories.join(", "))
}
/// `DebugEntryLocalDifficulty` with `DifficultyInstance.getSpecialMultiplier`.
pub fn local_difficulty_line(effective: f32) -> String {
let special = if effective < 2.0 {
0.0
} else if effective > 4.0 {
1.0
} else {
(effective - 2.0) / 2.0
};
format!("Local Difficulty: {effective:.2} // {special:.2}")
}
/// `DebugEntryGpuUtilization`: past 100% in red.
pub fn gpu_line(utilization: f64) -> String {
if utilization > 100.0 {
format!("GPU: {RED}100%")
} else {
format!("GPU: {}%", utilization.round() as i64)
}
}
/// `DebugEntrySoundMood`: `Library.getChannelDebugString` and the mood.
pub fn sound_mood_line(used: [usize; 2], max: [usize; 2], mood: f32) -> String {
format!(
"Sounds: {}/{} + {}/{} (Mood {}%)",
used[0],
max[0],
used[1],
max[1],
(mood * 100.0).round() as i32
)
}
/// `DebugEntrySoundCache`: the decoded buffers and their size, in MiB
/// rounded up.
pub fn sound_cache_line(buffers: usize, bytes: usize) -> String {
let mib = (bytes as f64 / 1024.0 / 1024.0).ceil() as i64;
format!("Sound cache: {buffers} buffers, {mib} MiB")
}
/// `DebugEntryLight` without the server's values
/// (`DEBUG_SHOW_SERVER_DEBUG_VALUES` is off): the raw brightness is the
/// brighter of the two at the feet.
pub fn light_line(sky: u8, block: u8) -> String {
format!(
"Client Light: {} ({sky} sky, {block} block)",
sky.max(block)
)
}
/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
/// anonymous resident memory for the heap's used part, all resident memory
/// for its committed part, the machine's memory for its maximum.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ProcessMemory {
pub used: u64,
pub allocated: u64,
pub max: u64,
}
impl ProcessMemory {
/// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
pub fn read() -> Option<Self> {
if !cfg!(target_os = "linux") {
return None;
}
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
Self::parse(&status, &meminfo)
}
fn parse(status: &str, meminfo: &str) -> Option<Self> {
let kib = |text: &str, key: &str| -> Option<u64> {
let line = text.lines().find(|l| l.starts_with(key))?;
Some(line.split_whitespace().nth(1)?.parse::<u64>().ok()? * 1024)
};
Some(Self {
used: kib(status, "RssAnon:")?,
allocated: kib(status, "VmRSS:")?,
max: kib(meminfo, "MemTotal:")?.max(1),
})
}
}
/// `DebugEntryMemory` and its `AllocationRateCalculator`: read twice a
/// second; a drop in use (a GC in vanilla) keeps the last rate.
#[derive(Debug, Default)]
pub struct MemoryMeter {
last: Option<(std::time::Instant, ProcessMemory)>,
rate: i64,
}
impl MemoryMeter {
pub fn lines(&mut self) -> Option<[String; 3]> {
let now = std::time::Instant::now();
let memory = match self.last {
Some((at, memory)) if now.duration_since(at).as_millis() < 500 => memory,
last => {
let memory = ProcessMemory::read()?;
if let Some((at, before)) = last
&& memory.used >= before.used
{
let seconds = now.duration_since(at).as_secs_f64();
self.rate = ((memory.used - before.used) as f64 / seconds).round() as i64;
}
self.last = Some((now, memory));
memory
}
};
Some(memory_lines(memory, self.rate))
}
}
/// `DebugEntryMemory`'s three lines.
pub fn memory_lines(m: ProcessMemory, rate: i64) -> [String; 3] {
let mib = |bytes: u64| bytes / 1024 / 1024;
[
format!(
"Mem: {:2}% {:03}/{:03}MiB",
m.used * 100 / m.max,
mib(m.used),
mib(m.max)
),
format!("Allocation rate: {:03}MiB/s", rate / 1024 / 1024),
format!(
"Allocated: {:2}% {:03}MiB",
m.allocated * 100 / m.max,
mib(m.allocated)
),
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -262,6 +494,103 @@ mod tests {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn looking_at_lines_match_vanilla() {
let props = [
("facing", "north"),
("lit", "true"),
("waterlogged", "false"),
];
assert_eq!(
state_lines("Targeted Block", [1, -2, 3], "minecraft:furnace", props),
lines(&[
"§nTargeted Block: 1, -2, 3",
"minecraft:furnace",
"facing: north",
"lit: §atrue",
"waterlogged: §cfalse",
])
);
let tags: std::collections::HashMap<String, std::collections::HashSet<String>> = [
("minecraft:logs", vec!["minecraft:oak_log"]),
("minecraft:dirt", vec!["minecraft:dirt"]),
("minecraft:burnable", vec!["minecraft:oak_log"]),
]
.into_iter()
.map(|(t, m)| (t.to_owned(), m.into_iter().map(str::to_owned).collect()))
.collect();
assert_eq!(
tag_lines(&tags, "minecraft:oak_log"),
lines(&["#minecraft:burnable", "#minecraft:logs"])
);
}
#[test]
fn world_lines_match_vanilla() {
use horizon_world::heightmap::HeightmapType::*;
let heights = |k| match k {
WorldSurface => 70,
OceanFloor => 64,
MotionBlocking => 69,
_ => 68,
};
assert_eq!(
heightmap_lines(heights, Some(&heights)),
[
"CH S: 70 M: 69 ML: 68".to_owned(),
"SH S: 70 O: 64 M: 69 ML: 68".to_owned()
]
);
assert_eq!(
heightmap_lines(heights, None)[1],
"SH S: ?? O: ?? M: ?? ML: ??"
);
assert_eq!(
spawn_counts_line(289, &[3, 10, 1, 0, 0, 2, 4, 0]),
"SC: 289, MO: 3, C: 10, AM: 1, AX: 0, UWC: 0, WC: 2, WA: 4, MI: 0"
);
assert_eq!(local_difficulty_line(1.5), "Local Difficulty: 1.50 // 0.00");
assert_eq!(local_difficulty_line(3.0), "Local Difficulty: 3.00 // 0.50");
assert_eq!(light_line(12, 14), "Client Light: 14 (12 sky, 14 block)");
}
#[test]
fn performance_lines_match_vanilla() {
assert_eq!(gpu_line(42.4), "GPU: 42%");
assert_eq!(gpu_line(130.0), "GPU: §c100%");
assert_eq!(
sound_mood_line([3, 1], [247, 8], 0.256),
"Sounds: 3/247 + 1/8 (Mood 26%)"
);
assert_eq!(
sound_cache_line(12, 3 * 1024 * 1024 + 1),
"Sound cache: 12 buffers, 4 MiB"
);
let mib = 1024 * 1024;
let memory = ProcessMemory {
used: 512 * mib,
allocated: 900 * mib,
max: 16384 * mib,
};
assert_eq!(
memory_lines(memory, 40 * mib as i64),
[
"Mem: 3% 512/16384MiB".to_owned(),
"Allocation rate: 040MiB/s".to_owned(),
"Allocated: 5% 900MiB".to_owned(),
]
);
let status = "Name:\thorizon\nVmRSS:\t 2048 kB\nRssAnon:\t 1024 kB\n";
assert_eq!(
ProcessMemory::parse(status, "MemTotal: 8192 kB\n"),
Some(ProcessMemory {
used: 1024 * 1024,
allocated: 2048 * 1024,
max: 8192 * 1024,
})
);
}
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
+164 -9
View File
@@ -10,8 +10,14 @@ use crate::{
items::ItemStack,
world::World,
};
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
use horizon_core::{
geometry::{Aabb, Direction, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
level::portal_travel::{BorderBounds, find_free_position},
};
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
const ITEM_GRAVITY: f64 = 0.04;
@@ -76,6 +82,9 @@ pub struct ItemEntity {
/// `Entity.tickCount`: ticks since the item entered the level, which
/// paces `ItemEntity.mergeWithNeighbours`.
pub ticks: u32,
/// `Entity.needsSync` / `syncPosition`: immediate `ServerEntity.sendChanges`
/// after a bounce or velocity jump (`> 0.1` in `ItemEntity.tick`).
pub needs_sync: bool,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -232,6 +241,7 @@ impl ItemEntity {
self.pickup_delay -= 1;
}
let (water_height, lava_height) = self.update_fluids(world, fluids);
let old_velocity = self.velocity;
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
@@ -329,6 +339,9 @@ impl ItemEntity {
&mut self.velocity,
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
self.age += 1;
// ItemEntity.tick refreshes immersion after moving.
self.update_fluids(world, fluids);
@@ -341,6 +354,7 @@ impl ItemEntity {
pub fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
self.on_ground = false;
self.needs_sync = true;
}
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
@@ -541,17 +555,55 @@ impl ExperienceOrb {
self.health = (self.health as f32 - damage) as i32;
self.health <= 0
}
pub fn bounds(&self) -> Aabb {
/// The box of an orb whose `position` (the bottom of the box) is `at`.
pub fn box_at(at: Vec3) -> Aabb {
let h = 0.25;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
Vec3::new(at.x - h, at.y, at.z - h),
Vec3::new(at.x + h, at.y + 0.5, at.z + h),
)
}
pub fn bounds(&self) -> Aabb {
Self::box_at(self.position)
}
/// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs as
/// `if (!level.noCollision(box)) unstuckIfPossible(box.getSize())`: the free
/// spot nearest the box's centre, searched in a cube of the box's size
/// around it. `None` when the box is already free (the orb then keeps its
/// exact position) or when nothing free is that close. `shape(p)` is the
/// block's collision shape at `p`, in world coordinates.
pub fn unstuck(
at: Vec3,
border: &BorderBounds,
mut shape: impl FnMut(BlockPos) -> Option<VoxelShape>,
) -> Option<Vec3> {
let bounds = Self::box_at(at);
let size = bounds.average_size();
let lo = bounds.min_position();
let cells = |v: f64| (v - 1.0e-7).floor() as i32 - 1..=(v + 1.0e-7).floor() as i32 + 1;
// noCollision(box): a block shape reaching into it leaves the orb stuck.
let mut stuck = false;
'cells: for x in cells(lo.x) {
for y in cells(lo.y) {
for z in cells(lo.z) {
if shape(BlockPos::new(x, y, z))
.is_some_and(|s| s.boxes().iter().any(|b| b.intersects(bounds)))
{
stuck = true;
break 'cells;
}
}
}
}
if !stuck {
return None;
}
// `AABB.ofSize(center, size, size, size)`: where the box's centre may
// go, a cube of the box's mean size around its centre.
let allowed = Aabb::of_size(bounds.center(), Vec3::new(size, size, size));
find_free_position(allowed, bounds.center(), bounds.size(), border, shape)
.map(|p| p.subtract(Vec3::new(0.0, bounds.size().y / 2.0, 0.0)))
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
@@ -1171,6 +1223,7 @@ mod fire_environment_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
@@ -1268,6 +1321,7 @@ mod merge_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: age,
needs_sync: false,
}
}
fn max_stack(stack: &ItemStack) -> u32 {
@@ -1541,6 +1595,7 @@ mod honey_slide_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
needs_sync: false,
}
}
@@ -1668,6 +1723,7 @@ mod slime_bounce_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
needs_sync: false,
}
}
@@ -1677,9 +1733,12 @@ mod slime_bounce_tests {
item.piston_slime_bounce(Direction::Up);
assert_eq!(item.velocity, Vec3::new(0.0, 1.0, 0.0));
assert!(!item.on_ground);
assert!(item.needs_sync);
item.needs_sync = false;
item.piston_slime_bounce(Direction::East);
assert_eq!(item.velocity, Vec3::new(1.0, 1.0, 0.0));
assert!(!item.on_ground);
assert!(item.needs_sync);
}
#[test]
@@ -1690,4 +1749,100 @@ mod slime_bounce_tests {
assert!(bounced > 0.0);
assert_eq!(bounced, 0.8 * 0.98);
}
#[test]
fn a_slime_bounce_marks_the_item_for_immediate_sync() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let slime = assets
.registry
.default_state("minecraft:slime_block")
.unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, slime).unwrap();
}
}
// ItemEntity.tick sets needsSync when getDeltaMovement().subtract(oldMovement).lengthSqr() > 0.01.
let mut falling = grounded_item();
falling.position = Vec3::new(20.5, 4.2, 20.5);
falling.previous = falling.position;
falling.velocity = Vec3::new(0.0, -0.5, 0.0);
falling.on_ground = false;
falling.age = 0;
falling.id = 1;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
assert!(falling.on_ground);
assert!(falling.needs_sync);
// An idle tick where velocity only steps by -0.04 (0.04^2 = 0.0016 <= 0.01)
// does not set needs_sync.
falling.velocity = Vec3::ZERO;
falling.needs_sync = false;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
assert!(!falling.needs_sync);
}
}
#[cfg(test)]
mod unstuck_tests {
use super::*;
const BORDER: BorderBounds = BorderBounds {
min_x: -1.0e6,
min_z: -1.0e6,
max_x: 1.0e6,
max_z: 1.0e6,
};
/// A level of full blocks at `solid`; every other cell is air.
fn shapes(solid: &[(i32, i32, i32)]) -> impl FnMut(BlockPos) -> Option<VoxelShape> + '_ {
move |p| {
if solid.contains(&(p.x, p.y, p.z)) {
VoxelShape::from_box(Aabb::block(p)).ok()
} else {
None
}
}
}
/// `level.noCollision(box)` over the same cells the search reads.
fn free(solid: &[(i32, i32, i32)], b: Aabb) -> bool {
!solid
.iter()
.any(|&(x, y, z)| Aabb::block(BlockPos::new(x, y, z)).intersects(b))
}
#[test]
fn an_orb_spawned_in_a_block_moves_to_a_free_box() {
// One block at y 0..1 and an orb standing in its upper half, as a mob
// killed inside one leaves it: its box overlaps the block by 0.1.
let solid = [(0, 0, 0)];
let at = Vec3::new(0.5, 0.9, 0.5);
let moved = ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)).expect("the orb moves");
// The nearest free box sits on the block's top face.
assert_eq!(moved, Vec3::new(0.5, 1.0, 0.5));
assert!(!ExperienceOrb::box_at(moved).intersects(Aabb::block(BlockPos::new(0, 0, 0))));
}
#[test]
fn an_orb_in_open_air_keeps_its_position() {
let solid = [(0, 0, 0)];
let at = Vec3::new(0.5, 2.0, 0.5);
assert!(free(&solid, ExperienceOrb::box_at(at)));
// Nothing to move for: the spawn sites keep the position as given.
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
}
}
@@ -243,6 +243,7 @@ mod tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
};
let mut still = item.clone();
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
+98 -2
View File
@@ -276,6 +276,16 @@ fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile>
slim: text("model").map(|model| model == "slim"),
})
}
/// `ConversionTracker.addAdditionalSaveData`: vanilla's conversion time, -1
/// while the mob is not converting. `counter2` is that time plus one while it
/// converts and 0 when it does not (see `mobs/undead.rs`).
fn conversion_time(m: &Mob) -> i32 {
if m.extra.counter2 > 0 {
m.extra.counter2 - 1
} else {
-1
}
}
/// `BlockState.CODEC`: the block's name when the state is that block's
/// default, `{id, properties}` for any other state.
pub fn state_to_nbt(registry: &BlockRegistry, state: BlockStateId) -> Tag {
@@ -949,7 +959,28 @@ pub fn mob_to_nbt(
)])
});
}
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
// Zombie and Skeleton.addAdditionalSaveData (a husk keeps Zombie's):
// `ConversionTracker`'s conversion and affliction times, -1 when it is
// not converting or not afflicted.
"minecraft:zombie" | "minecraft:husk" => {
for (key, value) in [
("DrownedConversionTime", conversion_time(m)),
("InWaterTime", m.extra.counter),
] {
map.entry(key.into()).or_insert(Tag::Int(value));
}
}
"minecraft:skeleton" => {
for (key, value) in [
("StrayConversionTime", conversion_time(m)),
("FreezingTime", m.extra.counter),
] {
map.entry(key.into()).or_insert(Tag::Int(value));
}
}
// Drowned.convertsInWater is false, so its tracker never ticks: vanilla
// saves -1 for both.
"minecraft:drowned" => {
for (key, value) in [
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -1832,6 +1863,23 @@ pub fn mob_from_nbt(
if crate::mobs::doors::is_zombie(m.species()) {
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
}
// Zombie and Skeleton.readAdditionalSaveData: `ConversionTracker` reads its
// affliction time (0 when absent) and starts a conversion from a saved time
// other than -1, -1 stopping one (`setConverting(false)`).
let tracker = match m.species() {
crate::mobs::Species::Zombie | crate::mobs::Species::Husk => {
Some(("InWaterTime", "DrownedConversionTime"))
}
crate::mobs::Species::Skeleton => Some(("FreezingTime", "StrayConversionTime")),
_ => None,
};
if let Some((affliction, conversion)) = tracker {
m.extra.counter = number(tag.get(affliction)).unwrap_or(0) as i32;
let time = number(tag.get(conversion)).unwrap_or(-1) as i32;
// Any other time starts a conversion (`startConversion`), a negative
// one spent on the next tick.
m.extra.counter2 = if time == -1 { 0 } else { time.max(0) + 1 };
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1900,6 +1948,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
bob_offset,
ticks: 0,
needs_sync: false,
})
}
@@ -2851,6 +2900,7 @@ mod tests {
pickup_delay: 10,
ticks: 0,
bob_offset: 0.0,
needs_sync: false,
};
let tag = item_entity_to_nbt(&e, [1, 2, 3, 4], None);
assert_eq!(kind(&tag).as_deref(), Some("minecraft:item"));
@@ -3561,7 +3611,7 @@ mod sheep_nbt_tests {
}
#[test]
fn mobs_keep_their_variant_puff_state_and_carried_block() {
fn mobs_keep_their_variant_puff_state_carried_block_and_conversion_times() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -3710,6 +3760,52 @@ mod sheep_nbt_tests {
.lost_horns,
[false; 2]
);
// Zombie and Skeleton.addAdditionalSaveData: the `ConversionTracker`
// tags, -1 for a counter that is not running, and both come back into
// the counters the tick uses.
let mut zombie = new("minecraft:zombie");
zombie.extra.counter = 300;
let (back, tag) = round_trip(&zombie);
assert_eq!(number(tag.get("InWaterTime")), Some(300));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (300, 0));
(zombie.extra.counter, zombie.extra.counter2) = (600, 300);
let (back, tag) = round_trip(&zombie);
assert_eq!(number(tag.get("InWaterTime")), Some(600));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
let mut skeleton = new("minecraft:skeleton");
skeleton.extra.counter = 70;
let (back, tag) = round_trip(&skeleton);
assert_eq!(number(tag.get("FreezingTime")), Some(70));
assert_eq!(number(tag.get("StrayConversionTime")), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (70, 0));
(skeleton.extra.counter, skeleton.extra.counter2) = (140, 301);
let (back, tag) = round_trip(&skeleton);
assert_eq!(number(tag.get("FreezingTime")), Some(140));
assert_eq!(number(tag.get("StrayConversionTime")), Some(300));
assert_eq!((back.extra.counter, back.extra.counter2), (140, 301));
// Not converting: -1 in both tags, and nothing starts on load.
for (kind, affliction, conversion) in [
("minecraft:zombie", "InWaterTime", "DrownedConversionTime"),
("minecraft:skeleton", "FreezingTime", "StrayConversionTime"),
] {
let mut m = new(kind);
(m.extra.counter, m.extra.counter2) = (-1, 0);
let (back, tag) = round_trip(&m);
assert_eq!(number(tag.get(affliction)), Some(-1));
assert_eq!(number(tag.get(conversion)), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (-1, 0));
}
// A saved conversion time starts one: `startConversion`.
let saved = Tag::compound([
("id", string("minecraft:zombie")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("InWaterTime", Tag::Int(600)),
("DrownedConversionTime", Tag::Int(300)),
]);
let back = mob_from_nbt(&saved, &types, 4, None).unwrap();
assert_eq!((back.extra.counter, back.extra.counter2), (600, 301));
}
}
File diff suppressed because it is too large. Load diff
@@ -331,6 +331,7 @@ impl Game<'_> {
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
+9 -10
View File
@@ -3,6 +3,7 @@
//! like a player (walk, look, break, place, use, click buttons). Actions that
//! take time run as a task advanced every frame and reply when they finish or
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{
Result,
@@ -23,10 +24,8 @@ use std::{
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
dpi::PhysicalPosition,
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
event_loop::ActiveEventLoop,
keyboard::KeyCode,
};
@@ -957,7 +956,7 @@ impl Game<'_> {
/// Synthesize the pointer moving to GUI pixel (x, y) and a click there.
pub(super) fn synth_click(
&mut self,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
x: f32,
y: f32,
button: MouseButton,
@@ -968,7 +967,7 @@ impl Game<'_> {
/// the others and a release at the last (one point is a click).
fn synth_drag(
&mut self,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
points: &[(f32, f32)],
button: MouseButton,
) -> Result<()> {
@@ -981,7 +980,7 @@ impl Game<'_> {
let device_id = DeviceId::dummy();
for (i, &(x, y)) in points.iter().enumerate() {
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::CursorMoved {
@@ -990,7 +989,7 @@ impl Game<'_> {
},
);
if i == 0 {
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseInput {
@@ -1001,7 +1000,7 @@ impl Game<'_> {
);
}
}
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseInput {
@@ -1035,7 +1034,7 @@ impl Game<'_> {
words: &[&str],
line: &str,
reply: &Sender<String>,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
) -> Result<Handled> {
let name = words[0];
let a = parse_args(&words[1..]);
@@ -1561,7 +1560,7 @@ impl Game<'_> {
.ok_or("no renderer yet")?;
let device_id = DeviceId::dummy();
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::CursorMoved {
@@ -1570,7 +1569,7 @@ impl Game<'_> {
},
);
for _ in 0..steps.abs() as u32 {
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseWheel {
+1 -1
View File
@@ -122,7 +122,7 @@ impl Game<'_> {
let e = &mut self.dropped[j];
e.stack.count -= take as u32;
if e.stack.count == 0 {
self.dropped.remove(j);
self.remove_dropped(j);
}
let at = [pos.x, pos.y, pos.z];
let pitch =
+25 -6
View File
@@ -2,7 +2,7 @@
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
//! the default pose, the hit wobble and humanoid armor layers.
use super::Game;
use super::{Game, InteractTarget};
use horizon_client::{
entities::ArmorStand,
items::ItemStack,
@@ -485,12 +485,30 @@ impl Game<'_> {
_ => 0,
}
}
/// `ArmorStand.interactAt`.
pub(super) fn interact_armor_stand(&mut self) -> bool {
let Some((i, d, point)) = self.armor_stand_hit() else {
/// `ArmorStand.interactAt` for the stand under the crosshair, or for the
/// stand a guest's interact packet named (its client's click point).
pub(super) fn interact_armor_stand(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d, point)) = (match target {
// interactAt gets the packet's location, measured from the stand's
// position, so getClickedSlot reads the height the client clicked.
Some(t) => Self::named_entity(&self.armor_stands, t.id, |a| a.id).map(|i| {
let p = self.armor_stands[i].position;
(
i,
0.0,
[
p.x + t.location[0],
p.y + t.location[1],
p.z + t.location[2],
],
)
}),
None => self.armor_stand_hit(),
}) else {
return false;
};
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
let held = self.held().cloned();
@@ -1130,7 +1148,8 @@ impl Game<'_> {
&& self.remote.is_none()
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0; 3], 40);
// Player.drop(stack, false): thrown from the eyes, no thrower.
self.throw_from_eyes(rest, eye, false);
}
// The attribute modifiers of a worn piece come from the server's
// ClientboundUpdateAttributesPacket on a client.
@@ -315,13 +315,18 @@ impl Game<'_> {
.tick(pos, &players, &mut anims.book_random);
}
Ticker::Shulker { facing } => {
// The lid's push of entities in its way (moveCollidedEntities)
// and the neighbour updates are left to the level.
self.be_anims
.shulkers
.entry(pos)
.or_default()
.tick(pos, facing);
// A window that simulates the world ticks its boxes on the
// server pass (`tick_block_entities`, which also applies
// `moveCollidedEntities`); a server's client only animates
// what it is sent. Either way the collision queries read
// the lids from `World::shulkers` (`sync_shulkers`).
if self.remote.is_some() {
self.be_anims
.shulkers
.entry(pos)
.or_default()
.tick(pos, facing);
}
}
Ticker::Skull { powered } => {
self.be_anims.skulls.entry(pos).or_default().tick(powered);
@@ -8,6 +8,7 @@ use super::Game;
use horizon_client::{
Result,
block_entities::{Pos, Ticking},
block_entity_anim::Ticker,
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
furnace::FurnaceCooking,
};
@@ -25,6 +26,8 @@ impl Game<'_> {
self.world.block_entities.set_states(states);
let containers = self.native_containers();
self.world.block_entities.set_mod_containers(containers);
let fluids = self.native_fluid_tables();
self.world.block_entities.set_mod_fluids(fluids);
}
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
@@ -90,8 +93,60 @@ impl Game<'_> {
self.client_tick_trial_spawner(key);
}
}
// `ShulkerBoxBlockEntity.tick`: the lids of the boxes this level
// animates. Their grown collision shape reaches the collision queries
// through `World::shulkers`, and an opening lid pushes what it runs
// into (`moveCollidedEntities`), as a moving piston does.
let pushes = self.tick_shulkers();
self.sync_shulkers();
self.push_shulker_entities(pushes);
Ok(())
}
/// `ShulkerBoxBlockEntity.tick` for the boxes in block-ticking chunks, in
/// position order: the lid's new progress, and the swept box of an opening
/// lid for `moveCollidedEntities`. A box whose block is gone is dropped.
/// The boxes are those the level animates: a box opens (`start_open` and
/// block event 1) before it has one, exactly as vanilla's block entity is
/// there from the start.
fn tick_shulkers(&mut self) -> Vec<super::piston_push::ShulkerPush> {
let ticking = self.block_ticking_area();
let mut pushes = Vec::new();
let mut gone = Vec::new();
let positions: Vec<[i32; 3]> = self.be_anims.shulkers.keys().copied().collect();
for pos in positions {
let state = self.world.block(pos[0], pos[1], pos[2]);
let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state.0) else {
gone.push(pos);
continue;
};
if !ticking(pos[0], pos[2])
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
{
continue;
}
let tick = self
.be_anims
.shulkers
.get_mut(&pos)
.map(|shulker| shulker.tick(pos, facing));
if let Some(push) = tick.and_then(|tick| tick.push) {
pushes.push((pos, facing, push));
}
}
for pos in gone {
self.be_anims.shulkers.remove(&pos);
}
pushes
}
/// Copies the shulker box lids into the world, where the collision queries
/// read them (`SceneCollisions::shulker_collision_state`), as
/// `sync_moving_pistons` does for pistons.
pub(super) fn sync_shulkers(&mut self) {
self.world.shulkers.clear();
self.world
.shulkers
.extend(self.be_anims.shulkers.iter().map(|(p, s)| (*p, s.clone())));
}
/// `BlockState.getTicker` of every state, for the block entities ticked
/// here. A creaking heart keeps its slot while uprooted (see
/// `tick_creaking_heart`).
+74 -6
View File
@@ -2,7 +2,7 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
use super::{Game, Keys, entity_culling::at, view_vector};
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::{EntityCollisions, SceneCollisions},
@@ -261,9 +261,72 @@ impl Game<'_> {
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
self.fluid_height_of(x, y, z, Some(Fluid::Water))
}
/// The horizontal water plane around the camera for the water mirror: the median
/// standing-water surface of a grid of columns 48 blocks around, each scanned down from
/// the camera to its ground. A lake or the sea has one plane; the mirror shows only the
/// water on it. With no water in reach the last plane found stays, so the mirror never
/// switches off as the camera moves.
pub(super) fn water_plane(&self, camera: glam::DVec3) -> Option<f32> {
const RADIUS: i32 = 48;
const STEP: usize = 4;
const DEPTH: i32 = 96;
let (cx, cz) = (camera.x.floor() as i32, camera.z.floor() as i32);
let top = camera.y.floor() as i32 + 2;
// Scanned again once the camera moves 8 blocks, or every 5 seconds: a scan reads up
// to 60 000 blocks on the game thread, and the plane rarely changes.
let scan = (
[cx >> 3, top >> 3, cz >> 3],
self.world.game_time.div_euclid(100),
);
if self.water_plane_scan.replace(Some(scan)) == Some(scan) {
return self.water_plane_memo.get();
}
let mut heights = Vec::with_capacity(625);
for x in (cx - RADIUS..=cx + RADIUS).step_by(STEP) {
for z in (cz - RADIUS..=cz + RADIUS).step_by(STEP) {
// The top of the column's water: the highest standing water under the
// camera (a full block's water continues above it).
for y in (top - DEPTH..=top).rev() {
if let Some((h, source)) = self.fluid_height(x, y, z) {
if source && h < 1.0 {
heights.push(y as f32 + h);
}
break;
}
if self.res.facts().get(self.world.block(x, y, z)).solid_render {
break;
}
}
}
}
if heights.is_empty() {
return self.water_plane_memo.get();
}
heights.sort_by(f32::total_cmp);
let found = heights[heights.len() / 2];
// The plane in use stays while a quarter of the columns are still on it, so two
// lakes at different heights do not make the mirror flicker between them.
let plane = match self.water_plane_memo.get() {
Some(last)
if 4 * heights.iter().filter(|h| (*h - last).abs() < 0.05).count()
>= heights.len() =>
{
last
}
_ => found,
};
self.water_plane_memo.set(Some(plane));
Some(plane)
}
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
pub(super) fn fluid_height_of(
&self,
x: i32,
y: i32,
z: i32,
fluid: Option<Fluid>,
) -> Option<(f32, bool)> {
let f = self.res.facts().get(self.world.block(x, y, z));
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
return None;
@@ -875,13 +938,18 @@ impl Game<'_> {
}
best
}
/// `AbstractBoat.interact`: board the boat under the crosshair.
pub(super) fn interact_boat(&mut self) -> bool {
let Some((i, d)) = self.boat_hit() else {
/// `AbstractBoat.interact`: board the boat under the crosshair, or the one
/// a guest's interact packet named.
pub(super) fn interact_boat(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d)) = (match target {
Some(t) => Self::named_entity(&self.boats, t.id, |b| b.id).map(|i| (i, 0.0)),
None => self.boat_hit(),
}) else {
return false;
};
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
// AbstractBoat.interact starts with Entity.interact: the lead.
+14 -10
View File
@@ -4,7 +4,7 @@ use super::{Game, Screen};
use horizon_client::{
chat::{
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
SYSTEM_INDICATOR,
SYSTEM_INDICATOR, normalize_chat_message,
},
command::Registries,
gamerules::GameRules,
@@ -857,14 +857,17 @@ impl Game<'_> {
screen.type_text(&m, &c, text);
}
}
/// ChatScreen.handleChatInput: history, then a command or a chat line.
/// The Chat option's restrictions stop the message here too, so a
/// control-socket `chat` cannot send what the player's setting hides.
/// ChatScreen.handleChatInput: the message comes in normalized
/// (`normalizeChatMessage`: trimmed, whitespace runs collapsed, cut to the
/// 256 UTF-16 units the packet holds), into history, then as a command or
/// a chat line. The Chat option's restrictions stop the message here too,
/// so a control-socket `chat` cannot send what the player's setting hides.
pub(super) fn handle_chat_input(&mut self, message: &str) {
if !self.chat_abilities().allows(message) {
let message = normalize_chat_message(message);
if message.is_empty() || !self.chat_abilities().allows(&message) {
return;
}
self.ui.chat.add_recent(message);
self.ui.chat.add_recent(&message);
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
@@ -873,7 +876,7 @@ impl Game<'_> {
{
return;
}
self.send_chat(message);
self.send_chat(&message);
return;
}
if let Some(command) = message.strip_prefix('/') {
@@ -884,7 +887,7 @@ impl Game<'_> {
} else {
let line = Text::translate(
"chat.type.text",
&[self.player_display_name(), Text::literal(message)],
&[self.player_display_name(), Text::literal(message.as_str())],
);
let focused = self.screen == Screen::Chat;
self.ui.chat.add(
@@ -895,7 +898,7 @@ impl Game<'_> {
self.ticks,
focused,
);
self.lan_host_chat(message);
self.lan_host_chat(&message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -1066,7 +1069,7 @@ impl Game<'_> {
return;
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
self.send_unattended_command(command);
} else {
self.run_command(command);
}
@@ -1260,6 +1263,7 @@ impl Game<'_> {
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
+127 -12
View File
@@ -20,6 +20,9 @@ struct Entry {
fall_distance: f32,
/// The causing entity: its id (for comparisons) and display name.
attacker: Option<(u32, Text)>,
/// The causing player's main-hand stack when it has a custom name, as
/// held when the hit landed (a mob's is read when the message is built).
item: Option<Text>,
/// The direct entity's display name when no entity caused the damage
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
@@ -74,7 +77,7 @@ pub(super) struct CombatTracker {
in_combat: bool,
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text))>,
pending: Vec<(String, (u32, Text), Option<Text>)>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
@@ -92,6 +95,9 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The hit on the player now runs its post-attack effects (thorns) only
/// if the player lives through it (`WitherSkull.onHitEntity`).
pub(super) post_attack_if_alive: bool,
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
@@ -187,12 +193,35 @@ impl Game<'_> {
self.note_named_attacker(damage_type, mob, name);
}
}
/// How many attackers wait for the damage they caused, for
/// [`Game::drop_pending_since`].
pub(super) fn pending_marks(&self) -> (usize, usize) {
(self.combat.pending.len(), self.combat.pending_direct.len())
}
/// Forgets the attackers noted since `marks`: their hit didn't land (a
/// native mod canceled it), so no combat entry takes them, and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
self.combat.pending.truncate(marks.0);
self.combat.pending_direct.truncate(marks.1);
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
self.note_player_attacker(damage_type, id, name, None);
}
/// `note_named_attacker` for a player, with their custom-named main-hand
/// item for the death message's `.item` form.
pub(super) fn note_player_attacker(
&mut self,
damage_type: &str,
id: u32,
name: Text,
item: Option<Text>,
) {
self.combat
.pending
.push((damage_type.to_owned(), (id, name)));
.push((damage_type.to_owned(), (id, name), item));
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
@@ -234,12 +263,16 @@ impl Game<'_> {
}
_ => continue,
};
let attacker = self
let (attacker, item) = self
.combat
.pending
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending.remove(i).1);
.position(|(t, _, _)| *t == name)
.map(|i| {
let (_, attacker, item) = self.combat.pending.remove(i);
(Some(attacker), item)
})
.unwrap_or_default();
let direct = match attacker {
Some(_) => None,
None => self
@@ -266,6 +299,7 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
item,
direct,
});
self.combat.last_damage = now;
@@ -408,12 +442,38 @@ impl Game<'_> {
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
stack.custom_name()?;
if !has_custom_name(&stack) {
return None;
}
if let Some(g) = horizon_client::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
}
Some(self.stack_display_name(&stack))
}
/// `getMainHandItem().getDisplayName()` of the player in place, when the
/// stack has a custom name.
pub(super) fn main_hand_custom_name(&self) -> Option<Text> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
has_custom_name(stack).then(|| self.stack_display_name(stack))
}
/// `main_hand_custom_name` of a player who is not in place: the guest
/// `conn` between turns, or none for the host while a guest acts (their
/// hotbar slot is kept by the swap).
fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
if player == self.lan_acting {
return self.main_hand_custom_name();
}
let g = self.lan.as_ref()?.hub.guest(player?)?;
let stack = g.inventory.get(g.selected)?.as_ref()?;
has_custom_name(stack).then(|| self.stack_display_name(stack))
}
/// The killer's item for `getLocalizedDeathMessage` and
/// `getMessageForAssistedFall`: a player's as recorded, a mob's now.
fn entry_item(&self, e: &Entry) -> Option<Text> {
e.item
.clone()
.or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0))
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
@@ -450,13 +510,26 @@ impl Game<'_> {
let at = knock.fall_location.unwrap_or("generic");
return Text::translate(&format!("death.fell.accident.{at}"), &[me]);
}
return match (&knock.attacker, &killer) {
// CombatTracker.getMessageForAssistedFall: the attacker's item
// with a custom name gets the `.item` message.
let (key, args) = match (&knock.attacker, &killer) {
(Some(a), k) if k.as_ref().is_none_or(|k| k.0 != a.0) => {
Text::translate("death.fell.assist", &[me, a.1.clone()])
horizon_client::scoreboard::death_message_args(
"death.fell.assist",
&me,
&a.1,
self.entry_item(knock).as_ref(),
)
}
(_, Some(k)) => Text::translate("death.fell.finish", &[me, k.1.clone()]),
_ => Text::translate("death.fell.killer", &[me]),
(_, Some(k)) => horizon_client::scoreboard::death_message_args(
"death.fell.finish",
&me,
&k.1,
self.entry_item(killing).as_ref(),
),
_ => ("death.fell.killer".into(), vec![me]),
};
return Text::translate(&key, &args);
}
let key = format!("death.attack.{}", def.0);
if def.1 == "intentional_game_design" {
@@ -467,14 +540,14 @@ impl Game<'_> {
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((id, name)) = killer {
if let Some((_, name)) = killer {
// DamageSource.getLocalizedDeathMessage: the cause's item with a
// custom name gets the `<id>.item` message.
let (key, args) = horizon_client::scoreboard::death_message_args(
&key,
&me,
&name,
self.mob_main_hand_name(id).as_ref(),
self.entry_item(killing).as_ref(),
);
return Text::translate(&key, &args);
}
@@ -549,6 +622,12 @@ impl Game<'_> {
}
None => None,
};
// DamageSource.getLocalizedDeathMessage reads that player's main hand
// as the message is built.
let player_item = match killer {
Some(conn) => self.player_main_hand_custom_name(conn),
None => player.as_ref().and_then(|_| self.main_hand_custom_name()),
};
// The cause's display name: a mob that is gone (a creeper that blew
// up) is still named by its blast.
let name = |g: &Self, who: Who| match who {
@@ -570,6 +649,9 @@ impl Game<'_> {
.iter()
.map(|e| {
let attacker = e.source.attacker.and_then(|w| name(self, w));
let item = (e.source.attacker == Some(Who::Player))
.then(|| player_item.clone())
.flatten();
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
// cause; a rocket nobody fired still names itself.
let direct = match e.source.kind {
@@ -586,6 +668,7 @@ impl Game<'_> {
fall_location: e.in_water.then_some("water"),
fall_distance: e.fall_distance,
attacker,
item,
direct,
}
})
@@ -614,6 +697,14 @@ impl Game<'_> {
}
}
/// `!stack.isEmpty() && stack.has(DataComponents.CUSTOM_NAME)`: the held
/// item that turns a death message into its `.item` form.
fn has_custom_name(stack: &horizon_client::items::ItemStack) -> bool {
&*stack.item != "minecraft:air"
&& stack.count > 0
&& stack.data().is_some_and(|d| d.custom_name.is_some())
}
/// `Entity.getDisplayName` of a mob: its custom name or its type's name.
fn mob_name(m: &Mob) -> Text {
match (&m.custom_name_text, &m.custom_name) {
@@ -623,6 +714,30 @@ fn mob_name(m: &Mob) -> Text {
}
}
#[cfg(test)]
mod held_item_tests {
use super::has_custom_name;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// `DamageSource.getLocalizedDeathMessage`: only a non-empty stack with
/// a `custom_name` component names the item.
#[test]
fn only_a_renamed_stack_names_the_item() {
let mut sword = ItemStack::new("minecraft:diamond_sword", 1);
assert!(!has_custom_name(&sword));
// Other components (here a repair cost) are not a custom name.
sword.data_mut().repair_cost = 1;
assert!(!has_custom_name(&sword));
sword.data_mut().custom_name = Some(Tag::String("Peacemaker".into()));
assert!(has_custom_name(&sword));
// An empty stack never does, even with the component.
let mut gone = sword.clone();
gone.count = 0;
assert!(!has_custom_name(&gone));
}
}
#[cfg(test)]
mod stuck_tests {
use super::Stuck;
+36 -10
View File
@@ -1926,7 +1926,11 @@ impl Game<'_> {
}
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Item(id) => {
if let Some(i) = self.dropped.iter().position(|i| i.id == id) {
self.remove_dropped(i);
}
}
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
@@ -1957,7 +1961,9 @@ impl Game<'_> {
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
let before = self.orb_ids();
self.orbs.retain(|o| o.age < u32::MAX / 2);
self.native_orbs_left_since(&before);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -2050,11 +2056,14 @@ impl Game<'_> {
return Err(err("commands.summon.failed", &[]));
}
"experience_orb" => {
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck
// like any other orb spawn.
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
previous: Vec3::new(pos[0], pos[1], pos[2]),
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 0,
age: 0,
@@ -3343,13 +3352,30 @@ impl Game<'_> {
.options
.resources
.join(format!("data/{ns}/tags/{registry}/{path}.json"));
let Ok(v) = std::fs::read(file)
.map_err(|_| ())
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).map_err(|_| ()))
else {
continue;
};
for e in v["values"].as_array().into_iter().flatten() {
// The game's file and the world's packs' both count, entries
// joined in resource-stack order; a pack file with `replace`
// drops what the lower sources put in (`TagLoader.load`).
let packed = self
.fns
.store
.files
.get(&format!("tags/{ns}:{registry}/{path}"))
.into_iter()
.flatten()
.filter_map(|b| serde_json::from_slice::<serde_json::Value>(b).ok());
let parsed = std::fs::read(file)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.into_iter()
.chain(packed);
let mut entries = Vec::new();
for v in parsed {
if v["replace"].as_bool() == Some(true) {
entries.clear();
}
entries.extend(v["values"].as_array().into_iter().flatten().cloned());
}
for e in &entries {
let id = e.as_str().or_else(|| e["id"].as_str()).unwrap_or_default();
match id.strip_prefix('#') {
Some(nested) => pending.push(nested.to_owned()),
@@ -73,8 +73,10 @@ impl Game<'_> {
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
let name = namespaced(effect.id);
let new = !m.has_effect(name);
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
m.add_effect_with(name, duration, amplifier, false, particles);
self.mod_effect_added(name, Some(id as i32), amplifier, new);
true
}
None => false,
@@ -1102,9 +1102,11 @@ impl Game<'_> {
/// The biome name at a block position.
fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?;
// The world's table (`world_biomes!`): a packed biome's id is past
// the built-in list, and `Biomes::get` would clamp it to the last
// vanilla one.
Some(
self.res
.biomes()
world_biomes!(self)
.get(chunk.biome(p[0], p[1], p[2]))
.name
.clone(),
@@ -1113,30 +1115,16 @@ impl Game<'_> {
/// A biome id or `#tag` matched against a biome name.
pub(super) fn biome_matches(&self, wanted: &str, biome: &str) -> bool {
match wanted.strip_prefix('#') {
Some(tag) => self.biome_tag_has(tag, biome, 0),
Some(tag) => self.biome_tag_has(tag, biome),
None => wanted == biome,
}
}
fn biome_tag_has(&self, tag: &str, biome: &str, depth: u32) -> bool {
if depth > 8 {
return false;
}
let (ns, path) = tag.split_once(':').unwrap_or(("minecraft", tag));
let file = self
.options
.resources
.join(format!("data/{ns}/tags/worldgen/biome/{path}.json"));
let Ok(bytes) = std::fs::read(file) else {
return self.res.mob_data().biome_has(tag, biome);
};
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
v["values"].as_array().is_some_and(|a| {
a.iter()
.filter_map(|e| e.as_str())
.any(|e| match e.strip_prefix('#') {
Some(inner) => self.biome_tag_has(inner, biome, depth + 1),
None => e == biome,
})
})
/// `#tag`: the biome tags the game's files and the world's packs define,
/// nested tags expanded; an unknown tag falls back to the game's built-in
/// table.
fn biome_tag_has(&self, tag: &str, biome: &str) -> bool {
let entries = self.tag_entries("worldgen/biome", tag);
entries.iter().any(|id| id == biome)
|| (entries.is_empty() && self.res.mob_data().biome_has(tag, biome))
}
}
@@ -176,7 +176,11 @@ impl Game<'_> {
if volume > i64::from(limit) {
return Err(err("commands.fillbiome.toobig", &[&limit, &volume]));
}
let Some(target) = self.res.biomes().list.iter().position(|b| b.name == biome) else {
let Some(target) = world_biomes!(self)
.list
.iter()
.position(|b| b.name == biome)
else {
return Err(err(
"argument.resource.not_found",
&[&biome, &"minecraft:worldgen/biome"],
@@ -203,7 +207,7 @@ impl Game<'_> {
continue;
};
let current = chunk.biome(x, y, z);
let current_name = &self.res.biomes().list[usize::from(current)].name;
let current_name = &world_biomes!(self).list[usize::from(current)].name;
if let Some(f) = &filter
&& !self.biome_matches(f, current_name)
{
@@ -512,9 +516,7 @@ impl Game<'_> {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
self.res
.items()
.loot_table(&table, false, held.as_ref(), random)
world_items!(self).loot_table(&table, false, held.as_ref(), random)
}
None => world_items!(self).loot_table(
&table,
@@ -547,9 +549,7 @@ impl Game<'_> {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
self.res
.items()
.entity_drops(&ty, by_player, held.as_ref(), random)
world_items!(self).entity_drops(&ty, by_player, held.as_ref(), random)
}
None => world_items!(self).entity_drops(
&ty,
@@ -109,7 +109,8 @@ impl Game<'_> {
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
"arrow" => DamageKind::Arrow,
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
"fireball" | "unattributed_fireball" | "wither_skull" => DamageKind::Fireball,
"fireball" | "unattributed_fireball" => DamageKind::Fireball,
"wither_skull" => DamageKind::WitherSkull,
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
@@ -1169,7 +1170,7 @@ impl Game<'_> {
};
let vehicle_name = self.target_name(v);
match target {
Target::Player => self.dismount_mount(),
Target::Player => self.stop_riding(),
Target::Mob(rider) => self.dismount_mob(rider),
_ => {}
}
@@ -1202,24 +1203,67 @@ impl Game<'_> {
}
up = self.ride_vehicle(v);
}
// Players and mobs can ride mobs.
let Target::Mob(mob_id) = vehicle else {
return Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
};
let rider = match target {
Target::Mob(id) => Some(id),
_ => None,
};
if !(target == Target::Player || rider.is_some())
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
{
return Err(err(
let generic = || {
err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
)
};
// Only players and mobs ride.
if !(target == Target::Player || rider.is_some()) {
return Err(generic());
}
// `RideCommand.mount` forces the ride (`startRiding(vehicle, true, true)`),
// which skips `canRide` and `canAddPassenger`: a boat under water still
// takes the rider. Horizon's boats seat at most `max_passengers`, so a
// full boat still refuses. Minecarts carry only players here: Horizon's
// carts have no mob passengers yet.
match vehicle {
Target::Boat(id) => {
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return Err(generic());
};
if self.boat_passengers(id) >= self.boats[i].max_passengers() {
return Err(generic());
}
if let Some(rider) = rider {
self.mount_mob(rider, id);
} else {
self.riding = Some(id);
self.yaw = self.boats[i].yaw;
// sendEventAndTriggers: START_RIDING_TRIGGER.
self.trigger_player("minecraft:started_riding");
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
}
Target::Cart(id) => {
if target != Target::Player || self.ridden_by_guest(id) {
return Err(generic());
}
self.riding = Some(id);
self.trigger_player("minecraft:started_riding");
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
}
_ => {}
}
// Mobs carry players and mobs.
let Target::Mob(mob_id) = vehicle else {
return Err(generic());
};
if !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
return Err(generic());
}
if let Some(rider) = rider {
// Entity.startRiding.
@@ -1248,12 +1292,14 @@ impl Game<'_> {
/// Entity.getVehicle: the player's mount, or a mob's.
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
let id = match t {
Target::Player => return self.riding.map(Target::Mob),
Target::Player => self.riding?,
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
_ => return None,
};
Some(if self.boats.iter().any(|b| b.id == id) {
Target::Boat(id)
} else if self.carts.iter().any(|c| c.id == id) {
Target::Cart(id)
} else {
Target::Mob(id)
})
+78 -10
View File
@@ -1,4 +1,5 @@
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{Result, items::ItemStack};
use horizon_core::geometry::Vec3;
@@ -7,7 +8,7 @@ use std::{
sync::mpsc::Sender,
time::{Duration, Instant},
};
use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
use winit::{event::MouseButton, keyboard::KeyCode};
const HELP: &[&str] = &[
"status game, player, streaming and renderer state",
@@ -107,7 +108,7 @@ impl Game<'_> {
let _ = flight.reply.send(summary.to_string());
}
}
pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) {
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let _ = reply.send(self.frame_stats.summary().to_string());
@@ -137,7 +138,7 @@ impl Game<'_> {
}
}
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &dyn EventLoopCtl) {
let reply = match self.control_command(&request.line, &request.reply, event_loop) {
Ok(Some(value)) => value,
// Deferred: the command keeps its own reply channel.
@@ -151,7 +152,24 @@ impl Game<'_> {
&mut self,
line: &str,
reply: &Sender<String>,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
) -> Result<Option<Value>> {
// A tap's or an up's release must run even when a dialog, the sign
// editor or a screen consumes the key, there is no world, or a
// command handler returns an error: a lost release keeps Shift down.
let released = released_modifier(line);
let result = self.control_command_inner(line, reply, event_loop);
if let Some(key) = released {
self.press_modifier_key(key, false);
}
result
}
fn control_command_inner(
&mut self,
line: &str,
reply: &Sender<String>,
event_loop: &dyn EventLoopCtl,
) -> Result<Option<Value>> {
let words: Vec<&str> = line.split_whitespace().collect();
if self.pending_save.is_some() && words.first() != Some(&"quit") {
@@ -285,6 +303,13 @@ impl Game<'_> {
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
let action = arg(2)?;
if !matches!(action, "down" | "up" | "tap" | "repeat") {
return Err(format!(
"key action must be down, up, tap or repeat, not {action}"
)
.into());
}
// A server's dialog takes the keys, in configuration too.
if self.dialog_open() {
if arg(2)? != "up" {
@@ -324,6 +349,7 @@ impl Game<'_> {
{
use horizon_client::debug_keys::KeyAction;
let action = arg(2)?;
self.press_modifier_key(key, action != "up");
if action != "up" {
self.physical_keys.insert(key);
if self.debug_key(key, KeyAction::Press) {
@@ -396,18 +422,30 @@ impl Game<'_> {
self.physical_keys.remove(&physical);
self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release);
}
other => {
return Err(format!(
"key action must be down, up, tap or repeat, not {other}"
)
.into());
}
_ => unreachable!("checked above"),
}
ok()
}
"release" => {
self.keys.clear();
self.physical_keys.clear();
self.modifiers = winit::keyboard::ModifiersState::empty();
// Sneak and sprint pressed through `key` live in the toggle
// mappings: release them as their key's release would (a
// toggled one stays on), or the next sync puts them back. A
// controller still holding one keeps it.
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
if !self.pad.holds_action(&self.settings.controller, name) {
self.toggles.key(
slot,
horizon_client::keymapping::KeyAction::Release,
false,
options[slot],
);
}
self.sync_toggle(slot);
}
self.left = false;
self.right = false;
ok()
@@ -1157,6 +1195,15 @@ fn vendor_name(id: u32) -> String {
}
}
/// The key a `key <name> tap|up` command releases, whatever its handler does.
fn released_modifier(line: &str) -> Option<KeyCode> {
let mut words = line.split_whitespace();
match (words.next(), words.next(), words.next()) {
(Some("key"), Some(key), Some("tap" | "up")) => key_code(key),
_ => None,
}
}
fn key_code(name: &str) -> Option<KeyCode> {
let name = name.to_ascii_lowercase();
let letters = [
@@ -1233,3 +1280,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn taps_and_ups_always_release_their_key() {
for line in ["key shift tap", "key shift up", "key Shift up"] {
assert_eq!(released_modifier(line), Some(KeyCode::ShiftLeft), "{line}");
}
for line in [
"key shift down",
"key shift repeat",
"key shift",
"keys shift up",
"key nope up",
] {
assert_eq!(released_modifier(line), None, "{line}");
}
}
}
+192 -8
View File
@@ -2,7 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, dispenser, view_vector};
use super::{Dim, Game, InteractTarget, dispenser, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -227,6 +227,20 @@ fn flame_geometry(
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
m.seen(player)
}
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
/// its death animation, the one the player rides (it shares the player's root
/// vehicle) and the camera entity the pick starts from, so a packet that names
/// one of those does nothing rather than reaching the feed, shear, leash or
/// name paths the pick path refuses.
pub(super) fn named_mob_pickable(
alive: bool,
id: u32,
riding: Option<u32>,
camera: Option<u32>,
) -> bool {
alive && Some(id) != riding && Some(id) != camera
}
impl Game<'_> {
pub(super) fn player_view(&self) -> Option<PlayerView> {
let p = self.player.as_ref()?;
@@ -303,7 +317,7 @@ impl Game<'_> {
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
return BiomeFacts::default();
};
let b = self.res.biomes().get(chunk.biome(x, y, z));
let b = world_biomes!(self).get(chunk.biome(x, y, z));
let tags = self.res.mob_data();
let has = |t: &str| tags.biome_has(t, &b.name);
let climate = if has("minecraft:spawns_warm_variant_farm_animals") {
@@ -459,6 +473,107 @@ impl Game<'_> {
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
/// applied, and at its maximum the slot empties and the equipment-break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
/// piece alone (`ItemStack.canBeHurtBy`).
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
use horizon_client::mobs::drops;
use horizon_world::player_inventory::EquipmentSlot;
if damage <= 0.0 {
return;
}
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let body_armour = drops::head_is_body(m.species());
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
.motion
.health
.damage
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if !info.equip_damage_on_hurt
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
{
return None;
}
Some(info.max_damage)
}
let mut broke: Vec<i8> = Vec::new();
// The four armour slots, then the body slot (`extra.head`).
let event_of = horizon_client::mobs::item_break::equipment_break_event;
for (index, slot) in [
(0usize, EquipmentSlot::Head),
(1, EquipmentSlot::Chest),
(2, EquipmentSlot::Legs),
(3, EquipmentSlot::Feet),
] {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let Some(stack) = m.extra.armor[index].clone() else {
continue;
};
let Some(max) = wearable(self, &stack, fire, name) else {
continue;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
continue;
}
let lost = stack.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.armor[index] = None;
broke.push(event_of(slot));
} else if let Some(worn) = m.extra.armor[index].as_mut() {
worn.damage = lost;
}
}
if body_armour {
let body = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
if let Some(body) = body
&& let Some(max) = wearable(self, &body, fire, name)
{
let loss = self.durability_loss(&body, amount);
if loss > 0 {
let lost = body.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.head = None;
m.extra.body_damage = 0;
broke.push(event_of(EquipmentSlot::Body));
} else {
m.extra.body_damage = lost as i32;
}
}
}
}
for event in broke {
// LivingEntity.onEquippedItemBroken through the level's broadcast.
self.lan_entity_event(id as i32, event, at);
}
}
/// What a converted frog gets once its tadpole has grown up: the spawn
/// finalization of a conversion, then persistence.
fn finalize_converted(&mut self, id: u32) {
@@ -650,7 +765,7 @@ impl Game<'_> {
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.items().block_tags,
biomes: self.res.biomes(),
biomes: world_biomes!(self),
mob_griefing: self.ui.rules.bool("mob_griefing"),
universal_anger: self.ui.rules.bool("universal_anger"),
mob_drops: self.ui.rules.bool("mob_drops"),
@@ -661,6 +776,11 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
mod_effects: if self.remote.is_none() {
Some(self)
} else {
None
},
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
self.level_players()
@@ -1141,6 +1261,7 @@ impl Game<'_> {
let projectile = match d.kind {
DamageKind::Arrow => Some("minecraft:arrow"),
DamageKind::Fireball => Some("minecraft:fireball"),
DamageKind::WitherSkull => Some("minecraft:wither_skull"),
DamageKind::Fireworks => Some("minecraft:firework_rocket"),
_ => None,
};
@@ -1243,6 +1364,10 @@ impl Game<'_> {
kind: "minecraft:fireball".into(),
..LootEntity::default()
}),
DamageKind::WitherSkull => Some(LootEntity {
kind: "minecraft:wither_skull".into(),
..LootEntity::default()
}),
DamageKind::Fireworks => Some(LootEntity {
kind: "minecraft:firework_rocket".into(),
..LootEntity::default()
@@ -1491,6 +1616,7 @@ impl Game<'_> {
self.drown_particles(at, v);
}
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind),
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
@@ -2829,6 +2955,7 @@ impl Game<'_> {
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
let rocket = std::mem::take(&mut self.combat.rocket_hit);
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -2865,6 +2992,7 @@ impl Game<'_> {
}),
};
// The causing entity for the death message.
let marks = self.pending_marks();
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
@@ -2879,6 +3007,11 @@ impl Game<'_> {
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
// The native damage hook's causing entity: the one given, or the
// owner of the projectile that hit (`projectile_owner` outlives a
// projectile's other hits), not the guessed melee attacker.
let owner = owner.filter(|_| projectile);
let causing = known.flatten().or(owner).map_or(0, |id| id as i32);
let mut outcome = horizon_client::health::BlockOutcome::default();
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
@@ -2889,6 +3022,7 @@ impl Game<'_> {
amount,
blocking,
living_cause,
causing,
)
&& {
outcome = o;
@@ -2914,12 +3048,21 @@ impl Game<'_> {
}
}
// The thorns hurt the entity that caused the hit, if there is one.
if let Some(a) = attacker.or(owner) {
let killed = self.player.as_ref().is_some_and(|p| {
super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used)
});
if let Some(a) = attacker.or(owner)
&& !(post_attack_if_alive && killed)
{
self.thorns(a);
}
}
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
if outcome.canceled {
self.drop_pending_since(marks);
}
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
@@ -3674,10 +3817,13 @@ impl Game<'_> {
) {
return self.wind_charge_hit(p, hit);
}
// A wither skull's hit on a part or crystal goes through
// `wither_skull_hit` below, as on any entity, and then explodes.
if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& id >= super::dragon::SPECIAL
&& !matches!(p.kind, ProjectileKind::WitherSkull { .. })
{
let by_player = p.owner == Some(Who::Player);
if p.arrow.is_some() {
@@ -3792,6 +3938,7 @@ impl Game<'_> {
} else {
EntityHit::Refused
};
self.burning_arrow_primes(p, who);
self.finish_arrow_hit(trident, outcome);
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
@@ -3866,6 +4013,7 @@ impl Game<'_> {
hurt
}
};
self.burning_arrow_primes(p, who);
// doKnockback: Punch pushes along the flight, less the target's
// knockback resistance.
let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt();
@@ -4371,12 +4519,28 @@ impl Game<'_> {
};
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
pub(super) fn interact_mob(&mut self) -> Result<bool> {
let Some((index, distance)) = self.mob_hit() else {
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
/// crosshair or on the one a guest's interact packet named: returns true
/// when consumed.
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
let Some((index, distance)) = (match target {
// The id the packet named, under the filters the crosshair pick
// applies (`mob_hit`): a mob in its death animation is not clicked
// by either path, so naming one in a packet must not reach the
// feed, shear, leash or name paths either.
Some(t) => Self::named_entity(&self.mobs, t.id, |m| m.id)
.filter(|&i| {
let m = &self.mobs[i];
named_mob_pickable(m.alive(), m.id, self.riding, self.remote_camera_id())
})
.map(|i| (i, 0.0)),
None => self.mob_hit(),
}) else {
return Ok(false);
};
if let Some(hit) = self.pick_unfiltered()
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none()
&& let Some(hit) = self.pick_unfiltered()
&& let Some(eye) = self.eye_position()
{
let d = ((hit.point[0] - eye[0]).powi(2)
@@ -5469,6 +5633,7 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
for mob in &self.mobs {
let entity_start = out.len();
let outline = self.mob_outline(mob);
@@ -5515,6 +5680,7 @@ impl Game<'_> {
}
}
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
let light = self.world.light(
mob.position.x.floor() as i32,
(mob.position.y + mob.eye_height()).floor() as i32,
@@ -5961,6 +6127,22 @@ fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
pub(super) fn source_damage_type(d: Damage) -> &'static str {
damage_type(d.kind, d.attacker.is_some())
}
/// Whether the causing entity deals `kind` itself, so it is the source's
/// direct entity too: a melee hit (`DamageSources.mobAttack`, `playerAttack`,
/// a mace's or a spear's), a sting, a sonic boom, thorns.
pub(super) fn melee(kind: horizon_client::mobs::DamageKind) -> bool {
use horizon_client::mobs::DamageKind as K;
matches!(
kind,
K::PlayerAttack
| K::MaceSmash
| K::Spear
| K::MobAttack
| K::Sting
| K::SonicBoom
| K::Thorns
)
}
/// The damage type id of a Horizon damage kind. `caused`: the source has a
/// causing entity (only a blast asks).
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str {
@@ -6008,6 +6190,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool)
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
K::WitherSkull => "minecraft:wither_skull",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
@@ -6323,6 +6506,7 @@ mod pearl_tests {
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
};
let pw = ProjWorld {
w: &w,
+12 -5
View File
@@ -6,7 +6,7 @@
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
//! only an entity whose `Pos` is the click point's height. Placement uses
//! outline bounds, collision shapes and suffocation predicates.
use super::{Game, Hit};
use super::{Game, Hit, InteractTarget};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
@@ -307,12 +307,19 @@ impl Game<'_> {
true
}
/// `Cushion.interact`: sit down unless sneaking or already occupied.
pub(super) fn interact_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
/// `Cushion.interact`: sit down unless sneaking or already occupied. The
/// cushion under the crosshair, or the one a guest's interact packet named.
pub(super) fn interact_cushion(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d, _)) = (match target {
Some(t) => {
Self::named_entity(&self.cushions, t.id, |c| c.id).map(|i| (i, 0.0, [0.0; 3]))
}
None => self.cushion_hit(),
}) else {
return false;
};
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
// isSecondaryUseActive.
+12 -74
View File
@@ -10,7 +10,6 @@ use horizon_client::{
gizmos::Gizmos,
hud::HudBatch,
hud_layout, keybinds,
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
@@ -41,6 +40,7 @@ impl DebugHost for Host<'_, '_> {
let g = &mut *self.game;
if without_menu && g.screen == Screen::Playing {
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
g.pause_focus = None;
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -297,7 +297,6 @@ impl Game<'_> {
{
return;
}
let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced);
let framebuffer = size.map(|v| v as i32);
let (scale, [width, height]) = debug_overlay::scale(
framebuffer,
@@ -307,73 +306,7 @@ impl Game<'_> {
[batch.width as i32, batch.height as i32],
);
let mut d = debug_overlay::Displayer::default();
// fps: DebugEntryFps.
if on("fps") {
self.debug_fps_line(&mut d);
}
// game_version: DebugEntryVersion (the launched version and brand).
if on("game_version") {
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
}
// player_position, player_section_position.
if let Some(p) = self.player.as_ref() {
let mut lines = debug_overlay::position(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim.id(),
self.streaming.forced.len(),
);
let section = lines.pop().unwrap_or_default();
if on("player_position") {
d.group("position", lines);
}
if on("player_section_position") {
d.group("position", [section]);
}
// chunk_generation_stats: DebugEntryChunkGeneration, where the world is generated here.
// The window's server thread publishes its generators; the deprecated in-frame path
// (`end.locator`) is not extended.
if on("chunk_generation_stats") {
let terrain = self
.integrated
.as_ref()
.and_then(|server| server.terrain(self.dim.id()));
// `DebugEntryChunkGeneration.display`: the feet block decides whether the line is
// sampled again; otherwise the last result is drawn.
let feet = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let dim = self.dim.id();
let mut cache = self.debug_density.borrow_mut();
let line = cache.line(
dim,
feet,
terrain.map(|t| move || t.density_debug_line(feet)),
);
if let Some(line) = line {
d.group("chunk_generation", [line.to_owned()]);
}
}
}
if on("simple_performance_impactors") {
self.debug_performance_lines(&mut d);
}
if on("system_specs") {
self.debug_system_lines(&mut d, framebuffer);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
});
if self.debug && !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
if on("tps") {
self.debug_tps_line(&mut d);
}
self.debug_entry_lines(&mut d, framebuffer);
// The chart keys and the charts only while the overlay is open.
let footer = if self.debug {
self.debug_chart_lines().to_vec()
@@ -410,7 +343,7 @@ impl Game<'_> {
hud_layout::paint(batch, atlas, &draws);
}
/// `DebugEntryFps`.
fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
let limit = self.framerate_limit();
let present = self
.renderer
@@ -441,7 +374,7 @@ impl Game<'_> {
));
}
/// `DebugEntrySimplePerformanceImpactors`.
fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
pub(super) fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
let s = &self.settings;
d.line(format!(
"{}{}B: {}",
@@ -468,7 +401,11 @@ impl Game<'_> {
));
}
/// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM).
fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) {
pub(super) fn debug_system_lines(
&self,
d: &mut debug_overlay::Displayer,
framebuffer: [i32; 2],
) {
if let Some(r) = self.renderer.as_ref() {
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
d.group(
@@ -536,7 +473,7 @@ impl Game<'_> {
}
}
/// `DebugEntryTps` for local and remote connections.
fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) {
pub(super) fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) {
if self.player.is_some() {
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
let (target, status) = if self.clock.sprinting() {
@@ -940,6 +877,7 @@ impl Game<'_> {
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
let mut g = Gizmos::default();
self.bounding_box_gizmos(camera, &mut g);
self.debug_renderer_gizmos(&mut g);
if self.debug_entry("chunk_borders")
&& let Some(p) = &self.player
{
@@ -1249,7 +1187,7 @@ impl DebugQuery {
pub(super) struct DensityCache(Option<(&'static str, [i32; 3], Option<String>)>);
impl DensityCache {
/// The line to draw; `sample` is `None` without a generator.
fn line(
pub(super) fn line(
&mut self,
dim: &'static str,
feet: [i32; 3],
@@ -0,0 +1,574 @@
//! The F3 overlay's text entries (26.3 `DebugEntry*`): what each enabled
//! entry of `debug-profile.json` reports to the `DebugScreenDisplayer`, in
//! `DebugScreenEntryList.rebuildCurrentList`'s order (by identifier). The
//! entries that read the server's level ask the integrated server for their
//! lines (`ServerDebug`); on another server they show what vanilla shows
//! there, nothing or `??`.
use super::Game;
use horizon_client::{debug_entries::ENTRIES, debug_overlay as overlay, debug_overlay::Displayer};
use horizon_world::{
heightmap::{HeightmapState, HeightmapType},
worldgen::terrain::TerrainBlockStates,
};
use std::collections::{HashMap, HashSet};
/// `DebugEntryLookingAt.RANGE`.
const LOOKING_AT_RANGE: f64 = 20.0;
/// The entries whose lines come from the server thread.
const SERVER_ENTRIES: [&str; 4] = [
"chunk_source_stats",
"entity_spawn_counts",
"heightmap",
"local_difficulty",
];
/// What the server thread reports for the window's player
/// (`DebugScreenOverlay.getServerChunk` and its `ServerLevel`).
#[derive(Clone, Debug, Default)]
pub(super) struct ServerDebug {
/// The server chunk's heightmaps at the feet (`keepAfterWorldgen`).
pub heights: Option<[i32; 4]>,
pub difficulty: Option<String>,
pub spawn_counts: Option<String>,
pub chunks: Option<String>,
}
impl Game<'_> {
/// Every enabled entry's lines, by identifier.
pub(super) fn debug_entry_lines(&self, d: &mut Displayer, framebuffer: [i32; 2]) {
let reduced = self.reduced_debug_info();
let mut paths: Vec<&str> = ENTRIES
.iter()
.map(|e| e.path)
.filter(|path| self.debug_entries.enabled(path, self.debug, reduced))
.collect();
paths.sort_unstable();
let server = self.server_debug(paths.iter().any(|p| SERVER_ENTRIES.contains(p)));
for path in paths {
match path {
"biome" => self.debug_biome_line(d),
"chunk_render_stats" => self.debug_chunk_render_line(d),
"chunk_source_stats" => self.debug_chunk_source_lines(d, server.as_ref()),
"day_count" => {
if self.player.is_some() {
d.line(format!("Day #{}", self.world.day_time.div_euclid(24000)));
}
}
"entity_render_stats" => self.debug_entity_render_line(d),
"entity_spawn_counts" => {
if let Some(line) = server.as_ref().and_then(|s| s.spawn_counts.clone()) {
d.line(line);
}
}
"fps" => self.debug_fps_line(d),
"game_version" => d.priority(format!(
"Minecraft {v} ({v}/horizon)",
v = horizon_client::save::VERSION_NAME
)),
"gpu_utilization" => d.line(overlay::gpu_line(self.gpu_utilization)),
"heightmap" => self.debug_heightmap_lines(d, server.as_ref()),
"light_levels" => {
if let Some(feet) = self.debug_feet() {
let light = self.world.light(feet[0], feet[1], feet[2]);
d.group("light", [overlay::light_line(light >> 4, light & 15)]);
}
}
"local_difficulty" => {
if let Some(line) = server.as_ref().and_then(|s| s.difficulty.clone()) {
d.line(line);
}
}
"looking_at_block_state" | "looking_at_block_tags" => {
let lines = self.debug_looking_at_block(path == "looking_at_block_state");
d.group("looking_at_block", lines);
}
"looking_at_entity" | "looking_at_entity_tags" => {
let lines = self.crosshair_entity_type().map_or_else(Vec::new, |kind| {
if path == "looking_at_entity" {
vec![format!("{}Targeted Entity", overlay::UNDERLINE), kind]
} else {
overlay::tag_lines(&self.res.mob_data().entity_tags, &kind)
}
});
d.group("looking_at_entity", lines);
}
"looking_at_fluid_state" | "looking_at_fluid_tags" => {
let lines = self.debug_looking_at_fluid(path == "looking_at_fluid_state");
d.group("looking_at_fluid", lines);
}
"memory" => {
if let Some(lines) = self.memory_meter.borrow_mut().lines() {
d.group("memory", lines);
}
}
"particle_render_stats" => d.line(format!("P: {}", self.particles.len())),
"player_position" | "player_section_position" => {
if let Some(p) = self.player.as_ref() {
let mut lines = overlay::position(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim.id(),
self.streaming.forced.len(),
);
let section = lines.pop().unwrap_or_default();
if path == "player_position" {
d.group("position", lines);
} else {
d.group("position", [section]);
}
}
}
"player_speed" => {
if self.player.is_some() {
let speed = self.known_player_movement();
d.group(
"position",
[format!("Speed: {:.3} blocks/tick", speed.length())],
);
}
}
"post_effects" => {
let effects = self.requested_post_effects();
if !effects.is_empty() {
d.line(format!("Post: {}", effects.join(", ")));
}
}
"simple_performance_impactors" => self.debug_performance_lines(d),
"sound_cache" => {
let (buffers, bytes) = self.audio.cache_stats();
d.line(overlay::sound_cache_line(buffers, bytes));
}
"sound_mood" => {
if self.player.is_some() {
d.line(overlay::sound_mood_line(
self.audio.engine.used_channels(),
[
horizon_client::sound_engine::STATIC_CHANNELS,
horizon_client::sound_engine::STREAMING_CHANNELS,
],
self.ambience.moodiness,
));
}
}
"system_specs" => {
self.debug_system_lines(d, framebuffer);
// Horizon's own: the shader pack's GPU time per pass (an
// empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
overlay::shader_pass_lines(&r.shader_pass_times())
});
if self.debug && !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
// here: the window's server thread publishes its generators;
// the deprecated in-frame path is not extended.
"chunk_generation_stats" => {
if let Some(p) = self.player.as_ref() {
let terrain = self
.integrated
.as_ref()
.and_then(|server| server.terrain(self.dim.id()));
// `DebugEntryChunkGeneration.display`: the feet block
// decides whether the line is sampled again; otherwise
// the last result is drawn.
let feet = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let dim = self.dim.id();
let mut cache = self.debug_density.borrow_mut();
let line = cache.line(
dim,
feet,
terrain.map(|t| move || t.density_debug_line(feet)),
);
if let Some(line) = line {
d.group("chunk_generation", [line.to_owned()]);
}
}
}
// detailed_memory: the JVM's memory pools, which Horizon
// has none of. The rest are renderers (debug_renderers.rs).
_ => {}
}
}
}
/// The camera's feet (`Entity.blockPosition`).
fn debug_feet(&self) -> Option<[i32; 3]> {
let p = self.player.as_ref()?;
Some([p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32))
}
/// The integrated server's lines when an entry wants them; the request
/// (the dimension and the feet) goes with it, for the next server tick.
fn server_debug(&self, wanted: bool) -> Option<ServerDebug> {
let server = self.integrated.as_ref()?;
let at = self
.debug_feet()
.filter(|_| wanted)
.map(|feet| (self.dim, feet));
server.request_debug(at);
at.map(|_| server.debug())
}
/// `DebugEntryBiome`: the client level's biome at the feet, inside the
/// build height.
fn debug_biome_line(&self, d: &mut Displayer) {
let Some([x, y, z]) = self.debug_feet() else {
return;
};
if y < self.world.bottom_y() || y >= self.world.top_y() {
return;
}
if let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4))
&& let Some(biome) = self.res.biomes().list.get(chunk.biome(x, y, z) as usize)
{
d.line(format!("Biome: {}", biome.name));
}
}
/// `LevelExtractor.sectionStatistics`: the sections drawn of the view
/// area's (smart culling is always on), the render distance, and
/// `SectionRenderDispatcher.getStats` (the meshes queued; Horizon has
/// no buffer pool, so no buffer is ever free).
fn debug_chunk_render_line(&self, d: &mut Displayer) {
let Some(r) = self.renderer.as_ref() else {
return;
};
let distance = self.settings.render_distance;
let side = (distance * 2 + 1) as usize;
let total = side * side * (self.world.layout().height() / 16) as usize;
d.line(format!(
"C: {}/{total} (s) D: {distance}, pC: {:03}, aB: {:02}",
r.drawn_sections,
self.streaming.pending(),
0
));
}
/// `LevelExtractor.entityStatistics`: the mobs drawn last frame of the
/// level's entities, and the server's simulation distance.
fn debug_entity_render_line(&self, d: &mut Displayer) {
if self.player.is_none() {
return;
}
let simulation = self
.remote
.as_ref()
.map_or(self.settings.simulation_distance, |r| r.simulation_distance);
d.line(format!(
"E: {}/{}, SD: {simulation}",
self.rendered_mobs.get(),
self.debug_entity_count()
));
}
/// `Level.getEntityCount` over the entities Horizon keeps.
fn debug_entity_count(&self) -> usize {
self.mobs.iter().filter(|m| !m.removed).count()
+ self.dropped.len()
+ self.orbs.len()
+ self.paintings.len()
+ self.item_frames.len()
+ self.armor_stands.len()
+ self.boats.len()
+ self.carts.len()
+ self.crystals.len()
+ self.creatures.projectiles.len()
}
/// The sections holding a mob, an item or an orb
/// (`EntitySectionStorage.count`).
fn debug_entity_sections(&self) -> usize {
let sections: HashSet<[i32; 3]> = self
.mobs
.iter()
.filter(|m| !m.removed)
.map(|m| [m.position.x, m.position.y, m.position.z])
.chain(
self.dropped
.iter()
.map(|i| [i.position.x, i.position.y, i.position.z]),
)
.chain(
self.orbs
.iter()
.map(|o| [o.position.x, o.position.y, o.position.z]),
)
.map(|p| p.map(|v| (v.floor() as i32) >> 4))
.collect();
sections.len()
}
/// `DebugEntryChunkSourceStats`: `ClientLevel.gatherChunkSourceStats`
/// (the chunk storage's size and its loaded chunks, then the entities,
/// the sections holding them and the chunks that tick them), and the
/// server level's when this game runs it.
fn debug_chunk_source_lines(&self, d: &mut Displayer, server: Option<&ServerDebug>) {
if self.player.is_none() {
return;
}
let range = self
.remote
.as_ref()
.map_or(self.settings.render_distance, |r| r.view_distance);
let side = (2 * (range.max(2) + 3) + 1) as usize;
let loaded = self.world.chunks.len();
d.line(format!(
"Chunks[C] W: {}, {loaded} E: {},{},{loaded}",
side * side,
self.debug_entity_count(),
self.debug_entity_sections()
));
if let Some(line) = server.and_then(|s| s.chunks.clone()) {
d.line(line);
}
}
/// `DebugEntryHeightmap`: the client chunk's heightmaps at the feet,
/// and the server chunk's.
fn debug_heightmap_lines(&self, d: &mut Displayer, server: Option<&ServerDebug>) {
let Some([x, _, z]) = self.debug_feet() else {
return;
};
if !self.world.loaded(x, z) {
return;
}
let heights = server.and_then(|s| s.heights);
let server_height = |kind: HeightmapType| {
let i = [
HeightmapType::WorldSurface,
HeightmapType::OceanFloor,
HeightmapType::MotionBlocking,
HeightmapType::MotionBlockingNoLeaves,
]
.iter()
.position(|k| *k == kind)
.unwrap_or(0);
heights.map_or(0, |h| h[i])
};
let lines = overlay::heightmap_lines(
|kind| self.column_height(kind, x, z),
heights
.is_some()
.then_some(&server_height as &dyn Fn(_) -> i32),
);
d.group("heightmaps", lines);
}
/// `Heightmap.getFirstAvailable`: one above the highest block the
/// heightmap counts. The chunk keeps WORLD_SURFACE; every other type is
/// at or under it, scanned down from there.
pub(super) fn column_height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 {
let properties = &self.pack.assets.properties;
let surface = self
.world
.world_surface(x, z, |s| properties.heightmap_state(s).is_air());
if kind == HeightmapType::WorldSurface {
return surface;
}
(self.world.bottom_y()..surface)
.rev()
.find(|&y| kind.is_opaque(properties.heightmap_state(self.world.block(x, y, z))))
.map_or(self.world.bottom_y(), |y| y + 1)
}
/// `Entity.pick(20, 0, false)` from the camera: the block outline hit.
fn debug_block_hit(&self) -> Option<[i32; 3]> {
let (origin, dir) = self.aim()?;
Some(self.clip_ray(origin, dir, LOOKING_AT_RANGE)?.pos)
}
/// `DebugEntryLookingAt.BlockStateInfo` or `BlockTagInfo`.
fn debug_looking_at_block(&self, state: bool) -> Vec<String> {
let Some(pos) = self.debug_block_hit() else {
return Vec::new();
};
let id = self.world.block(pos[0], pos[1], pos[2]);
let Some(s) = self.pack.assets.registry.state(id) else {
return Vec::new();
};
if state {
overlay::state_lines(
"Targeted Block",
pos,
&s.name,
s.properties.iter().map(|(k, v)| (k.as_str(), v.as_str())),
)
} else {
overlay::tag_lines(&self.items().block_tags, &s.name)
}
}
/// `Entity.pick(20, 0, true)` (`ClipContext.Fluid.ANY`): the first fluid
/// along the view, under its surface, before the block outline hit.
fn debug_fluid_hit(&self) -> Option<[i32; 3]> {
let (origin, dir) = self.aim()?;
let block = self.clip_ray(origin, dir, LOOKING_AT_RANGE);
let reach = block.map_or(LOOKING_AT_RANGE, |h| {
(0..3)
.map(|i| (h.point[i] - origin[i]).powi(2))
.sum::<f64>()
.sqrt()
});
let mut t = 0.0;
while t <= reach {
let p: [f64; 3] = std::array::from_fn(|i| origin[i] + dir[i] * t);
let b = p.map(|v| v.floor() as i32);
if let Some((height, _)) = self.fluid_height_of(b[0], b[1], b[2], None)
&& p[1] - f64::from(b[1]) <= f64::from(height)
{
return Some(b);
}
t += 0.05;
}
block.map(|h| h.pos)
}
/// `FluidState`'s registered name and properties at `pos`: a source is
/// `water`/`lava` with `falling`, a flowing one `flowing_*` with `level`.
fn fluid_state(&self, pos: [i32; 3]) -> (String, Vec<(&'static str, String)>) {
use horizon_client::blocks::Fluid;
let f = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]));
let kind = match f.fluid {
Fluid::None => return ("minecraft:empty".into(), Vec::new()),
Fluid::Water => "water",
Fluid::Lava => "lava",
};
let falling = ("falling", f.fluid_falling.to_string());
if f.fluid_level == 8 && !f.fluid_falling {
(format!("minecraft:{kind}"), vec![falling])
} else {
(
format!("minecraft:flowing_{kind}"),
vec![falling, ("level", f.fluid_level.to_string())],
)
}
}
/// `DebugEntryLookingAt.FluidStateInfo` or `FluidTagInfo`.
fn debug_looking_at_fluid(&self, state: bool) -> Vec<String> {
let Some(pos) = self.debug_fluid_hit() else {
return Vec::new();
};
let (name, properties) = self.fluid_state(pos);
if state {
overlay::state_lines(
"Targeted Fluid",
pos,
&name,
properties.iter().map(|(k, v)| (*k, v.as_str())),
)
} else {
overlay::tag_lines(fluid_tags(&self.options.resources), &name)
}
}
/// `Minecraft.crosshairPickEntity`'s type: the nearest entity under the
/// crosshair before any block.
fn crosshair_entity_type(&self) -> Option<String> {
let mut best: Option<(f64, String)> = None;
let mut offer = |hit: Option<(f64, String)>| {
if let Some((distance, kind)) = hit
&& self.before_block(distance)
&& best.as_ref().is_none_or(|b| distance < b.0)
{
best = Some((distance, kind));
}
};
offer(
self.mob_hit()
.map(|(i, d)| (d, self.mobs[i].kind.id.to_string())),
);
offer(
self.boat_hit()
.map(|(i, d)| (d, self.boats[i].entity_type())),
);
offer(
self.cart_hit()
.map(|(i, d)| (d, self.carts[i].kind.id().to_owned())),
);
offer(
self.armor_stand_hit()
.map(|(_, d, _)| (d, "minecraft:armor_stand".to_owned())),
);
offer(
self.frame_hit()
.map(|(i, d)| (d, self.item_frames[i].kind().to_owned())),
);
offer(
self.painting_hit()
.map(|(_, d)| (d, "minecraft:painting".to_owned())),
);
offer(
self.dragon_hit()
.map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())),
);
offer(
self.guest_hit()
.map(|(_, d)| (d, "minecraft:player".to_owned())),
);
best.map(|b| b.1)
}
/// The server thread's half of the debug entries, in the level of
/// `dim` while it is in place: the server chunk's heightmaps, the local
/// difficulty, the last spawn state and the chunk source's counts.
pub(super) fn server_debug_lines(&self, feet: [i32; 3]) -> ServerDebug {
let [x, y, z] = feet;
let key = (x >> 4, z >> 4);
let loaded = self.world.chunks.contains_key(&key);
let inside = y >= self.world.bottom_y() && y < self.world.top_y();
let heights = loaded.then(|| {
[
HeightmapType::WorldSurface,
HeightmapType::OceanFloor,
HeightmapType::MotionBlocking,
HeightmapType::MotionBlockingNoLeaves,
]
.map(|kind| self.column_height(kind, x, z))
});
let difficulty = (loaded && inside).then(|| {
let at = [f64::from(x), f64::from(y), f64::from(z)];
overlay::local_difficulty_line(self.local_difficulty(at).effective)
});
let spawn_counts = self
.spawn_counts
.get(&self.dim)
.map(|(chunks, counts)| overlay::spawn_counts_line(*chunks, counts));
// ServerLevel.gatherChunkSourceStats: the chunk map's size, then
// PersistentEntitySectionManager's known and visible entities, its
// sections, the chunks it loaded and sees, and its two queues
// (Horizon loads a chunk's entities with it: both stay empty).
let (entities, loaded) = (self.debug_entity_count(), self.world.chunks.len());
let chunks = Some(format!(
"Chunks[S] W: {loaded} E: {entities},{entities},{},{loaded},{loaded},0,0",
self.debug_entity_sections()
));
ServerDebug {
heights,
difficulty,
spawn_counts,
chunks,
}
}
}
/// The fluid tags (`data/*/tags/fluid`), loaded once.
fn fluid_tags(root: &std::path::Path) -> &'static HashMap<String, HashSet<String>> {
static TAGS: std::sync::OnceLock<HashMap<String, HashSet<String>>> = std::sync::OnceLock::new();
TAGS.get_or_init(|| {
horizon_client::items::load_tags(root, "fluid", &horizon_world::loot::PackOverlay::NONE)
.unwrap_or_default()
})
}
@@ -317,6 +317,19 @@ impl Game<'_> {
};
let (w, h) = self.gui_size();
let (top, bottom) = (HEADER, h - FOOTER);
let rows = rows(&s.search.value);
let left = row_left(w);
// A press on the scrollbar holds it and clicks nothing else.
if s.scroll.press(
self.cursor,
left + ROW_WIDTH + 8.0,
top,
bottom,
content_height(rows.len()),
) {
self.debug_options = Some(s);
return;
}
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
s.search.focused = ui::hovered(
@@ -330,8 +343,6 @@ impl Game<'_> {
s.search.click(&self.gui.atlas, self.cursor.0, shift, false);
}
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
let rows = rows(&s.search.value);
let left = row_left(w);
let mut clicked = None;
if inside {
for (i, row) in rows.iter().enumerate() {
@@ -390,6 +401,23 @@ impl Game<'_> {
.wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content);
}
}
/// The pointer moved while the list's scrollbar is held.
pub(super) fn debug_options_drag(&mut self) {
let (_, h) = self.gui_size();
let y = self.cursor.1;
if let Some(s) = &mut self.debug_options
&& s.scroll.held()
{
let content = content_height(rows(&s.search.value).len());
s.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub(super) fn debug_options_release(&mut self) {
if let Some(s) = &mut self.debug_options {
s.scroll.release();
}
}
/// A key the debug keys left: Escape closes, the rest edits the search.
pub(super) fn debug_options_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
@@ -0,0 +1,292 @@
//! The debug options screen's renderer entries (26.3 `DebugRenderer`): the
//! water levels, heightmaps, collision boxes, light levels and sturdy faces
//! around the camera. Lines join the debug gizmos; fills and billboard texts
//! go with the entities' translucent geometry, as the test markers do.
//!
//! No-ops, for want of the data: `chunk_section_paths`,
//! `chunk_section_octree` and `chunk_section_visibility` (Horizon culls on
//! the GPU, with no section occlusion graph or octree),
//! `visualize_chunks_on_server` (the server's chunk statuses),
//! `visualize_sky_light_sections` (the light engine's section data) and
//! `visualize_entity_supporting_blocks` (`Entity.mainSupportingBlockPos`).
use super::Game;
use horizon_client::{
block_entity_render::vertex_light,
debug_chart::srgb_lerp,
gizmos::{self as g, Gizmos},
hud::HudBatch,
hud_layout::abgr,
mesher::Vertex,
text::Text,
};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_world::{collision_context::CollisionContext, heightmap::HeightmapType};
/// `WaterDebugRenderer`'s fill: `ARGB.colorFromFloat(0.15F, 0, 1, 0)`.
const WATER_FILL: u32 = 0x2600_FF00;
/// `SolidFaceRenderer`'s fill (-2130771968).
const SOLID_FACE_FILL: u32 = 0x80FF_0000;
/// `TextGizmo.Style.DEFAULT_SCALE`.
const TEXT_SCALE: f32 = 0.32;
/// `HeightMapRenderer.BOX_HEIGHT`.
const BOX_HEIGHT: f64 = 0.09375;
/// `HeightMapRenderer.getColor`, for the client chunk's heightmaps.
fn heightmap_color(kind: HeightmapType) -> u32 {
match kind {
HeightmapType::WorldSurfaceWg => 0xFFFF_FF00,
HeightmapType::OceanFloorWg => 0xFFFF_00FF,
HeightmapType::WorldSurface => 0xFF00_B200,
HeightmapType::OceanFloor => 0xFF00_007F,
HeightmapType::MotionBlocking => 0xFF00_4C4C,
HeightmapType::MotionBlockingNoLeaves => 0xFF00_7F7F,
}
}
/// `BlockPos.betweenClosed` of the cube `radius` around `center`.
fn cube(center: [i32; 3], radius: i32) -> impl Iterator<Item = [i32; 3]> {
let r = -radius..=radius;
r.clone().flat_map(move |dy| {
let r = r.clone();
r.clone().flat_map(move |dz| {
r.clone()
.map(move |dx| [center[0] + dx, center[1] + dy, center[2] + dz])
})
})
}
impl Game<'_> {
/// `CollisionBoxRenderer`: the block collisions within 6 of the camera
/// entity's box, white.
pub(super) fn debug_renderer_gizmos(&self, g: &mut Gizmos) {
if !self.debug_entry("visualize_collision_boxes") {
return;
}
let Some(p) = &self.player else {
return;
};
let bounds = p.bounds().inflate(Vec3::new(6.0, 6.0, 6.0));
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let mut scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
let Ok(shape) = self.collision.collision_shape_with_context(
&mut scene,
self.world.block(x, y, z),
BlockPos::new(x, y, z),
&CollisionContext::empty(false),
) else {
continue;
};
let at = [f64::from(x), f64::from(y), f64::from(z)];
for b in shape.boxes() {
let (min, max) = (b.min_position(), b.max_position());
g.cuboid(
[at[0] + min.x, at[1] + min.y, at[2] + min.z],
[at[0] + max.x, at[1] + max.y, at[2] + max.z],
g::WHITE,
g::STROKE_WIDTH,
);
}
}
}
}
}
/// The fill and text renderers' geometry for this frame.
pub(super) fn debug_renderer_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let water = self.debug_entry("visualize_water_levels");
let heightmap = self.debug_entry("visualize_heightmap");
let block_light = self.debug_entry("visualize_block_light_levels");
let sky_light = self.debug_entry("visualize_sky_light_levels");
let solid = self.debug_entry("visualize_solid_faces");
let Some(p) = &self.player else {
return;
};
if !(water || heightmap || block_light || sky_light || solid) {
return;
}
let feet = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32);
let eye = camera.to_array().map(|v| v.floor() as i32);
let mut fills: Vec<([[f64; 3]; 4], u32)> = Vec::new();
let mut texts: Vec<([f64; 3], String, u32)> = Vec::new();
if water {
self.water_level_shapes(feet, &mut fills, &mut texts);
}
if heightmap {
self.heightmap_shapes(eye, &mut fills);
}
if block_light || sky_light {
light_level_texts(self, eye, block_light, sky_light, &mut texts);
}
if solid {
self.solid_face_shapes(eye, &mut fills);
}
let start = out.len();
self.mark_translucent_entity(start, "");
let light = vertex_light(0xFF);
let mut white = HudBatch::new([1, 1]);
for (quad, color) in fills {
// A plain colour: the atlas's white pixel at the fill's colour
// (the batch takes ABGR).
white.vertices.clear();
white.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, abgr(color));
let Some(v) = white.vertices.first().copied() else {
continue;
};
for corner in quad {
out.push(Vertex {
normal: [0; 4],
pos: (glam::DVec3::from_array(corner) - camera)
.as_vec3()
.to_array(),
uv: v.uv,
layer: 0,
color: v.color,
light,
glint_uv: [0.0, 0.0],
});
}
}
// `Gizmos.billboardText`: centred, without a background.
let mut patches = Vec::new();
for (at, text, color) in texts {
patches.extend(self.name_tag_quads(
glam::DVec3::from_array(at) - camera,
&[(Text::literal(text).with_color(color & 0xFF_FFFF), 0.0)],
color >> 24,
false,
2,
0,
g::billboard_text_unit(TEXT_SCALE),
out,
));
}
self.map_patches.extend(patches);
}
/// `WaterDebugRenderer`: each water block within 10 of the feet filled
/// to its height, with its amount over it.
fn water_level_shapes(
&self,
feet: [i32; 3],
fills: &mut Vec<([[f64; 3]; 4], u32)>,
texts: &mut Vec<([f64; 3], String, u32)>,
) {
use horizon_client::blocks::Fluid;
for pos in cube(feet, 10) {
let f = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]));
if f.fluid != Fluid::Water {
continue;
}
let Some((height, _)) = self.fluid_height_of(pos[0], pos[1], pos[2], None) else {
continue;
};
let [x, y, z] = pos.map(f64::from);
let top = y + f64::from(height);
let (lo, hi) = (f64::from(0.01f32), f64::from(0.99f32));
fills.extend(
g::cuboid_fill_quads([x + lo, y + lo, z + lo], [x + hi, top, z + hi])
.map(|q| (q, WATER_FILL)),
);
// FluidState.getAmount: a source holds 8, a flowing one its level.
texts.push((
[x + 0.5, top, z + 0.5],
f.fluid_level.to_string(),
0xFF00_0000,
));
}
}
/// `HeightMapRenderer`: the client chunk's heightmaps of the 5x5 chunks
/// around the camera, a small box per column, stacked by type.
fn heightmap_shapes(&self, eye: [i32; 3], fills: &mut Vec<([[f64; 3]; 4], u32)>) {
let (cx, cz) = (eye[0] >> 4, eye[2] >> 4);
for chunk_x in cx - 2..=cx + 2 {
for chunk_z in cz - 2..=cz + 2 {
if !self.world.chunks.contains_key(&(chunk_x, chunk_z)) {
continue;
}
for kind in HeightmapType::ALL
.into_iter()
.filter(|k| k.send_to_client())
{
let color = heightmap_color(kind);
for x in chunk_x * 16..chunk_x * 16 + 16 {
for z in chunk_z * 16..chunk_z * 16 + 16 {
let height = f64::from(self.column_height(kind, x, z))
+ f64::from(kind as u8) * BOX_HEIGHT;
let (x, z) = (f64::from(x), f64::from(z));
fills.extend(
g::cuboid_fill_quads(
[x + 0.25, height, z + 0.25],
[x + 0.75, height + BOX_HEIGHT, z + 0.75],
)
.map(|q| (q, color)),
);
}
}
}
}
}
}
/// `SolidFaceRenderer`: each outline box of the blocks within 6 of the
/// camera, grown by 0.002, with its sides that are sturdy filled.
fn solid_face_shapes(&self, eye: [i32; 3], fills: &mut Vec<([[f64; 3]; 4], u32)>) {
// In SolidFaceRenderer's order (WEST, SOUTH, EAST, NORTH, DOWN, UP):
// the Direction ordinal and the side's quad in `cuboid_fill_quads`.
const SIDES: [(u8, usize); 6] = [(4, 1), (3, 3), (5, 0), (2, 2), (0, 5), (1, 4)];
for pos in cube(eye, 6) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
if &*f.name == "minecraft:air" || f.sturdy_full == 0 {
continue;
}
let at = pos.map(f64::from);
for b in f.outline.iter() {
let min = [at[0] + b[0], at[1] + b[1], at[2] + b[2]].map(|v| v - 0.002);
let max = [at[0] + b[3], at[1] + b[4], at[2] + b[5]].map(|v| v + 0.002);
let quads = g::cuboid_fill_quads(min, max);
for (direction, side) in SIDES {
if f.sturdy_full & (1 << direction) != 0 {
fills.push((quads[side], SOLID_FACE_FILL));
}
}
}
}
}
}
/// `LightDebugRenderer`: within 10 of the camera, the sky light where it
/// isn't full and the block light where there is some (the light engine's
/// section data, drawn with them in vanilla, Horizon doesn't keep).
fn light_level_texts(
game: &Game<'_>,
eye: [i32; 3],
block: bool,
sky: bool,
texts: &mut Vec<([f64; 3], String, u32)>,
) {
for pos in cube(eye, 10) {
let light = game.world.light(pos[0], pos[1], pos[2]);
let (sky_light, block_light) = (light >> 4, light & 15);
let [x, y, z] = pos.map(f64::from);
if sky_light != 15 && sky {
let color = srgb_lerp(f32::from(sky_light) / 15.0, 0xFF00_00FF, 0xFF00_FFFF);
texts.push(([x + 0.5, y + 0.25, z + 0.5], sky_light.to_string(), color));
}
if block && block_light != 0 {
let color = srgb_lerp(f32::from(block_light) / 15.0, 0xFFAA_0000, 0xFFFF_FF00);
texts.push(([x + 0.5, y + 0.5, z + 0.5], block_light.to_string(), color));
}
}
}
+3 -1
View File
@@ -509,7 +509,7 @@ impl Game<'_> {
});
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let biomes = world_biomes!(self);
let (settings, errors) = horizon_client::flatgen::settings_from_json(
&props.generator_settings,
|b| self.pack.assets.registry.default_state(b).is_some(),
@@ -610,6 +610,7 @@ impl Game<'_> {
pub(super) fn run_dedicated(&mut self) -> Result<()> {
let result = self.run_dedicated_inner();
if let Err(error) = &result {
self.native_players_leaving();
self.native_lifecycle_world_failed(&error.to_string());
self.native_lifecycle_close_failed(&error.to_string());
}
@@ -1716,6 +1717,7 @@ impl Game<'_> {
// MinecraftServer.stopServer's start: everyone is still here.
self.native_server_stopping(true);
log("INFO", "Stopping server");
self.native_players_leaving();
log("INFO", "Saving players");
if let Some(mut host) = self.lan.take() {
self.flush_native_net(&mut host);
+71 -9
View File
@@ -45,23 +45,41 @@ pub(super) fn aim_deflect(
p.acceleration = 0.1;
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
/// `Entity.getPickRadius`: only `AbstractHurtingProjectile` has one (1); a shulker
/// bullet's is 0.
fn pick_radius(p: &Projectile) -> f64 {
if matches!(p.kind, ProjectileKind::ShulkerBullet { .. }) {
0.0
} else {
PICK_RADIUS
}
}
/// A projectile a player's blow reaches: a redirectable one, or a shulker bullet
/// (`isPickable` and `isAttackable`), which `ShulkerBullet.hurtServer` pops.
pub(super) fn attackable(p: &Projectile) -> bool {
redirectable(p) || (!p.stuck && matches!(p.kind, ProjectileKind::ShulkerBullet { .. }))
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge or a
/// shulker bullet 0.3125, from its feet up) grown by the pick radius, which only
/// `AbstractHurtingProjectile` has (`getPickRadius`: 1; a shulker bullet's is 0).
fn pick_box(p: &Projectile) -> Aabb {
let size = match p.kind {
ProjectileKind::LargeFireball { .. } => 1.0,
_ => 0.3125,
};
let half = size / 2.0 + PICK_RADIUS;
let radius = pick_radius(p);
let half = size / 2.0 + radius;
Aabb::new(
Vec3::new(
p.position.x - half,
p.position.y - PICK_RADIUS,
p.position.y - radius,
p.position.z - half,
),
Vec3::new(
p.position.x + half,
p.position.y + size + PICK_RADIUS,
p.position.y + size + radius,
p.position.z + half,
),
)
@@ -77,7 +95,7 @@ impl Game<'_> {
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| redirectable(p))
.filter(|(_, p)| attackable(p))
.filter_map(|(i, p)| ray_box(eye, dir, reach, pick_box(p)).map(|t| (i, t)))
.min_by(|a, b| a.1.total_cmp(&b.1))
}
@@ -103,10 +121,37 @@ impl Game<'_> {
return false;
}
}
self.deflect(i);
if matches!(
self.creatures.projectiles[i].kind,
ProjectileKind::ShulkerBullet { .. }
) {
self.hit_shulker_bullet(i);
} else {
self.deflect(i);
}
true
}
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
let p = self.creatures.projectiles.remove(i);
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:entity.shulker_bullet.hurt", Some(at), 1.0, 1.0);
if let Some(ty) = horizon_client::particles::ParticleType::from_name("crit") {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
}
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
self.reset_attack_strength();
self.swing_arm();
}
/// `Player.canInteractWithEntity(box, 3.0)` as the server checks an
/// attack packet: the projectile's box within the reach plus 3 of the eyes.
pub(super) fn within_interaction_range(&self, i: usize) -> bool {
@@ -115,9 +160,10 @@ impl Game<'_> {
};
let b = pick_box(p);
let (lo, hi) = (b.min_position(), b.max_position());
let r = pick_radius(&self.creatures.projectiles[i]);
let (lo, hi) = (
[lo.x + PICK_RADIUS, lo.y + PICK_RADIUS, lo.z + PICK_RADIUS],
[hi.x - PICK_RADIUS, hi.y - PICK_RADIUS, hi.z - PICK_RADIUS],
[lo.x + r, lo.y + r, lo.z + r],
[hi.x - r, hi.y - r, hi.z - r],
);
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
@@ -192,6 +238,22 @@ mod tests {
let small = Projectile::fireball(ProjectileKind::SmallFireball, at, None, [1.0, 0.0, 0.0]);
assert!(redirectable(&large));
assert!(!redirectable(&small));
// A shulker bullet takes a blow, with no pick radius round its box.
let bullet = Projectile::new(
ProjectileKind::ShulkerBullet {
target: Who::Player,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
at,
None,
);
assert!(attackable(&bullet) && !redirectable(&bullet));
assert!(!attackable(&small));
let b = pick_box(&bullet);
assert_eq!(b.max_position().y, 64.3125);
// The pick box: a block round the fireball's 1 x 1 box.
let b = pick_box(&large);
assert_eq!(b.min_position().x, -1.5);
+42 -12
View File
@@ -199,6 +199,9 @@ impl Game<'_> {
selecting: None,
});
self.dialogs.waiting = None;
self.keys.clear();
self.left = false;
self.right = false;
self.capture(false);
}
@@ -261,8 +264,7 @@ impl Game<'_> {
Some(Click::RunCommand(command)) => {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
let command = command.strip_prefix('/').unwrap_or(&command);
self.send_chat(&format!("/{command}"));
self.send_unattended_command(&command);
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
@@ -696,6 +698,7 @@ impl Game<'_> {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.focus = Some(input);
open.dragging = Some(input);
if let Some(v) = open.values.get_mut(input) {
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
@@ -768,16 +771,17 @@ impl Game<'_> {
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
let hovered = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered {
let hovered_cycle =
layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered_cycle {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
@@ -912,6 +916,32 @@ impl Game<'_> {
}
return Ok(());
}
if let Some(focus) = open.focus
&& let Some(input) = open.dialog.inputs.get(focus)
&& let Control::NumberRange {
start,
end,
step,
width,
..
} = &input.control
&& let Some(InputValue::Slider(val)) = open.values.get_mut(focus)
&& matches!(key, KeyCode::ArrowLeft | KeyCode::ArrowRight)
{
let delta = if let Some(step) = step {
let range = (end - start).abs();
if range > 0.0 { *step / range } else { 0.01 }
} else {
1.0 / (*width as f32 - 8.0).max(1.0)
};
let step_sign = if key == KeyCode::ArrowRight {
delta
} else {
-delta
};
*val = (*val + step_sign).clamp(0.0, 1.0);
return Ok(());
}
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
return Ok(());
};
@@ -123,7 +123,7 @@ impl Game<'_> {
fn fixture_reset(&mut self, seed: i64) {
self.world = World::new(self.res.facts().air, seed);
// The oracle level is plains everywhere (bone meal's flowers, freezing).
let plains = u32::from(self.res.biomes().id("minecraft:plains"));
let plains = u32::from(world_biomes!(self).id("minecraft:plains"));
for cx in -2..=2 {
for cz in -2..=2 {
let mut chunk = Chunk::empty(cx, cz);
+22
View File
@@ -3949,6 +3949,28 @@ mod tests {
assert_eq!(d.health, MAX_HEALTH - 1.0);
}
#[test]
fn only_a_player_owned_wither_skull_takes_the_dragons_health() {
// WitherSkull.onHitEntity: EnderDragon.hurt takes the wither's skull
// (not a player, not an explosion) but takes no health, even perched.
let skull = ProjectileKind::WitherSkull { dangerous: false };
let wither = super::super::wither::skull_source(Some(Who::Mob(7)), [0.0; 3]);
assert_eq!((wither.amount, wither.kind), (8.0, DamageKind::WitherSkull));
let mut d = dragon(Phase::Scanning { time: 0 });
let hurt = d.hurt_part(2, wither.amount, false, wither.kind, Some(&skull));
assert_eq!(hurt, Hurt::Landed { full: false });
assert_eq!(d.health, MAX_HEALTH);
// A player's skull: 8 on the head, 8 / 4 + 1 on the body.
let player = super::super::wither::skull_source(Some(Who::Player), [0.0; 3]);
let mut d = dragon(Phase::Scanning { time: 0 });
let hurt = d.hurt_part(0, player.amount, true, player.kind, Some(&skull));
assert_eq!(hurt, Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 8.0);
let mut d = dragon(flying());
d.hurt_part(2, player.amount, true, player.kind, Some(&skull));
assert_eq!(d.health, MAX_HEALTH - 3.0);
}
#[test]
fn the_cooldown_lets_only_the_excess_through() {
// LivingEntity.hurtServer: damageCooldownTime > 10 keeps lastHurt.
@@ -364,7 +364,9 @@ impl Game<'_> {
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
let (new, entity) = (!m.has_effect(id), m.id as i32);
m.add_effect(id, ticks, amplifier);
self.mod_effect_added(id, Some(entity), amplifier, new);
}
}
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
@@ -488,14 +490,7 @@ impl Game<'_> {
.is_some_and(|i| s.damage >= i.max_damage)
{
self.armor[slot] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
self.player_equipment_broke(super::armor_slot(slot), &stack);
self.apply_equipment();
}
}
@@ -760,16 +755,11 @@ impl Game<'_> {
boots.damage += loss;
if boots.damage >= max {
self.armor[3] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
// LivingEntity.breakItem: the broken boots' five crumbs.
self.eat_particles(&stack, 5);
// LivingEntity.onEquippedItemBroken: the broken boots.
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::Feet,
&stack,
);
self.apply_equipment();
}
}
+71 -129
View File
@@ -6,7 +6,7 @@ use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
containers::BlockEntity,
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -39,8 +39,6 @@ pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
/// File (in the End region folder) keeping End gateway exits.
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -74,17 +72,21 @@ enum Arriving {
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
gateway: [i32; 3],
entity: Option<&BlockEntity>,
legacy: Option<([i32; 3], bool)>,
) -> Self {
/// The source of a new exit must stay loaded until its block entity is updated.
pub(super) fn source_chunk(self) -> Option<(i32, i32)> {
match self {
Self::Find { gateway, .. } => Some((gateway[0] >> 4, gateway[2] >> 4)),
Self::Known { .. } => None,
}
}
/// TheEndGatewayBlockEntity.getPortalPosition, using the gateway block entity.
fn for_gateway(gateway: [i32; 3], entity: Option<&BlockEntity>) -> Self {
let configured = match entity {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
match configured.or(legacy) {
match configured {
Some((exit, exact)) => Self::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
@@ -105,17 +107,6 @@ fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
}
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
/// entity has its own; true when it took the saved one.
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
if entity.exit_portal.is_some() {
return false;
}
entity.exit_portal = Some(exit);
entity.exact_teleport = exact;
true
}
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
@@ -124,8 +115,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
#[derive(Default)]
pub(super) struct EndState {
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
exits: Option<HashMap<[i32; 3], ([i32; 3], bool)>>,
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
@@ -932,74 +921,39 @@ impl Game<'_> {
Ok(())
}
fn gateway_exits(&mut self) -> &mut HashMap<[i32; 3], ([i32; 3], bool)> {
if self.end.exits.is_none() {
let path = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end")
.join(GATEWAY_FILE);
let parsed: Option<Vec<([i32; 3], [i32; 3], bool)>> = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
self.end.exits = Some(
parsed
.unwrap_or_default()
.into_iter()
.map(|(g, e, x)| (g, (e, x)))
.collect(),
);
}
self.end.exits.as_mut().expect("loaded exits")
}
fn save_gateway_exits(&mut self) -> Result<()> {
let list: Vec<_> = self
.gateway_exits()
.iter()
.map(|(g, (e, x))| (*g, *e, *x))
.collect();
let dir = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(GATEWAY_FILE), serde_json::to_vec(&list)?)?;
Ok(())
}
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
if self.gateway_exits().remove(&gateway).is_some() {
self.save_gateway_exits()?;
}
Ok(())
}
/// `TheEndGatewayBlockEntity.setExitPosition`: the block entity alone owns the exit.
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
// exit then waits in the side file until the gateway is used again (`gateway_job`).
if self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
if !self.world.chunks.contains_key(&chunk) {
return Err("gateway source chunk is not loaded".into());
}
// A gateway removed while an entity waits for the destination stays removed.
if &*self.block_name(gateway) != "minecraft:end_gateway" {
return Ok(());
}
if let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with((gateway[0], gateway[1], gateway[2]), || {
BlockEntity::EndGateway(Default::default())
})
{
g.exit_portal = Some(exit);
g.exact_teleport = exact;
self.world.modified.insert(chunk);
return self.forget_gateway_exit(gateway);
}
self.gateway_exits().insert(gateway, (exit, exact));
self.save_gateway_exits()
Ok(())
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
fn known_gateway_exit(&self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
match self
.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2]))
{
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|e| (e, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
@@ -1021,32 +975,18 @@ impl Game<'_> {
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let mut known = self.gateway_exits().get(&gateway).copied();
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
// side file kept for it moves there, and the entry is dropped, so removing or replacing
// the exit (`/data remove block`) is not undone by an old entry.
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
if let Some(saved) = known
&& self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
if adopt_saved_exit(g, saved) {
self.world.modified.insert(chunk);
}
known = None;
let _ = self.forget_gateway_exit(gateway);
}
let job = GatewayJob::for_gateway(
gateway,
self.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2])),
known,
);
if let Some(c) = job.source_chunk()
&& !self.end.areas.contains(&c)
{
self.end.areas.push(c);
self.streaming.extra.push((c, 1));
}
let destination = match job {
GatewayJob::Known { exit, .. } => Vec3::new(
f64::from(exit[0]) + 0.5,
@@ -1108,6 +1048,13 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
if !self
.world
.chunks
.contains_key(&(gateway[0] >> 4, gateway[2] >> 4))
{
return Err("gateway source chunk is not loaded".into());
}
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
@@ -1598,6 +1545,8 @@ impl Game<'_> {
uuid,
self.persist_raw(id),
);
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
self.dropped.remove(i);
self.forget_entity(id);
tag
@@ -1668,7 +1617,12 @@ impl Game<'_> {
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
if !self.world.chunks.contains_key(&chunk(arrival.target))
|| arrival
.job
.source_chunk()
.is_some_and(|c| !self.world.chunks.contains_key(&c))
{
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
@@ -1736,7 +1690,16 @@ impl Game<'_> {
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
let sources = self
.end
.arrivals
.iter()
.filter_map(|a| a.job.source_chunk());
for c in arrivals
.chain(tickets.map(|&(c, _)| c))
.chain(sources)
.chain(self.gateway_source_chunks())
{
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
@@ -1785,23 +1748,6 @@ mod tests {
use super::*;
use horizon_client::containers::EndGateway;
#[test]
fn a_saved_exit_moves_into_a_gateway_without_one() {
let saved = ([8, 100, 0], true);
let mut bare = EndGateway::default();
assert!(adopt_saved_exit(&mut bare, saved));
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
assert!(bare.exact_teleport);
// The block entity's own exit wins over a stale entry.
let mut own = EndGateway {
exit_portal: Some([1, 2, 3]),
..Default::default()
};
assert!(!adopt_saved_exit(&mut own, saved));
assert_eq!(own.exit_portal, Some([1, 2, 3]));
assert!(!own.exact_teleport);
}
#[test]
fn a_new_exit_keeps_the_gateways_exact_teleport() {
let exact = BlockEntity::EndGateway(EndGateway {
@@ -1823,7 +1769,7 @@ mod tests {
}
#[test]
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
fn end_gateway_job_reads_the_block_entity_and_keeps_new_sources_loaded() {
let gateway = [702, 100, 0];
let exit = [8, 100, 0];
for exact in [true, false] {
@@ -1833,18 +1779,14 @@ mod tests {
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
GatewayJob::for_gateway(gateway, Some(&entity)),
GatewayJob::Known { exit, exact },
);
}
let empty = BlockEntity::EndGateway(EndGateway::default());
for entity in [None, Some(&empty)] {
assert_eq!(
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
GatewayJob::Known { exit, exact: false }
);
assert_eq!(
GatewayJob::for_gateway(gateway, entity, None),
GatewayJob::for_gateway(gateway, entity),
GatewayJob::Known {
exit: END_SPAWN,
exact: true
@@ -1856,14 +1798,14 @@ mod tests {
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(nearby, Some(&exact), None),
GatewayJob::for_gateway(nearby, Some(&exact)),
GatewayJob::Find {
gateway: nearby,
exact: true
}
);
assert_eq!(
GatewayJob::for_gateway(nearby, entity, None),
GatewayJob::for_gateway(nearby, entity),
GatewayJob::Find {
gateway: nearby,
exact: false
@@ -0,0 +1,24 @@
//! What the game asks of the loop that runs it.
//!
//! A window's game runs inside winit's event loop, and its methods used to take winit's
//! `ActiveEventLoop` only to stop the loop or to say when to wake next. A headless client
//! (#4951) has no display, so it cannot have that loop: it runs its own, and hands the same
//! methods this trait instead. winit's loop implements it, so a window's game is unchanged.
use winit::event_loop::{ActiveEventLoop, ControlFlow};
pub(super) trait EventLoopCtl {
/// Ends the loop after the current iteration (`ActiveEventLoop::exit`).
fn exit(&self);
/// When the loop wakes next (`ActiveEventLoop::set_control_flow`).
fn set_control_flow(&self, flow: ControlFlow);
}
impl EventLoopCtl for ActiveEventLoop {
fn exit(&self) {
ActiveEventLoop::exit(self);
}
fn set_control_flow(&self, flow: ControlFlow) {
ActiveEventLoop::set_control_flow(self, flow);
}
}
+23 -4
View File
@@ -327,6 +327,9 @@ impl Game<'_> {
/// whole, this push in it, once their server copy has travelled
/// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the
/// packet would add.
///
/// `cause` is the blast's source as predicates see it, read before the
/// guests' turns swap their own combat state in.
pub(super) fn explosion_hurt_player(
&mut self,
center: [f64; 3],
@@ -334,6 +337,7 @@ impl Game<'_> {
damage_type: &str,
exploding: Option<u32>,
attacker: Option<&(u32, Text)>,
cause: Option<&horizon_client::advancements::EntityView>,
) -> Option<[f64; 3]> {
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
@@ -354,7 +358,7 @@ impl Game<'_> {
return None;
}
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
self.adv.marks.blast = Some((feet, self.combat.explosion_cause.clone()));
self.adv.marks.blast = Some((feet, cause.cloned()));
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
@@ -406,6 +410,8 @@ impl Game<'_> {
amount,
blocking,
attacker.is_some(),
// The blast's causing entity.
attacker.map_or(0, |(id, _)| *id as i32),
)
{
outcome = o;
@@ -478,10 +484,18 @@ impl Game<'_> {
};
// The player, then every other player in the level (LAN guests),
// each swapped in as the player. In a guest's turn (their hit on an
// end crystal) the player is that guest.
// end crystal) the player is that guest. The blast's source is the
// same for all of them (`onExplosionHit(this.source)`).
let attacker = self.blast_attacker();
let own =
self.explosion_hurt_player(center, radius, damage_type, exploding, attacker.as_ref());
let blast_source = self.combat.explosion_cause.clone();
let own = self.explosion_hurt_player(
center,
radius,
damage_type,
exploding,
attacker.as_ref(),
blast_source.as_ref(),
);
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
@@ -499,6 +513,7 @@ impl Game<'_> {
damage_type,
exploding,
attacker.as_ref(),
blast_source.as_ref(),
)
});
if let Some(Some(push)) = push {
@@ -565,6 +580,7 @@ impl Game<'_> {
}
self.mobs = mobs;
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
let before = self.dropped_ids();
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
let feet = [item.position.x, item.position.y, item.position.z];
@@ -591,9 +607,11 @@ impl Game<'_> {
true
});
self.dropped = items;
self.native_items_left_since(&before);
// Experience orbs: `ExperienceOrb.hurtServer` takes the damage off
// their 5 health, and `pushFromExplosion` moves the ones that stay
// (a 0.5 x 0.5 box, the eyes at 0.85 of its height).
let orbs_before = self.orb_ids();
let mut orbs = std::mem::take(&mut self.orbs);
orbs.retain_mut(|o| {
let feet = [o.position.x, o.position.y, o.position.z];
@@ -620,6 +638,7 @@ impl Game<'_> {
true
});
self.orbs = orbs;
self.native_orbs_left_since(&orbs_before);
// Primed TNT: knockback only, from its position (not its eyes).
let mut tnts = std::mem::take(&mut self.tnts);
for t in &mut tnts {
+1 -1
View File
@@ -151,7 +151,7 @@ impl Game<'_> {
6.0,
horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Magic,
attacker: Some(horizon_client::mobs::Who::Mob(f.owner)),
attacker: owner.map(horizon_client::mobs::Who::Mob),
from: Some(f.pos),
},
);
+7 -1
View File
@@ -467,7 +467,13 @@ impl Game<'_> {
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(amount, Damage::fireworks(attacker, at));
let hurt = m.damage(amount, Damage::fireworks(attacker, at));
// LivingEntity.hurtServer: a player's rocket makes them the
// last player to hurt it (`setLastHurtByPlayer`), so its death
// message names them and their item.
if hurt && attacker == Some(Who::Player) {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
}
}
+1 -1
View File
@@ -291,7 +291,7 @@ impl Game<'_> {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
Some(
self.res.biomes().list[c.biome(x, y, z) as usize]
world_biomes!(self).list[c.biome(x, y, z) as usize]
.name
.clone(),
)
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more