Merge main into split-block-particles

game.rs: main moved the neighbouring item and hand helpers out (#13856,
#13877); kept main's file and removed only crack_particle and
particle_sprite, as this branch does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-09 16:54:33 +01:00
commit daf537e0d4
293 files changed
+10331 -2691

No files matched your search

+5 -5
View File
@@ -217,17 +217,17 @@ jobs:
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
+7 -2
View File
@@ -168,9 +168,14 @@ jobs:
- name: ALSA and udev headers
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
if: ${{ !matrix.musl }}
run: cp -f "$BIN/horizon-official${{ matrix.exe }}" "$BIN/horizon-client${{ matrix.exe }}"
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
+3 -3
View File
@@ -181,7 +181,7 @@ jobs:
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" "${musl[@]}" \
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -267,9 +267,9 @@ jobs:
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"${musl[@]}" \
${musl[@]+"${musl[@]}"} \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"${arm64[@]}" \
${arm64[@]+"${arm64[@]}"} \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
+7 -2
View File
@@ -25,8 +25,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine uses engine; platform uses engine and platform; Horizon (its extras,
cosmetics, shop, telemetry, launcher) uses platform and Horizon only, never the engine: it moves to its own repository
later. `tools/check_layers.py` checks it. New Horizon code goes behind `horizon-extras`; built-in addresses go in
`horizon-addresses`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
@@ -39,6 +41,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
@@ -75,6 +79,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-hooks`, `horizon-game`, `horizon-official`, `horizon-heartbeat` | The layer seam: the engine's hooks for crates above it, the game an executable starts, the official executable (shipped as `horizon-client`) that registers Horizon's in-game parts, the first of them ([docs/layers.md](docs/layers.md#how-a-horizon-crate-plugs-in)) |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
+15 -4
View File
@@ -45,9 +45,13 @@ and compares their outputs.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Three layers: engine, platform, Horizon.**
- Vanilla code never depends on the platform (mods, shader packs, HDR, the settings system) or on Horizon
(its extras, cosmetics, the shop, official servers, telemetry).
- The platform never depends on Horizon.
- Horizon uses only the platform, never the engine: it will live in its own repository.
[docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -93,7 +97,8 @@ names its sources: the sections of that specification, the documentation pages a
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
default. The game may offer to turn it on when it finds a Java mod; the player decides. It is promoted out of beta
only after MSPT and RAM benchmarks against the Java client with the same mods ([#13914](https://github.com/horizon-rust/horizon/issues/13914)).
## Before asking for review
@@ -113,6 +118,12 @@ including examples. The dependency scope includes optional, platform, build and
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
### Translations
Most of Horizon's translations were written by machine and stay marked unreviewed until someone who speaks the
language checks them. To report a wrong string or review your language, see [docs/translations.md](docs/translations.md)
and #translations on Horizon's Discord. Never mark machine-written text as reviewed.
## Claim before you start
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
Generated
+36 -3
View File
@@ -1450,6 +1450,7 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1500,7 +1501,6 @@ version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
@@ -1529,9 +1529,9 @@ dependencies = [
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1588,7 +1588,6 @@ dependencies = [
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
@@ -1676,6 +1675,15 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-mods",
"horizon-threads",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
@@ -1687,6 +1695,19 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-heartbeat"
version = "0.1.0"
dependencies = [
"horizon-platform",
"horizon-telemetry",
"log",
]
[[package]]
name = "horizon-hooks"
version = "0.1.0"
[[package]]
name = "horizon-items"
version = "0.1.0"
@@ -1767,6 +1788,17 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -1781,6 +1813,7 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-hooks",
"horizon-system",
]
+4
View File
@@ -30,6 +30,10 @@ members = [
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-hooks",
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+3 -2
View File
@@ -129,8 +129,9 @@ ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
and `optical_flow.rs` and `interpolation.rs` follow the SDK's
`sdk/src/components/{opticalflow/ffx_opticalflow.cpp,frameinterpolation/ffx_frameinterpolation.cpp}`
(their passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
+1
View File
@@ -90,6 +90,7 @@ impl Report {
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
+7
View File
@@ -377,6 +377,13 @@ mod tests {
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
let combined = dir.join("machine_mod.hmod");
std::fs::copy(&hnmod, &combined).unwrap();
assert_eq!(
check::kind_of(&combined),
Some(horizon_mods::index::Kind::NativeMod)
);
assert!(check::check(&combined, check::kind_of(&combined).unwrap(), None, None).is_ok());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
+1 -3
View File
@@ -13,11 +13,9 @@ winit = "0.30.13"
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
controllers = []
[lints]
workspace = true
+2 -109
View File
@@ -1523,9 +1523,8 @@ impl Rumble {
}
}
/// What a trigger feels like on a DualSense, from what its action is doing (docs/controllers.md
/// "Adaptive triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the
/// others may leave the firmware.
/// What a trigger feels like, from what its action is doing (docs/controllers.md "Adaptive
/// triggers"); the controller code turns it into a DualSense trigger effect.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TriggerFeel {
Free,
@@ -1544,66 +1543,6 @@ pub enum TriggerFeel {
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
Texture(f32),
}
#[cfg(feature = "controllers")]
impl TriggerFeel {
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
use horizon_dualsense::trigger;
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match self {
TriggerFeel::Free => horizon_dualsense::TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => horizon_dualsense::TRIGGER_OFF,
}
}
}
#[cfg(feature = "controllers")]
impl PadSettings {
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub fn trigger_effects(
&self,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [horizon_dualsense::TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|trigger| {
let actions = self.actions_of(trigger);
let feel = if !self.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
feel.effect(self.trigger_threshold)
})
}
}
/// What a DualSense's lightbar shows (`lightbar:`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lightbar {
@@ -2438,52 +2377,6 @@ bind_key.hotbar.2:north
assert_eq!(w.observe(Some(1.0)), Some(1.0));
}
#[cfg(feature = "controllers")]
#[test]
fn triggers_feel_their_action() {
use horizon_dualsense::{TRIGGER_OFF, trigger};
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = s.trigger_effects(TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
s.trigger_effects(TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(TriggerFeel::Click.effect(0.4), catch(7));
assert_eq!(TriggerFeel::Boost.effect(0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(TriggerFeel::Click.effect(0.7)[1..3], [0xa0, 0x00]);
assert_eq!(TriggerFeel::Click.effect(0.1), trigger::weapon(2, 3, 8));
assert_eq!(TriggerFeel::Brace.effect(0.4), trigger::feedback(2, 3));
assert_eq!(TriggerFeel::Locked.effect(0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
TriggerFeel::Texture(0.5).effect(0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
TriggerFeel::Texture(3.0).effect(0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
TriggerFeel::Texture(50.0).effect(0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(TriggerFeel::Texture(0.0).effect(0.4), TRIGGER_OFF);
assert_eq!(TriggerFeel::Texture(-1.0).effect(0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
s.trigger_effects(TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
#[test]
fn lightbar_colours() {
let full = Lightbar::Health.colour(1.0, "minecraft:overworld").unwrap();
+5 -8
View File
@@ -22,6 +22,8 @@ horizon-command = { path = "../horizon-command" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -73,16 +75,13 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
@@ -114,18 +113,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
default = ["audio", "controllers", "discord", "friends"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
-3
View File
@@ -75,9 +75,6 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
-7
View File
@@ -1,7 +0,0 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
+1 -1
View File
@@ -244,7 +244,7 @@ fn arch_label(build: &str, machine: &str) -> String {
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
pub(crate) fn operating_system() -> String {
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
+30 -139
View File
@@ -194,6 +194,7 @@ mod frame_gen;
mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
@@ -215,6 +216,7 @@ mod occlusion;
mod oit;
mod oit_features;
pub(crate) mod output;
mod output_apply;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
@@ -1526,6 +1528,8 @@ pub struct Renderer {
emission_stats: hdr_stats::EmissionStats,
/// What this renderer's destination offers and its device draws.
output_caps: output::OutputCapabilities,
/// An output that failed to apply, not tried again until something it needs changes.
output_rejected: Option<output_apply::Rejected>,
/// The output applied (`update_output`), with the frame every pipeline that draws it
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
@@ -1538,6 +1542,8 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The window's graphics driver (`hdr_start`); `None` offscreen.
hdr_driver: Option<String>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
@@ -2985,11 +2991,13 @@ impl Renderer {
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
let hdr_driver = surface.is_some().then(|| output::window_driver(&info));
let output_caps = output::OutputCapabilities {
hdr: hdr_output,
wide_frame: wide_ok,
float_frame: float_ok,
hud_layer: coverage_ok,
held: output::held(settings, hdr_driver.as_deref()),
};
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
@@ -5346,10 +5354,12 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
// Nothing is presented as HDR yet: `update_output` applies the settings.
output: output::resolve_output(settings, output_caps, display, frame, false),
frame_kept_logged: false,
hdr_marker: None,
hdr_driver,
pack_post: None,
pack_loading: None,
native: None,
@@ -5518,7 +5528,7 @@ impl Renderer {
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
let format = self.output.frame.format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
@@ -5527,8 +5537,8 @@ impl Renderer {
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
@@ -5631,6 +5641,7 @@ impl Renderer {
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(Arc::new(surface));
self.surface_recreated();
Ok(())
}
/// The world's depth, which occlusion culling's test and post effects may read.
@@ -7086,14 +7097,7 @@ impl Renderer {
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
output_apply::surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
@@ -7149,106 +7153,11 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.output_caps,
self.caps_for(settings),
self.reading,
self.config.format,
)
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.refresh_hdr_offer();
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now: Vulkan's offered formats can change with desktop
/// HDR or the window's output.
fn refresh_hdr_offer(&mut self) {
if let Some(surface) = &self.surface {
self.output_caps.hdr = hdr::output(&surface.get_capabilities(&self.adapter));
}
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
let next = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let [paper_white, peak, black] = next.nits;
if next.encoding.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if next.encoding.is_some() {
if self.surface.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
}
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let frame = self.output.frame;
self.hdr = next.encoding.map(|output| {
hdr::Hdr::new(
&self.device,
&self.queue,
frame.format,
output,
frame.hud_layer,
)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
hdr.black = black;
self.send_hdr_metadata();
}
// An output of another encoding than the one presented doesn't replace it yet, only
// Off does: the snapshot says what is presented.
self.output = output::EffectiveOutput {
encoding: self.hdr.as_ref().map(|hdr| hdr.output),
..next
};
}
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
/// masks. Only with HDR output on: without it the frame's alpha is never read.
pub fn measure_emission(&mut self) {
@@ -7258,24 +7167,6 @@ impl Renderer {
.measure(&self.device, &self.queue, &views, hdr.masks);
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let Some(now) = watch.changed(self.reading, self.surface.as_deref(), &self.adapter) else {
return false;
};
self.refresh_hdr_offer();
self.reading = now;
true
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
@@ -15399,6 +15290,10 @@ struct DisplayWatch {
}
impl DisplayReading {
/// What wgpu reads from the OS for `surface`'s display; nothing without one.
fn read(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Self {
surface.map_or_else(Self::default, |s| Self::os(&s.display_hdr_info(adapter)))
}
fn os(info: &wgpu::DisplayHdrInfo) -> Self {
#[cfg(target_os = "macos")]
let hdr = macos_hdr(info);
@@ -15439,9 +15334,7 @@ impl DisplayWatch {
return (watch, wayland_reading(now));
}
let _ = window;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
let now = DisplayReading::read(surface, adapter);
log_os_reported(now.luminance);
let watch = Self {
#[cfg(target_os = "linux")]
@@ -15450,30 +15343,28 @@ impl DisplayWatch {
};
(watch, now)
}
/// What the system reports, if that changed from `last`.
fn changed(
/// What the system reports at each poll that is due at `time` (`read`; on Wayland, at each
/// event), whether or not it is `last` again: the surface's capabilities are read with it.
fn poll(
&mut self,
last: DisplayReading,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
time: std::time::Instant,
read: impl FnOnce() -> DisplayReading,
) -> Option<DisplayReading> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(wayland_reading);
}
let next = self.polled.as_mut()?;
let time = std::time::Instant::now();
if time < *next {
return None;
}
*next = time + DISPLAY_POLL;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
(now != last).then(|| {
let now = read();
if now != last {
log_os_reported(now.luminance);
now
})
}
Some(now)
}
}
@@ -2,8 +2,8 @@
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
@@ -425,7 +425,7 @@ mod tests {
eprintln!("no GPU: skipping");
return;
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
@@ -441,7 +441,7 @@ mod tests {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
@@ -470,11 +470,11 @@ mod tests {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
let vanilla = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
@@ -3,8 +3,8 @@
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
@@ -325,7 +325,7 @@ mod tests {
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
layout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = ColorModulator;\n}\n";
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
@@ -1,3 +1,25 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
+107
View File
@@ -419,6 +419,26 @@ impl Hdr {
}
}
/// The same HDR output presenting `output` instead (`output_apply`): the same frame and
/// HUD layer, and the player's tuning as it is (HDR Lighting, the world's lighting, the UI
/// brightness, the masks and tiers, the debug view, calibration and the luminance). Its
/// targets are made again on the next frame.
pub fn retargeted(&self, device: &wgpu::Device, queue: &wgpu::Queue, output: Output) -> Self {
Self {
debug: self.debug,
paper_white: self.paper_white,
peak: self.peak,
black: self.black,
tiers: self.tiers,
ui: self.ui,
masks: self.masks,
lighting: self.lighting,
look: self.look,
calibration: self.calibration,
..Self::new(device, queue, self.format, output, self.layered.is_some())
}
}
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
@@ -1541,6 +1561,93 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// HDR output switched from HDR10 to scRGB and back (`output_apply`): the player's tuning
/// stays, each swapchain gets the same light in its own encoding, and the HDR10 capture
/// and the HDR10 picture after the switch back are the ones before it.
#[test]
fn a_switched_encoding_keeps_the_tuning_and_shows_the_same_light() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR encoding switch GPU skipped: no adapter");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
nits: 700.0,
band: 0.6,
});
let tuning = |h: &Hdr| {
(
[h.paper_white, h.peak, h.black, h.ui],
(h.look, h.lighting, h.debug, h.layered.is_some(), h.format),
(h.masks, h.tiers, h.calibration),
)
};
let switched = hdr.retargeted(&device, &queue, Output::ScRgb);
assert_eq!(switched.output, Output::ScRgb);
assert_eq!(tuning(&switched), tuning(&hdr));
hdr.calibration = None;
hdr.debug = false;
// The swapchain's first texel (`capture`: the HDR10 screenshot's) for light 2 times
// paper white, grey, as the layered frame stores it.
let shot = |hdr: &mut Hdr, capture: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let g = 2f64.powf(1.0 / 2.2);
fill(&mut encoder, &frame, [g, g, g, 1.0]);
hdr.begin(&queue, true);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let target = if capture {
hdr.capture(&device, &queue, &mut encoder, true).unwrap()
} else {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
};
if !capture {
hdr.present(&queue, &mut encoder, &target, true, None);
}
texel(&device, &queue, encoder, &target)
};
// BT.2100's PQ EOTF: a 10-bit code's nits.
let pq_nits = |code: f32| {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = (code / 1023.0f32).powf(1.0 / m2);
((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) * 10000.0
};
let pq = shot(&mut hdr, false);
let pq_capture = shot(&mut hdr, true);
let mut scrgb = hdr.retargeted(&device, &queue, Output::ScRgb);
let linear = shot(&mut scrgb, false);
assert_eq!(shot(&mut scrgb, true), pq_capture);
// Two times paper white in both: 500 nits, as HDR10's code and as scRGB's 80-nit units.
let near = |got: f32| (got - 500.0).abs() <= 0.015 * 500.0;
assert!(
near(pq_nits(pq[0])) && pq[..3].iter().all(|&c| c == pq[0]),
"{pq:?}"
);
assert!(near(linear[0] * 80.0), "{linear:?}");
let mut back = scrgb.retargeted(&device, &queue, Output::Pq);
assert_eq!(
(shot(&mut back, false), shot(&mut back, true)),
(pq, pq_capture)
);
}
/// ITU-R BT.2390's black level lift on grey light, in HDR10 and in scRGB, against values
/// worked out apart from the shader, in f64 from SMPTE ST 2084: E3 = E2 + b (1 - E2)^4 on
/// the PQ signal, for a source from 0 to the peak and b the black level's share of it.
@@ -0,0 +1,65 @@
//! A window's HDR output start and what a failed one leaves (`graphics::HDR_MARKER`). Turning
//! HDR output on writes a marker naming the window's graphics driver (`output::window_driver`);
//! the first frame presented, turning HDR output off and a clean exit remove it. A start that
//! finds it holds HDR output back on that driver (`output::held`) until a mode is chosen on the
//! HDR page or the driver changes. Offscreen renderers have no driver: they never write the
//! marker or hold HDR output back.
use super::{Renderer, output};
impl Renderer {
/// What this renderer's destination offers for `settings`: HDR output held back on a window
/// whose graphics driver a start failed on (`output::held`).
pub(super) fn caps_for(
&self,
settings: &crate::graphics::Graphics,
) -> output::OutputCapabilities {
output::OutputCapabilities {
held: output::held(settings, self.hdr_driver.as_deref()),
..self.output_caps
}
}
/// Takes the hold `settings` put on this window's driver into the capabilities, and says it
/// once per start.
pub(super) fn hold_hdr_back(&mut self, settings: &crate::graphics::Graphics) {
self.output_caps = self.caps_for(settings);
if self.output_caps.held
&& let Some(driver) = &self.hdr_driver
{
static LOGGED: std::sync::Once = std::sync::Once::new();
LOGGED.call_once(|| {
log::warn!(
"Horizon renderer: HDR output is held back: the last start with this graphics driver ({driver}) ended before HDR output showed a frame. Choose an HDR mode on the HDR page to try again (or remove hdrFailedStart from {})",
crate::graphics::FILE
)
});
}
}
/// Writes the marker at `marker` as a window's HDR output turns on (`on`), and removes this
/// renderer's marker as it turns off.
pub(super) fn mark_hdr_start(&mut self, on: bool, marker: &std::path::Path) {
if !on {
self.drop_hdr_marker();
} else if self.surface.is_some()
&& let Some(driver) = &self.hdr_driver
{
let _ = std::fs::write(marker, crate::graphics::hdr_marker_text(driver));
self.hdr_marker = Some(marker.to_owned());
}
}
/// The graphics driver this window's HDR output starts on (`output::window_driver`);
/// `None` offscreen.
pub fn hdr_driver(&self) -> Option<&str> {
self.hdr_driver.as_deref()
}
/// Removes this renderer's marker of an HDR output that hasn't presented a frame yet, for a
/// clean exit: only a crash may leave it.
pub fn drop_hdr_marker(&mut self) {
if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
}
}
@@ -2677,7 +2677,8 @@ file = \"b.wgsl\"
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
/// alone, held to the headroom. In SDR Horizon's is a clip at white. An HDR output switched
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
@@ -2687,8 +2688,9 @@ file = \"b.wgsl\"
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR, the
// output switched).
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
@@ -2746,6 +2748,11 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
display = display.retargeted(&device, &queue, Pq);
}
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -2813,14 +2820,16 @@ file = \"b.wgsl\"
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
for switched in [false, true] {
let horizon = shown(false, true, switched);
assert!(near(horizon, 4.82043), "{switched}: {horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true, switched);
assert!(near(pack, headroom), "{switched}: {pack}");
}
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
let sdr = shown(pack_curve, false, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
@@ -11,9 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
/// The hooks' trait and dispatch, made from `native-hooks.json` by `cargo run -p xtask -- mod-api`;
/// the game's build checks that the file is current.
mod generated;
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
@@ -0,0 +1,71 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API 364.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
///
/// Every mod gets `event.warp` in order and may add, change or remove warp.
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
/// What [`RenderHooks::world_warp`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldWarpEvent<'a> {
pub warp: &'a mut WorldWarp,
}
pub fn dispatch_world_warp(mods: &[&dyn RenderHooks], warp: &mut WorldWarp) {
let mut event = WorldWarpEvent { warp };
for mod_hook in mods {
mod_hook.world_warp(&mut event);
}
}
+163 -4
View File
@@ -18,6 +18,43 @@ pub(crate) struct OutputCapabilities {
pub float_frame: bool,
/// The device keeps the HUD on a layer of its own (`hud_coverage::supported`).
pub hud_layer: bool,
/// HDR output is held back: the last start on this window's graphics driver ended before
/// HDR output showed a frame ([`held`]). The mode stays; the output is SDR.
pub held: bool,
}
/// Whether HDR output is held back on a renderer whose window's graphics driver is `driver`
/// (`None` offscreen, which never holds it back: only a window writes the marker).
pub(crate) fn held(settings: &Graphics, driver: Option<&str>) -> bool {
driver.is_some_and(|driver| settings.hdr_held(driver))
}
/// The graphics driver this window's HDR output starts on ([`driver_fingerprint`]), with the
/// system's version as crash reports name it (`crash::operating_system`), read once a run.
pub(crate) fn window_driver(info: &wgpu::AdapterInfo) -> String {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
driver_fingerprint(info, OS.get_or_init(crate::crash::operating_system))
}
/// The graphics driver a window's HDR output starts on, in one line: the backend, the
/// adapter's vendor and device IDs and its name, the driver's name and its version, and the
/// system's version `os`. Another GPU, a driver update or a system update reads differently:
/// Metal names no driver, so on macOS the driver updates with the system.
pub fn driver_fingerprint(info: &wgpu::AdapterInfo, os: &str) -> String {
let adapter = format!(
"{:?} {:04x}:{:04x} {}",
info.backend, info.vendor, info.device, info.name
);
[adapter.as_str(), &info.driver, &info.driver_info, os]
.map(|field| {
field
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.trim()
.to_owned()
})
.join("; ")
}
/// What the system last reported for a renderer's display: its luminance, and whether it
@@ -97,6 +134,8 @@ pub struct EffectiveOutput {
pub(crate) reported: Option<Reported>,
/// The device draws the 16-bit frame (Smooth Gradients).
pub(crate) wide_frame_supported: bool,
/// HDR output is held back on this window's graphics driver (`OutputCapabilities::held`).
pub(crate) held: bool,
/// The HDR output presented; `None` is SDR.
pub(crate) encoding: Option<Output>,
pub(crate) frame: FrameConfiguration,
@@ -118,6 +157,7 @@ impl EffectiveOutput {
system_hdr: None,
reported: None,
wide_frame_supported: false,
held: false,
encoding: None,
frame: FrameConfiguration {
format: wgpu::TextureFormat::Bgra8Unorm,
@@ -136,9 +176,10 @@ impl EffectiveOutput {
self.encoding.is_some()
}
/// Whether `mode` asks for HDR here: On always, Auto where the destination offers it and
/// the display reports HDR. The output still needs an offer and a frame that can take it.
/// the display reports HDR, neither while HDR output is held back. The output still needs
/// an offer and a frame that can take it.
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
requests_hdr(mode, self.offered.is_some(), self.system_hdr)
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
}
/// What a 0.2 pack's `Frame` describes: the HDR display on the layered frame, whose
/// picture is light; SDR otherwise, where the frame holds SDR's picture, the one HDR
@@ -169,6 +210,10 @@ fn requests_hdr(mode: HdrMode, offered: bool, system: Option<bool>) -> bool {
HdrMode::Auto => offered && system == Some(true),
}
}
/// Whether `settings` ask for HDR on a destination of `caps` whose display reports `reading`.
fn wanted(settings: &Graphics, caps: OutputCapabilities, reading: DisplayReading) -> bool {
!caps.held && requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr)
}
/// The frame a renderer made now draws in, for `settings`, the pack's `request`, what the
/// destination and device can do (`caps`) and what the display reports.
@@ -179,7 +224,7 @@ pub(crate) fn resolve_frame(
reading: DisplayReading,
swapchain: wgpu::TextureFormat,
) -> FrameConfiguration {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
let (format, hud_layer) = super::frame_configuration(
wanted && settings.hdr_smooth_gradients,
(
@@ -210,12 +255,13 @@ pub(crate) fn resolve_output(
frame: FrameConfiguration,
presents: bool,
) -> EffectiveOutput {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
EffectiveOutput {
offered: caps.hdr,
system_hdr: reading.hdr,
reported: reading.luminance,
wide_frame_supported: caps.wide_frame,
held: caps.held,
encoding: caps
.hdr
.filter(|_| wanted && presents && frame.overlay_format == frame.format),
@@ -247,6 +293,7 @@ pub(crate) mod tests {
wide_frame: true,
float_frame: true,
hud_layer: true,
held: false,
},
DisplayReading {
luminance: Some(Reported {
@@ -378,6 +425,118 @@ pub(crate) mod tests {
assert_eq!(settings.hdr, HdrMode::On);
}
/// A window on the driver a start failed on stays SDR, on an SDR frame, whatever the
/// mode, and the mode stays as chosen; another driver tries HDR again, and an offscreen
/// renderer (no driver) never holds HDR back.
#[test]
fn a_failed_start_holds_hdr_back_on_its_driver_only() {
let failed = "Vulkan 10de:2684 GPU; driver; 1.0";
for mode in [HdrMode::On, HdrMode::Auto] {
let settings = Graphics {
hdr: mode,
hdr_failed_start: Some(failed.into()),
..Graphics::default()
};
let request = FrameRequest {
linear_pack: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
let (mut caps, reading) = window(Some(Output::Pq), Some(true));
caps.held = held(&settings, driver);
snapshot(&settings, request, caps, reading)
};
let out = on(Some(failed));
assert_eq!(out.encoding, None, "{mode:?}");
assert_eq!(out.frame.format, Bgra8Unorm, "{mode:?}");
assert!(!out.frame.hud_layer && out.held && !out.requests_hdr(mode));
// Still offered: the page lets the player pick a mode to try again.
assert!(out.hdr_offered());
assert_eq!(settings.hdr, mode);
for driver in [Some("Vulkan 10de:2684 GPU; driver; 1.1"), None] {
let out = on(driver);
assert_eq!(out.encoding, Some(Output::Pq), "{mode:?} {driver:?}");
assert_eq!(out.frame.format, LINEAR_FRAME, "{mode:?} {driver:?}");
assert!(!out.held && out.requests_hdr(mode));
}
}
// A marker that named no driver holds on whichever window comes first.
let legacy = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(String::new()),
..Graphics::default()
};
assert!(held(&legacy, Some(failed)));
assert!(!held(&legacy, None));
}
/// The fingerprint tells another GPU, backend, driver version or system version apart, in
/// one line.
#[test]
fn the_driver_fingerprint_is_one_line_that_changes_with_the_driver() {
let os = "windows (x86_64) version Windows 11 (26200.7019)";
let info = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 4090".into(),
vendor: 0x10de,
device: 0x2684,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
driver: "NVIDIA".into(),
driver_info: "581.57\n".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 32,
subgroup_max_size: 32,
transient_saves_memory: None,
limit_bucket: None,
};
let fingerprint = driver_fingerprint(&info, os);
assert_eq!(
fingerprint,
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57; \
windows (x86_64) version Windows 11 (26200.7019)"
);
assert_ne!(
driver_fingerprint(&info, "windows (x86_64) version Windows 11 (26200.7171)"),
fingerprint
);
// Metal names no driver: a macOS update is what changes it.
let metal = wgpu::AdapterInfo {
name: "Apple M3".into(),
vendor: 0,
device: 0,
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Metal,
..info.clone()
};
let sonoma = driver_fingerprint(&metal, "macos (aarch64) version macOS 14.6");
assert_eq!(
sonoma,
"Metal 0000:0000 Apple M3; ; ; macos (aarch64) version macOS 14.6"
);
assert_ne!(
driver_fingerprint(&metal, "macos (aarch64) version macOS 15.1"),
sonoma
);
let other = [
wgpu::AdapterInfo {
driver_info: "582.02".into(),
..info.clone()
},
wgpu::AdapterInfo {
backend: wgpu::Backend::Dx12,
..info.clone()
},
wgpu::AdapterInfo {
device: 0x2704,
..info.clone()
},
];
for other in other {
assert_ne!(driver_fingerprint(&other, os), fingerprint, "{other:?}");
}
}
/// Auto's paper white and peak are this display's report; the player's own values win.
#[test]
fn auto_levels_follow_this_renderers_report() {
@@ -0,0 +1,532 @@
//! Applying the output a renderer resolves (`output::resolve_output`). Each observation of the
//! settings, the destination's capabilities and the display resolves one candidate. One that
//! changes only values (the luminance, the UI brightness, what the display reports) updates
//! them in place. Another encoding or frame is built and the swapchain configured for it before
//! the snapshot says so. One that fails is never published as active, and is not tried again
//! until the request, the capabilities or the surface change.
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use output::{EffectiveOutput, OutputCapabilities};
/// What an output's resources are made for: the encoding presented and the frame.
type Key = (Option<hdr::Output>, output::FrameConfiguration);
fn key(output: &EffectiveOutput) -> Key {
(output.encoding, output.frame)
}
/// An output whose resources or swapchain configuration failed, with the capabilities it
/// failed on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rejected {
key: Key,
caps: OutputCapabilities,
}
/// What applying an output changes in a renderer.
trait Present {
/// Builds what `output` presents and configures the swapchain for it, then swaps the new
/// resources in. On an error the resources stay as they were, and the swapchain may be
/// configured for neither output.
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String>;
/// Presents SDR whatever happens: a swapchain that doesn't configure now is configured
/// again before the next frame (`surface_unconfigured`).
fn present_sdr(&mut self);
}
/// The snapshot to publish for `candidate` after `applied`, on `caps`. The same resources
/// take the candidate's values as they are. Other ones are presented first and published only
/// once that worked. On a failure the output applied stays where it presents again, else
/// SDR, and the failed one is kept in `rejected` until the candidate's resources or the
/// capabilities change.
fn commit(
target: &mut impl Present,
applied: &EffectiveOutput,
candidate: EffectiveOutput,
caps: OutputCapabilities,
rejected: &mut Option<Rejected>,
) -> EffectiveOutput {
let wanted = key(&candidate);
if rejected.is_some_and(|r| r.key != wanted || r.caps != caps) {
*rejected = None;
}
if wanted == key(applied) {
return candidate;
}
let kept = EffectiveOutput {
encoding: applied.encoding,
..candidate
};
if rejected.is_some() {
return kept;
}
let Err(e) = target.present(&candidate) else {
return candidate;
};
log::warn!(
"Horizon renderer: {} could not be presented ({e}); keeping {}",
name(candidate.encoding),
name(applied.encoding)
);
*rejected = Some(Rejected { key: wanted, caps });
if target.present(&kept).is_ok() {
return kept;
}
log::warn!(
"Horizon renderer: {} no longer presents; SDR",
name(kept.encoding)
);
target.present_sdr();
EffectiveOutput {
encoding: None,
..candidate
}
}
fn name(encoding: Option<hdr::Output>) -> &'static str {
encoding.map_or("SDR", hdr::Output::name)
}
/// The swapchain's configuration for `encoding`: `config` (what every pipeline draws in), or
/// HDR10 or scRGB.
pub(super) fn surface_config(
config: &wgpu::SurfaceConfiguration,
encoding: Option<hdr::Output>,
) -> wgpu::SurfaceConfiguration {
let mut config = config.clone();
if let Some(output) = encoding {
config.format = output.format();
config.view_formats = vec![output.format()];
config.color_space = output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
/// alone included.
fn observe(
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
now: DisplayReading,
offer: Option<Option<hdr::Output>>,
) -> bool {
let before = (*reading, caps.hdr);
*reading = now;
if let Some(offer) = offer {
caps.hdr = offer;
}
before != (*reading, caps.hdr)
}
/// A renderer applying an output, with the marker its HDR output's start writes.
struct Applying<'a> {
renderer: &'a mut Renderer,
marker: &'a std::path::Path,
}
impl Present for Applying<'_> {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
let r = &mut *self.renderer;
let current = r.hdr.as_ref().map(|hdr| hdr.output);
let switching = output.encoding.is_some_and(|e| current != Some(e));
// If the game dies before HDR output shows a frame, its pipelines' build included, the
// marker is still there at the next start, which then holds HDR output back on this
// driver.
if output.encoding.is_none() || switching {
r.mark_hdr_start(output.encoding.is_some(), self.marker);
}
let built = match output.encoding {
Some(encoding) if switching => Some(r.build_hdr(encoding, output)?),
_ => None,
};
if let Some(surface) = &r.surface {
r.frame_gen.stop();
// The submit thread may still present to the old swapchain.
r.queue.wait();
let hdr = built.as_ref().or(r.hdr.as_ref());
Renderer::configure(
&r.device,
surface,
&surface_config(&r.config, output.encoding),
hdr.filter(|_| output.encoding.is_some()),
&r.device_lost,
)?;
r.surface_unconfigured = false;
}
if output.encoding.is_none() {
r.hdr = None;
} else if built.is_some() {
r.hdr = built;
}
Ok(())
}
fn present_sdr(&mut self) {
let r = &mut *self.renderer;
r.hdr = None;
r.mark_hdr_start(false, self.marker);
if let Some(surface) = &r.surface {
r.frame_gen.stop();
r.queue.wait();
r.surface_unconfigured =
Renderer::configure(&r.device, surface, &r.config, None, &r.device_lost).is_err();
}
}
}
impl Renderer {
/// HDR output presenting `encoding` for `output`, made apart from the one presented: from
/// it where there is one, keeping the player's tuning (`Hdr::retargeted`). An error is
/// the device's, caught here rather than left for the next frame.
fn build_hdr(
&self,
encoding: hdr::Output,
output: &EffectiveOutput,
) -> std::result::Result<hdr::Hdr, String> {
let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let mut hdr = match &self.hdr {
Some(hdr) => hdr.retargeted(&self.device, &self.queue, encoding),
None => {
let mut hdr = hdr::Hdr::new(
&self.device,
&self.queue,
output.frame.format,
encoding,
output.frame.hud_layer,
);
hdr.look = self.hdr_lighting_setting;
hdr
}
};
let memory = pollster::block_on(memory.pop());
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
return Err(e.to_string());
}
// The metadata the swapchain's configuration sends.
[hdr.paper_white, hdr.peak, hdr.black] = output.nits;
hdr.ui = output.ui_brightness;
Ok(hdr)
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
self.hold_hdr_back(settings);
let candidate = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let (applied, caps) = (self.output, self.output_caps);
let mut rejected = self.output_rejected.take();
let renderer = &mut Applying {
renderer: self,
marker,
};
let next = commit(renderer, &applied, candidate, caps, &mut rejected);
self.output_rejected = rejected;
let [paper_white, peak, black] = next.nits;
if next.encoding != applied.encoding {
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
if (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black) {
(hdr.paper_white, hdr.peak, hdr.black) = (paper_white, peak, black);
self.send_hdr_metadata();
}
}
self.output = next;
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now (`None` offscreen): Vulkan's offered formats can
/// change with desktop HDR or the window's output, and a display can offer the other
/// encoding.
fn surface_offer(&self) -> Option<Option<hdr::Output>> {
let surface = self.surface.as_ref()?;
Some(hdr::output(&surface.get_capabilities(&self.adapter)))
}
/// After a new window surface: what it offers, and an output that failed on the old one
/// may be tried again. The settings apply again.
pub(super) fn surface_recreated(&mut self) {
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
self.display_fresh = true;
self.output_rejected = None;
}
/// Whether what the system reports for the display, or what the surface offers, changed
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
/// encoding): the settings' Auto values and the output follow it then, so apply them
/// again. Each due poll reads the surface's capabilities as well, even when the display
/// reports the same.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
let read = || DisplayReading::read(surface, adapter);
let Some(now) = watch.poll(self.reading, std::time::Instant::now(), read) else {
return false;
};
let offer = self.surface_offer();
observe(&mut self.reading, &mut self.output_caps, now, offer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::{Graphics, HdrMode, Reported};
use hdr::Output::{Pq, ScRgb};
use output::FrameRequest;
use output::tests::{snapshot, window};
/// A swapchain that records what it was asked to present, and fails `fails`.
#[derive(Default)]
struct Swapchain {
fails: Vec<Option<hdr::Output>>,
/// Each encoding presented, with the luminance its metadata sends.
asked: Vec<(Option<hdr::Output>, [f32; 3])>,
sdr: usize,
}
impl Present for Swapchain {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
self.asked.push((output.encoding, output.nits));
if self.fails.contains(&output.encoding) {
return Err("unsupported".into());
}
Ok(())
}
fn present_sdr(&mut self) {
self.sdr += 1;
}
}
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
hdr_float: true,
};
fn on() -> Graphics {
Graphics {
hdr: HdrMode::On,
..Graphics::default()
}
}
/// The candidate on a renderer whose frame is `applied`'s.
fn candidate(
settings: &Graphics,
caps: OutputCapabilities,
reading: DisplayReading,
applied: &EffectiveOutput,
) -> EffectiveOutput {
output::resolve_output(settings, caps, reading, applied.frame, true)
}
/// HDR stays on while the display offers scRGB instead of HDR10 and then HDR10 again:
/// the swapchain, its metadata and the snapshot follow each, with no Off between.
#[test]
fn the_output_follows_pq_to_scrgb_and_back_while_hdr_stays_on() {
let settings = on();
let (mut caps, reading) = window(Some(Pq), Some(true));
let mut applied = snapshot(&settings, NO_PACK, caps, reading);
assert_eq!(applied.encoding, Some(Pq));
let (mut swapchain, mut rejected) = (Swapchain::default(), None);
for encoding in [ScRgb, Pq] {
caps.hdr = Some(encoding);
let next = candidate(&settings, caps, reading, &applied);
applied = commit(&mut swapchain, &applied, next, caps, &mut rejected);
assert_eq!(
(applied.encoding, applied.offered),
(Some(encoding), Some(encoding))
);
assert_eq!(
applied.frame,
snapshot(&settings, NO_PACK, caps, reading).frame
);
}
let metadata = [400.0, 1000.0, 0.0];
assert_eq!(
swapchain.asked,
[(Some(ScRgb), metadata), (Some(Pq), metadata)]
);
assert_eq!((rejected, swapchain.sdr), (None, 0));
}
/// The display reports the same luminance and HDR, but the surface now offers scRGB: the
/// next due poll reads it, and the output follows with the same nits and report.
#[test]
fn a_change_of_offer_alone_is_seen_at_the_next_poll() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
let start = std::time::Instant::now();
let mut watch = DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: Some(start + super::super::DISPLAY_POLL),
};
let same = reading;
assert_eq!(watch.poll(reading, start, || unreachable!()), None);
let due = start + super::super::DISPLAY_POLL;
assert_eq!(watch.poll(reading, due, || same), Some(same));
assert_eq!(watch.polled, Some(due + super::super::DISPLAY_POLL));
assert!(observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
// Nothing changed at the next poll, and offscreen nothing is offered to read.
assert!(!observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
assert!(!observe(&mut reading, &mut caps, same, None));
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut Swapchain::default(), &applied, next, caps, &mut None);
assert_eq!(published.encoding, Some(ScRgb));
assert_eq!(
(published.nits, published.reported, published.system_hdr),
(applied.nits, applied.reported, applied.system_hdr)
);
}
/// The display's luminance or the UI brightness change on the same output: the values
/// follow, and nothing is built or configured again.
#[test]
fn new_nits_on_the_same_output_build_nothing() {
let mut settings = on();
let (caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
});
settings.hdr_ui_brightness = 60;
let mut swapchain = Swapchain::default();
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
assert_eq!(published, next);
assert_eq!(published.nits, [250.0, 600.0, 0.0]);
assert_eq!(published.ui_brightness, 0.6);
assert!(swapchain.asked.is_empty());
}
/// An encoding that fails is never the snapshot's: the output applied stays where it
/// presents again, else SDR. It is not tried again each frame, nor for new nits, only
/// once the request or the capabilities change.
#[test]
fn an_output_that_fails_is_never_active_nor_tried_every_frame() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let pq = snapshot(&settings, NO_PACK, caps, reading);
caps.hdr = Some(ScRgb);
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(published.encoding, Some(Pq));
assert_eq!(swapchain.asked.len(), 2, "scRGB, then HDR10 again");
// Frames after, and a new luminance: no new attempt.
for paper_white in [400, 400, 300] {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
assert_eq!(again.encoding, Some(Pq));
assert_eq!(again.nits[0], paper_white as f32);
}
assert_eq!(swapchain.asked.len(), 2);
// Another request (HDR Off, then On) tries it again.
let off = Graphics::default();
let sdr = commit(
&mut swapchain,
&published,
candidate(&off, caps, reading, &published),
caps,
&mut rejected,
);
assert_eq!((sdr.encoding, rejected), (None, None));
let next = candidate(&settings, caps, reading, &sdr);
assert_eq!(
commit(&mut swapchain, &sdr, next, caps, &mut rejected).encoding,
None
);
assert_eq!(
swapchain.asked.iter().map(|a| a.0).collect::<Vec<_>>(),
[Some(ScRgb), Some(Pq), None, Some(ScRgb), None]
);
// So does another capability (the hold lifted here stands for any).
caps.held = true;
let held = candidate(&settings, caps, reading, &sdr);
assert_eq!(held.encoding, None);
commit(&mut swapchain, &sdr, held, caps, &mut rejected);
caps.held = false;
let asked = swapchain.asked.len();
let next = candidate(&settings, caps, reading, &sdr);
commit(&mut swapchain, &sdr, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), asked + 2);
// The output applied failing too: SDR, never the failed one.
let mut broken = Swapchain {
fails: vec![Some(ScRgb), Some(Pq)],
..Default::default()
};
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut broken, &pq, next, caps, &mut None);
assert_eq!((published.encoding, broken.sdr), (None, 1));
assert_eq!(settings.hdr, HdrMode::On, "the mode stays");
// A new surface forgets the failure (`surface_recreated`): the same request on the
// same capabilities is tried again.
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
for _ in 0..2 {
commit(&mut swapchain, &pq, next, caps, &mut rejected);
}
assert_eq!(swapchain.asked.len(), 2);
rejected = None;
commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), 4);
}
}
@@ -50,6 +50,98 @@ struct TaaResources {
uniform: wgpu::Buffer,
}
/// One frame handed to an upscaler backend: the jittered world at `render_size` and what
/// it needs to rebuild it at `output_size`. The motion is the camera's alone (`reproject`);
/// objects that move on their own carry no motion vectors yet.
// Only TAA reads it so far; a vendor backend reads the rest (#12918).
#[allow(dead_code)]
pub(super) struct UpscaleFrame<'a> {
/// The world's colour, rendered with this frame's jitter.
pub color: &'a wgpu::TextureView,
/// The world's depth, at `render_size`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
pub output_size: [u32; 2],
/// The jitter in clip space, as added to the projection.
pub jitter_clip: [f32; 2],
/// The same jitter in rendered pixels, y down.
pub jitter_pixels: [f32; 2],
/// This frame's unjittered clip space to the previous frame's.
pub reproject: [[f32; 4]; 4],
/// The history is unusable: a cut, a resize, a frame without the upscaler.
pub reset: bool,
/// Frames jittered so far.
pub index: u32,
}
/// Something that turns the jittered low-resolution world into the output picture.
/// TAA (`TaaResources`) is the only backend; `Target` is the per-size state a backend
/// keeps beside the world target (TAA's history textures).
pub(super) trait UpscalerBackend {
type Target;
/// The name its GPU timing is shown under.
fn name(&self) -> &'static str;
/// Write this frame's output and return the bind group that shows it (`draw_onto`).
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
target: &'t Self::Target,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup;
}
impl UpscalerBackend for TaaResources {
type Target = History;
fn name(&self) -> &'static str {
"TAA"
}
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
taa: &'t History,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup {
// The resolve bind group already samples `frame.color` and `frame.depth`.
let i = (frame.index % 2) as usize;
let uniform = TaaUniform {
reproject: frame.reproject,
jitter: [
frame.jitter_clip[0],
frame.jitter_clip[1],
if frame.reset { 1.0 } else { 0.0 },
0.0,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.and_then(|t| t.writes(self.name())),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
&taa.show[i]
}
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
@@ -563,33 +655,25 @@ impl Post {
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
let size = self.size().unwrap_or([1, 1]);
let frame = UpscaleFrame {
color: &target.color,
depth: &target.depth,
render_size: size,
output_size: taa.size,
jitter_clip: [self.taa_this_frame.jitter[0], self.taa_this_frame.jitter[1]],
jitter_pixels: self.jitter_pixels(),
reproject: self.taa_this_frame.reproject,
reset: self.taa_this_frame.jitter[2] > 0.5,
index: self.taa_frame,
};
let shown = res.resolve(queue, encoder, &frame, taa, timing.as_deref_mut());
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
self.draw_onto(encoder, screen, shown, pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
+155 -5
View File
@@ -5,9 +5,23 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
/// Written beside it before a window's HDR output is switched on, naming the graphics driver it
/// starts on ([`hdr_marker_text`]), and removed once the first HDR frame is presented. Found at
/// the next start, it means HDR output took the game down there: HDR output is held back on
/// that driver ([`Graphics::hdr_failed_start`]), and the mode stays as the player chose it.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// The marker's text for a window whose HDR output starts on `driver`
/// (`gfx::output::window_driver`).
pub fn hdr_marker_text(driver: &str) -> String {
format!("driver:{driver}\n")
}
/// The driver a marker names; empty for one written before markers named it.
fn marker_driver(text: &str) -> &str {
text.lines()
.find_map(|line| line.strip_prefix("driver:"))
.unwrap_or_default()
.trim()
}
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak. Each renderer keeps its
@@ -809,6 +823,11 @@ pub struct Graphics {
pub hdr_lighting: bool,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// The graphics driver whose last start ended before HDR output showed a frame (the
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
/// Empty for a marker that named no driver: the first one seen holds it.
pub hdr_failed_start: Option<String>,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -884,6 +903,7 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_lighting: true,
hdr_key: "key.keyboard.unknown".into(),
hdr_failed_start: None,
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
@@ -909,15 +929,50 @@ impl Graphics {
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
let marker = dir.join(HDR_MARKER);
if let Ok(text) = fs::read_to_string(&marker)
&& fs::remove_file(&marker).is_ok()
&& g.hdr != HdrMode::Off
{
g.hdr_failed_start = Some(marker_driver(&text).to_owned());
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
"the last start ended before HDR output showed a frame; HDR output stays off with that graphics driver until the HDR mode is changed (graphics option hdrFailedStart, in {FILE})"
);
}
g
}
/// Whether HDR output is held back on a window whose graphics driver is `driver`
/// (`gfx::output::window_driver`): the last start on it ended before HDR output
/// showed a frame ([`Graphics::hdr_failed_start`]).
pub fn hdr_held(&self, driver: &str) -> bool {
self.hdr_failed_start
.as_deref()
.is_some_and(|failed| failed.is_empty() || failed == driver)
}
/// The window's renderer runs on `driver`: a failed start that named no driver is this
/// one's now, and another driver's (a driver update, another GPU) is dropped, so HDR output
/// is tried again. True when it changed.
pub fn settle_failed_start(&mut self, driver: &str) -> bool {
match self.hdr_failed_start.as_deref() {
Some("") => self.hdr_failed_start = Some(driver.to_owned()),
Some(failed) if failed != driver => self.hdr_failed_start = None,
_ => return false,
}
true
}
/// `before`, as Undo brings it back, but never the hold on a driver HDR output failed to
/// start on: that stays as it is now, and goes when Undo changes the mode, as any change of
/// the mode drops it.
pub fn undone_to(&self, before: &Self) -> Self {
Self {
hdr_failed_start: self
.hdr_failed_start
.clone()
.filter(|_| self.hdr == before.hdr),
..before.clone()
}
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
for line in text.lines() {
@@ -1026,6 +1081,7 @@ impl Graphics {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
@@ -1104,6 +1160,9 @@ impl Graphics {
bool_text(self.hdr_smooth_gradients),
self.gpu_culling.key(),
);
if let Some(driver) = &self.hdr_failed_start {
text += &format!("hdrFailedStart:{driver}\n");
}
text += &self.blue_map.serialize();
text
}
@@ -1243,6 +1302,85 @@ mod tests {
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// A start that finds the marker (HDR output took the last one down) keeps the mode the
/// player chose and saves the driver the marker names; HDR Off saves nothing.
#[test]
fn a_marker_keeps_the_mode_and_holds_the_driver_it_names() {
let driver = "Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57";
for mode in HdrMode::ALL {
let dir = scratch(&format!("hdr-marker-{mode:?}"));
let saved = Graphics {
hdr: mode,
..Graphics::default()
};
saved.save(&dir);
fs::write(dir.join(HDR_MARKER), hdr_marker_text(driver)).unwrap();
let g = Graphics::load(&dir);
let failed = (mode != HdrMode::Off).then(|| driver.to_owned());
assert_eq!((g.hdr, &g.hdr_failed_start), (mode, &failed), "{mode:?}");
assert!(!dir.join(HDR_MARKER).exists(), "{mode:?}");
// Saved for good: the next start, without a marker, reads the same.
let again = Graphics::load(&dir);
assert_eq!((again.hdr, again.hdr_failed_start), (mode, failed));
let _ = fs::remove_dir_all(&dir);
}
}
/// Today's marker names no driver: it holds whichever driver the window starts on first,
/// and is that driver's from then on.
#[test]
fn a_marker_without_a_driver_holds_the_first_one_seen() {
let dir = scratch("hdr-marker-legacy");
Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
}
.save(&dir);
fs::write(dir.join(HDR_MARKER), "HDR output was being switched on\n").unwrap();
let mut g = Graphics::load(&dir);
assert_eq!(
(g.hdr, g.hdr_failed_start.as_deref()),
(HdrMode::Auto, Some(""))
);
let (first, second) = (
"Vulkan 10de:2684 GPU; NVIDIA; 1",
"Dx12 1002:744c GPU; AMD; 2",
);
assert!(g.hdr_held(first) && g.hdr_held(second));
assert!(g.settle_failed_start(first));
assert!(g.hdr_held(first) && !g.hdr_held(second));
assert!(!g.settle_failed_start(first));
let _ = fs::remove_dir_all(&dir);
}
/// The window starting on another driver (an update, another GPU) drops the hold, so HDR
/// output is tried again; on the same driver it stays.
#[test]
fn another_driver_drops_the_hold() {
let held = "Vulkan 10de:2684 GPU; NVIDIA; 581.57";
let mut g = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(held.into()),
..Graphics::default()
};
assert!(!g.settle_failed_start(held));
assert!(g.hdr_held(held));
assert!(g.settle_failed_start("Vulkan 10de:2684 GPU; NVIDIA; 582.02"));
assert_eq!((g.hdr, g.hdr_failed_start), (HdrMode::On, None));
assert!(!Graphics::default().hdr_held(held));
}
#[test]
fn round_trips() {
let g = Graphics {
@@ -1295,6 +1433,9 @@ mod tests {
hdr_ui_brightness: 60,
hdr_lighting: false,
hdr_key: "key.keyboard.h".into(),
hdr_failed_start: Some(
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
@@ -1303,6 +1444,15 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
// A legacy marker's hold, which names no driver, round-trips too; none writes no line.
for failed in [Some(String::new()), None] {
let graphics = Graphics {
hdr_failed_start: failed,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert!(!Graphics::default().serialize().contains("hdrFailedStart"));
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
@@ -708,6 +708,10 @@ fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
Setup::Changed
}
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
const HDR_HELD: &str = "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.";
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(
item: Item,
@@ -716,6 +720,7 @@ fn description(
text: &'static str,
) -> &'static str {
match (item, setup(s, out)) {
(Item::Horizon(H::Hdr), _) if out.held => HDR_HELD,
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
@@ -1086,7 +1091,9 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
// Any choice tries HDR output again on a driver a start failed on.
H::Hdr => {
g.hdr_failed_start = None;
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !out.hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
@@ -1155,7 +1162,11 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
HDR_HELD
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1522,7 +1533,7 @@ impl Screen for GraphicsScreen {
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = b.horizon.clone();
s.horizon = s.horizon.undone_to(&b.horizon);
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
s.gamma = b.gamma;
@@ -134,3 +134,68 @@ fn hdr_setup_follows_what_the_system_reported() {
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
/// the mode; any change of the mode drops the hold, and HDR is asked for again.
#[test]
fn a_held_hdr_start_is_said_on_the_page_and_a_mode_change_tries_again() {
use crate::gfx::{HdrOutput, output::tests};
let mut s = Settings::default();
s.horizon.hdr = HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0".into());
let (mut caps, reading) = tests::window(Some(HdrOutput::Pq), Some(true));
caps.held = true;
let out = tests::snapshot(&s.horizon, Default::default(), caps, reading);
let hdr = Item::Horizon(H::Hdr);
let setting = h(
H::Hdr,
Cost::None,
"Lava, fire and sunlight shine brighter than white.",
);
let held = super::row(setting, &s, &out);
assert_eq!(held.chosen.as_deref(), Some(HDR_HELD));
assert_eq!(held.why, None);
assert_eq!(hdr.label(&s, &out).plain(), "HDR: Auto");
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s, &out), Some(HDR_HELD));
// Not held, the row says what HDR does.
caps.held = false;
let free = tests::snapshot(&s.horizon, Default::default(), caps, reading);
assert_eq!(super::row(setting, &s, &free).chosen, None);
// Any step of the mode, either way, tries again.
for back in [false, true] {
let mut s = s.clone();
hdr.cycle(&mut s, back, FB, &out);
assert_ne!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(s.horizon.hdr_failed_start, None);
}
}
/// Undo never brings the hold back: held Auto, changed to On (HDR tried again), then
/// undone, is Auto without the hold. With the mode left alone, it keeps the hold as it is.
#[test]
fn undo_never_restores_a_held_hdr_start() {
let mut screen = GraphicsScreen {
framebuffer: FB,
output: NONE,
};
let mut opened = Settings::default();
opened.horizon.hdr = HdrMode::Auto;
opened.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let mut s = opened.clone();
let out = offering(true, Some(true));
Item::Horizon(H::Hdr).cycle(&mut s, true, FB, &out);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::On, &None)
);
screen.undo(&mut s, &opened);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::Auto, &None)
);
// Another row changed and undone: the hold stays.
let mut s = opened.clone();
s.horizon.fxaa = !s.horizon.fxaa;
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
@@ -17,6 +17,7 @@
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
@@ -33,6 +34,10 @@ pub enum Step {
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// A step's test picture for HDR output: the step, its nits and where the controls start (a
/// share of the height).
pub type Picture = (Step, f32, f32);
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
@@ -43,6 +48,30 @@ pub enum Outcome {
Cancel,
}
/// The window as calibration found it: calibration turns it fullscreen for its test pictures,
/// and gives it back as it was.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowState {
Fullscreen,
Maximized,
Windowed,
}
impl WindowState {
pub fn of(fullscreen: bool, maximized: bool) -> Self {
match (fullscreen, maximized) {
(true, _) => Self::Fullscreen,
(false, true) => Self::Maximized,
(false, false) => Self::Windowed,
}
}
/// On leaving calibration: whether the window leaves fullscreen, and whether it is
/// maximized again after (leaving fullscreen gives back its restored size on Windows).
pub fn leaving(self) -> (bool, bool) {
(self != Self::Fullscreen, self == Self::Maximized)
}
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
@@ -53,8 +82,11 @@ pub struct Calibration {
dragging: bool,
}
/// Height of the band of controls at the bottom, in GUI pixels.
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
const BAND: f32 = 84.0;
/// Each further line of the hint, which wraps within the window less this at each side.
const HINT_LINE: f32 = 10.0;
const HINT_MARGIN: f32 = 10.0;
impl Step {
/// The slider's steps past its first: the settings' own.
@@ -115,18 +147,24 @@ impl Step {
struct Layout {
top: f32,
hint: Vec<Text>,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
/// The band grows up by the lines `step`'s hint takes past its first.
fn layout(font: &Font, step: Step, (w, h): (f32, f32)) -> Layout {
let hint = Text::literal(lang::get(step.hint_key()));
let hint = font.split(&hint, w - 2.0 * HINT_MARGIN);
let more = hint.len().saturating_sub(1) as f32 * HINT_LINE;
let top = (h - BAND - more).floor();
let cx = (w / 2.0).floor();
let by = top + 58.0;
let by = top + more + 58.0;
Layout {
top,
slider: (cx - 100.0, top + 32.0, 200.0),
hint,
slider: (cx - 100.0, top + more + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
@@ -159,6 +197,9 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn layout(&self, font: &Font, size: (f32, f32)) -> Layout {
layout(font, self.current(), size)
}
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
@@ -184,18 +225,17 @@ impl Calibration {
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
/// The current step's test picture in a GUI of `size`.
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
(
self.current(),
self.values[self.step],
layout(0.0, h).top / h.max(1.0),
self.layout(font, size).top / size.1.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = layout(w, h);
let l = self.layout(&atlas.font, (w, h));
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
@@ -205,16 +245,10 @@ impl Calibration {
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
for (i, line) in l.hint.iter().enumerate() {
let y = l.top + 18.0 + i as f32 * HINT_LINE;
ui::centered_text(batch, atlas, line, 0.0, y, w, rgba(160, 160, 160, 255));
}
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -261,8 +295,14 @@ impl Calibration {
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
pub fn click(
&mut self,
font: &Font,
cursor: (f32, f32),
size: (f32, f32),
drag: bool,
) -> Outcome {
let l = self.layout(font, size);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
@@ -336,6 +376,14 @@ mod tests {
const NONE: EffectiveOutput = EffectiveOutput::NONE;
fn font() -> Font {
Font::metrics(std::path::Path::new("/nonexistent/horizon-test-assets"))
}
/// At the default GUI scale of the game's first window (854 x 480 at 2).
fn picture(c: &Calibration) -> Picture {
c.picture(&font(), (427.0, 240.0))
}
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
@@ -344,14 +392,14 @@ mod tests {
let mut c = Calibration::new(&s, &NONE);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert!((picture(&c).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(picture(&c).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
@@ -370,10 +418,10 @@ mod tests {
for (output, peak, paper_white) in [(&reporting, 1000.0, 400.0), (&NONE, 1000.0, 203.0)] {
let mut c = Calibration::new(&s, output);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).0, Step::Peak);
assert_eq!(c.picture(1.0).1, peak);
assert_eq!(picture(&c).0, Step::Peak);
assert_eq!(picture(&c).1, peak);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, paper_white);
assert_eq!(picture(&c).1, paper_white);
}
}
@@ -392,14 +440,14 @@ mod tests {
];
let mut c = Calibration::new(&Settings::default(), &NONE);
for (i, expected) in expected.iter().enumerate() {
assert_eq!(c.picture(1.0).0, STEPS[i]);
assert_eq!(picture(&c).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![c.picture(1.0).1];
let mut seen = vec![picture(&c).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = c.picture(1.0).1;
let v = picture(&c).1;
if seen.last() != Some(&v) {
seen.push(v);
}
@@ -422,7 +470,7 @@ mod tests {
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s, &NONE);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
assert!((picture(&c).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
@@ -435,14 +483,89 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, 10_000.0);
assert_eq!(picture(&c).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 9500.0);
assert_eq!(picture(&c).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 1950.0);
assert_eq!(picture(&c).1, 1950.0);
}
/// A hint wider than the window less its margins wraps, the band grows up by the lines
/// it adds, and the test picture ends where the band drawn starts.
#[test]
fn the_hint_wraps_and_the_band_grows_by_its_lines() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::Enter);
let hint = Text::literal(lang::get(Step::PaperWhite.hint_key()));
let wide = font.width_text(&hint);
let size = (wide + 2.0 * HINT_MARGIN, 240.0);
let one = c.layout(&font, size);
assert_eq!(one.hint.len(), 1);
assert_eq!(one.top, 240.0 - BAND);
let narrow = (wide * 0.6, 240.0);
let wrapped = c.layout(&font, narrow);
assert_eq!(wrapped.hint.len(), 2);
assert!(
wrapped
.hint
.iter()
.all(|l| font.width_text(l) <= narrow.0 - 2.0 * HINT_MARGIN)
);
assert_eq!(wrapped.top, 240.0 - BAND - HINT_LINE);
// Under the hint's lines, the controls keep their place in the band.
assert_eq!(
wrapped.slider.1 - wrapped.top,
one.slider.1 - one.top + HINT_LINE
);
assert_eq!(
wrapped.next.1 - wrapped.top,
one.next.1 - one.top + HINT_LINE
);
for size in [size, narrow] {
let (_, _, band) = c.picture(&font, size);
assert_eq!(band * size.1, c.layout(&font, size).top);
}
}
/// The Peak step's patch (a square of a tenth of the screen, centred above the band,
/// `hdr.wgsl`) stays above the band even on the narrowest GUI with every hint wrapped:
/// its side is no more than the band's top.
#[test]
fn the_test_pictures_stay_above_a_grown_band() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
for step in STEPS {
for (w, h) in [(320.0, 240.0), (200.0, 240.0), (427.0, 240.0)] {
let (shown, _, band) = c.picture(&font, (w, h));
assert_eq!(shown, step);
let side = (0.1 * w * h).sqrt();
assert!(
side <= band * h,
"{step:?} at {w}x{h}: {side} > {}",
band * h
);
}
c.key(KeyCode::Enter);
}
}
/// Leaving calibration gives the window back as it found it.
#[test]
fn leaving_gives_the_window_back_as_it_was() {
use WindowState as W;
assert_eq!(W::of(true, false), W::Fullscreen);
assert_eq!(W::of(true, true), W::Fullscreen);
assert_eq!(W::of(false, true), W::Maximized);
assert_eq!(W::of(false, false), W::Windowed);
// (leave fullscreen, maximize again)
assert_eq!(W::Fullscreen.leaving(), (false, false));
assert_eq!(W::Maximized.leaving(), (true, true));
assert_eq!(W::Windowed.leaving(), (true, false));
}
}
+5 -5
View File
@@ -100,13 +100,13 @@ pub struct HudAtlas {
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' Polytone screen sprite modifiers.
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
@@ -2895,7 +2895,7 @@ impl HudRenderer {
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&horizon_shaders::CoreProgram>,
shader: Option<&crate::core_shader::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
@@ -393,7 +393,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
@@ -414,7 +414,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
@@ -5,7 +5,7 @@
use std::path::Path;
use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use crate::core_shader::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
@@ -64,7 +64,7 @@ pub(super) fn load_core(
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
match crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
Ok(program) => {
log::info!("the resource pack's {name} shader is used");
Some(Arc::new(program))
@@ -132,7 +132,7 @@ impl PackText {
count: None,
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -175,7 +175,7 @@ impl PackText {
}),
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
+1
View File
@@ -38,6 +38,7 @@ pub mod command_block;
pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod core_shader;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
@@ -8,6 +8,7 @@ pub use crate::settings::Opt;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
hud::{HudAtlas, HudBatch, rgba},
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
@@ -1682,8 +1683,8 @@ pub struct OptionsUi {
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
/// The window as calibration found it before making it fullscreen (the game keeps it).
pub calibration_window: Option<crate::hdr_calibration::WindowState>,
pub split_setup: crate::split_ui::SplitUi,
pub studio: crate::studio_ui::StudioUi,
/// The AI Agent page's status lines (set by the game while it shows).
@@ -1762,7 +1763,7 @@ impl Default for OptionsUi {
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
calibration_window: None,
split_setup: Default::default(),
studio: Default::default(),
agent_status: Vec::new(),
@@ -4030,7 +4031,7 @@ impl OptionsUi {
if right {
return Vec::new();
}
let outcome = c.click((x, y), size, drag);
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.shell().is_some() {
@@ -4632,13 +4633,12 @@ impl OptionsUi {
}
}
}
/// HDR calibration's test picture, while its page is open: the step, its nits and
/// where the controls start (a share of the height `h`).
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
/// HDR calibration's test picture, while its page is open, in a GUI of `size`.
pub fn calibration_picture(&self, atlas: &HudAtlas, size: (f32, f32)) -> Option<Picture> {
self.calibration
.as_ref()
.filter(|_| self.page() == Page::HdrCalibration)
.map(|c| c.picture(h))
.map(|c| c.picture(&atlas.font, size))
}
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
@@ -143,3 +143,34 @@ fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
/// A window holding HDR output back (a start failed on its driver) shows it on the HDR page
/// as soon as the graphics screen opens, with no setting applied: the row's line, and
/// Calibrate blocked, where the same window not holding it back calibrates.
#[test]
fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
use crate::gfx::output::tests::{snapshot, window};
let atlas = atlas();
let mut s = Settings::default();
s.horizon.hdr = crate::graphics::HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let (mut caps, reading) = window(Some(crate::gfx::HdrOutput::Pq), Some(true));
for held in [true, false] {
caps.held = held;
let mut ui = OptionsUi {
size: (427.0, 240.0),
..Default::default()
};
let output = snapshot(&s.horizon, Default::default(), caps, reading);
ui.draw(
&s,
output,
&mut HudBatch::new([427, 240]),
&atlas,
(-1.0, -1.0),
);
ui.show(Page::Graphics);
assert!(calibrates(&mut ui, &mut s, &atlas) != held, "held {held}");
assert_eq!(ui.graphics.screen.output.held, held);
}
}
+208 -12
View File
@@ -8,8 +8,8 @@
//!
//! [`TouchControls`] turns fingers into the controller state the game already
//! reads ([`PadState`]). Each finger gets a role when it lands (the joystick or
//! D-pad, a button, or a camera drag) and keeps it until it lifts, so moving,
//! looking and pressing never take each other's fingers.
//! D-pad, a button, or a look gesture on the world) and keeps it until it
//! lifts, so moving, looking and pressing never take each other's fingers.
pub mod aim;
pub mod layout;
pub use aim::{
@@ -22,6 +22,10 @@ use crate::gamepad::{Button, PadState};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
/// A world touch held this long without dragging becomes a hold (mining, eating).
const HOLD_DELAY: Duration = Duration::from_millis(350);
/// How long the marker of a short world tap stays.
const MARKER_TIME: Duration = Duration::from_millis(180);
/// Two taps on the D-pad's sneak button closer than this toggle crouching.
const DOUBLE_TAP: Duration = Duration::from_millis(300);
/// A look finger that moves farther than this (a fraction of the surface
@@ -35,8 +39,19 @@ const SWAP_HANDS: &str = "action:key.swapOffhand";
enum Role {
/// Drives the joystick or D-pad, measured from this anchor.
Move([f32; 2]),
/// Landed on the world: turns the camera once it moves past [`DRAG_SLOP`].
Look { start: [f32; 2], dragged: bool },
/// Turns the camera; without a drag, a short tap or a long hold acts on
/// the world where it landed.
Look {
start: [f32; 2],
since: Instant,
/// Moved past [`DRAG_SLOP`]: a camera turn, not a tap.
dragged: bool,
/// Whether a hold uses the item (eat, draw a bow) instead of mining,
/// as [`TouchControls::hold_uses_item`] said when the finger landed.
held_use: bool,
/// Became a hold before it dragged; it keeps acting while it turns.
committed: bool,
},
/// Landed on a control that acted at once; nothing more until it lifts.
Ignore,
/// Holds the shield up.
@@ -52,6 +67,8 @@ pub struct TouchControls {
pub settings: Settings,
/// The game needs the crosshair whatever the scheme.
pub force_crosshair: bool,
/// The held item is aimed along the crosshair ([`projectile_crosshair`]).
pub projectile_crosshair: bool,
/// The player has a shield, so the shield button may show.
pub shield_available: bool,
/// Only the shortcut buttons (chat, pause, settings, F3, F5): a keyboard
@@ -64,6 +81,16 @@ pub struct TouchControls {
pub vertical_movement: bool,
pub swimming: bool,
pub mounted: bool,
/// A long hold on the world uses the held item instead of mining. Read when
/// a finger lands, so a change mid-hold (the last apple eaten) can't turn
/// eating into mining.
pub hold_uses_item: bool,
/// Where this tick's world action aims (0..1 of the surface), set by
/// [`Self::poll_state`].
pub aim: Option<[f32; 2]>,
/// A world hold is active this tick, and whether it uses the item.
pub holding: bool,
pub holding_use: bool,
/// A button was pressed; the game plays the click and clears it.
pub clicked: bool,
pub chat_requested: bool,
@@ -75,6 +102,10 @@ pub struct TouchControls {
pending: PadState,
/// Key or action presses for the game's key bindings: (token, down).
key_events: Vec<(String, bool)>,
tap_aim: Option<[f32; 2]>,
/// A short world tap waiting for the game, with the finger's `held_use`.
pending_tap: Option<bool>,
last_marker: Option<([f32; 2], Instant)>,
last_sneak_tap: Option<Instant>,
sprint_latched: bool,
aspect: f32,
@@ -99,6 +130,27 @@ impl TouchControls {
}
}
/// Where a dynamic joystick may appear, as `[x, y, w, h]`: a strip
/// `dynamic_area_x` of the width wide on the joystick's side, and
/// `dynamic_area_y` of the height tall centred on it. Touches outside it
/// stay camera gestures.
pub fn dynamic_area(&self) -> [f32; 4] {
let home = self.placement(Control::Move);
let width = self.settings.dynamic_area_x.clamp(0.0, 1.0);
let height = self.settings.dynamic_area_y.clamp(0.0, 1.0);
let y = (home.center[1] - height / 2.0).clamp(0.0, 1.0 - height);
let x = if home.center[0] < 0.5 {
0.0
} else {
1.0 - width
};
[x, y, width, height]
}
fn dynamic_joystick(&self) -> bool {
self.settings.dynamic_joystick && self.settings.mode != Mode::Dpad
}
/// The shortcuts that stay with [`Self::auxiliary_only`].
fn auxiliary_control(c: Control) -> bool {
matches!(
@@ -123,7 +175,17 @@ impl TouchControls {
/// The visible control under `point`, the last-drawn one first.
pub fn control_at(&self, point: [f32; 2]) -> Option<Control> {
Control::ALL.into_iter().rev().find(|&c| {
self.visible(c) && layout::contains(self.placement(c).rect(self.aspect()), point)
if !self.visible(c) {
return false;
}
if c == Control::Move && self.dynamic_joystick() {
let area = self.dynamic_area();
return area[2] > 0.0
&& area[3] > 0.0
&& !self.moving()
&& layout::contains(area, point);
}
layout::contains(self.placement(c).rect(self.aspect()), point)
})
}
@@ -262,12 +324,41 @@ impl TouchControls {
.any(|(r, _)| matches!(r, Role::Shield))
}
/// Where the touch marker goes in the tap schemes: under a look finger, or
/// briefly where a short tap landed.
pub fn pointer(&self) -> Option<[f32; 2]> {
if self.crosshair() {
return None;
}
self.fingers
.values()
.find_map(|(r, point)| matches!(r, Role::Look { .. }).then_some(*point))
.or_else(|| {
self.last_marker
.filter(|(_, t)| t.elapsed() < MARKER_TIME)
.map(|(p, _)| p)
})
}
/// The aim point of a world action: the crosshair, or where the finger is.
fn aim_at(&self, point: [f32; 2]) -> [f32; 2] {
if self.settings.crosshair() || self.projectile_crosshair {
[0.5, 0.5]
} else {
point
}
}
fn world_gestures(&self) -> bool {
!self.crosshair() || self.settings.crosshair_gestures
}
/// One touch event: finger `id` at `point` (0..1 of the surface), as it
/// lands (`started`), moves, or lifts (`ended`). Returns how far the
/// camera should turn, in surface fractions (see [`camera_delta`]).
pub fn update(&mut self, id: u64, point: [f32; 2], started: bool, ended: bool) -> [f32; 2] {
if ended {
self.fingers.remove(&id);
self.lift(id);
return [0.0; 2];
}
if started {
@@ -275,14 +366,26 @@ impl TouchControls {
return [0.0; 2];
}
let aspect = self.aspect();
let attack_look = self.settings.attack_button_look && self.crosshair();
let Some((role, previous)) = self.fingers.get_mut(&id) else {
return [0.0; 2];
};
let turns = match role {
Role::Look { start, dragged } => {
Role::Look {
start,
since,
dragged,
committed,
..
} => {
if !*dragged && since.elapsed() >= HOLD_DELAY {
*committed = true;
}
*dragged |= ((point[0] - start[0]) * aspect).hypot(point[1] - start[1]) > DRAG_SLOP;
*dragged
}
// In the crosshair schemes the attack button can steer while it mines.
Role::Button(Button::RightTrigger) => attack_look,
_ => false,
};
let delta = [point[0] - previous[0], point[1] - previous[1]];
@@ -290,11 +393,34 @@ impl TouchControls {
if turns { delta } else { [0.0; 2] }
}
fn lift(&mut self, id: u64) {
let Some((
Role::Look {
start,
since,
dragged,
held_use,
..
},
_,
)) = self.fingers.remove(&id)
else {
return;
};
if self.world_gestures() && !dragged && since.elapsed() < HOLD_DELAY {
self.tap_aim = Some(self.aim_at(start));
self.last_marker = Some((start, Instant::now()));
self.pending_tap = Some(held_use);
}
}
fn land(&mut self, id: u64, point: [f32; 2]) {
let role = match self.control_at(point) {
Some(Control::Move) if !self.moving() => {
Role::Move(self.placement(Control::Move).center)
}
Some(Control::Move) if !self.moving() => Role::Move(if self.dynamic_joystick() {
point
} else {
self.placement(Control::Move).center
}),
Some(Control::Shield) => {
self.clicked = true;
self.pending.triggers[0] = 1.0;
@@ -348,7 +474,10 @@ impl TouchControls {
Some(c) => Self::button(c).map_or(Role::Ignore, Role::Button),
None => Role::Look {
start: point,
since: Instant::now(),
dragged: false,
held_use: self.hold_uses_item,
committed: false,
},
};
self.fingers.insert(id, (role, point));
@@ -367,20 +496,87 @@ impl TouchControls {
self.last_sneak_tap = None;
self.sprint_latched = false;
self.pending = PadState::default();
self.aim = None;
self.tap_aim = None;
self.pending_tap = None;
self.last_marker = None;
self.holding_use = false;
self.holding = false;
self.chat_requested = false;
self.settings_requested = false;
self.debug_requested = false;
self.perspective_requested = false;
}
/// A short world tap since the last call, and whether it uses the item.
/// A tap never holds attack or use into the next tick: the game acts once
/// at [`Self::aim`].
pub fn take_tap(&mut self) -> Option<bool> {
self.pending_tap.take()
}
/// Key or action presses for the game's key bindings since the last call.
pub fn key_events(&mut self) -> Vec<(String, bool)> {
std::mem::take(&mut self.key_events)
}
/// This tick's controller state, with presses that already lifted.
/// This tick's controller state, with presses that already lifted, and
/// sets [`Self::aim`], [`Self::holding`] and [`Self::holding_use`].
pub fn poll_state(&mut self) -> PadState {
self.state().merge(&std::mem::take(&mut self.pending))
let mut state = self.state().merge(&std::mem::take(&mut self.pending));
self.aim = self.tap_aim.take();
self.holding_use = false;
self.holding = false;
if !self.world_gestures() {
return state;
}
let mut hold = None;
for (role, point) in self.fingers.values_mut() {
if let Role::Look {
since,
dragged,
held_use,
committed,
..
} = role
{
if !*dragged && since.elapsed() >= HOLD_DELAY {
*committed = true;
}
if !*dragged || *committed {
hold = Some((*point, *committed, *held_use));
if *committed {
break;
}
}
}
}
if let Some((point, committed, held_use)) = hold {
self.aim = Some(self.aim_at(point));
if committed {
self.holding = true;
self.holding_use = held_use;
state.triggers[usize::from(!held_use)] = 1.0;
}
}
state
}
/// Keeps the current hold using the item, even after the last one was
/// eaten or the target changed.
pub fn latch_use(&mut self) {
if let Some((Role::Look { held_use, .. }, _)) = self.fingers.values_mut().find(|(r, _)| {
matches!(
r,
Role::Look {
committed: true,
..
}
)
}) {
*held_use = true;
}
self.holding_use = true;
}
/// The controller state the fingers hold right now.
@@ -176,9 +176,21 @@ pub struct Settings {
pub sensitivity_vertical: f32,
/// The sprint button toggles sprinting instead of holding it.
pub sprint_toggle: bool,
/// In the crosshair schemes, taps and holds on the world also attack and
/// use (at the crosshair), as in the tap schemes.
pub crosshair_gestures: bool,
/// In the crosshair schemes, dragging the attack button turns the camera.
pub attack_button_look: bool,
/// In the tap schemes, the use button shows while it would act on what
/// the crosshair points at.
pub contextual_interact: bool,
/// The joystick appears where the finger lands, within the dynamic area.
pub dynamic_joystick: bool,
/// The dynamic area's width: a fraction of the surface, from the
/// joystick's edge inwards.
pub dynamic_area_x: f32,
/// The dynamic area's height, centred on the joystick (1 is the whole height).
pub dynamic_area_y: f32,
/// Scales every control without changing the saved sizes (0.5-2).
pub button_scale: f32,
}
@@ -220,7 +232,12 @@ impl Default for Settings {
sensitivity_horizontal: 1.0,
sensitivity_vertical: 1.0,
sprint_toggle: false,
crosshair_gestures: false,
attack_button_look: true,
contextual_interact: false,
dynamic_joystick: false,
dynamic_area_x: 0.25,
dynamic_area_y: 1.0,
button_scale: 1.0,
}
}
@@ -265,6 +282,8 @@ impl Settings {
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
}
self.button_scale = finite_or(self.button_scale, 1.0).clamp(0.5, 2.0);
self.dynamic_area_x = finite_or(self.dynamic_area_x, 0.25).clamp(0.0, 1.0);
self.dynamic_area_y = finite_or(self.dynamic_area_y, 1.0).clamp(0.0, 1.0);
}
/// Whether attack and use act on the crosshair rather than on a tap.
@@ -437,14 +456,19 @@ mod tests {
#[test]
fn control_options_default_safely_and_stay_in_range() {
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
assert!(!read.sprint_toggle && !read.contextual_interact);
assert_eq!(read.button_scale, 1.0);
assert!(read.attack_button_look && !read.sprint_toggle && !read.dynamic_joystick);
assert_eq!([read.dynamic_area_x, read.dynamic_area_y], [0.25, 1.0]);
let mut s = Settings {
button_scale: f32::NAN,
dynamic_area_x: -1.0,
dynamic_area_y: f32::INFINITY,
..Settings::default()
};
s.sanitize();
assert_eq!(s.button_scale, 1.0);
assert_eq!(
[s.button_scale, s.dynamic_area_x, s.dynamic_area_y],
[1.0, 0.0, 1.0]
);
s.button_scale = 9.0;
s.sanitize();
assert_eq!(s.button_scale, 2.0);
@@ -0,0 +1,285 @@
//! Taps and holds on the world, and the dynamic joystick.
use super::*;
/// Backdates a look finger, as if it had been held `ms` milliseconds.
fn age(t: &mut TouchControls, id: u64, ms: u64) {
if let Some((Role::Look { since, .. }, _)) = t.fingers.get_mut(&id) {
*since = Instant::now() - Duration::from_millis(ms);
}
}
#[test]
fn dynamic_area_follows_the_configured_width_and_height() {
let mut t = TouchControls::default();
t.settings.dynamic_area_x = 0.25;
t.settings.dynamic_area_y = 0.5;
let area = t.dynamic_area();
assert_eq!([area[0], area[2], area[3]], [0.0, 0.25, 0.5]);
let move_y = t.placement(Control::Move).center[1];
assert!((area[1] - (move_y - 0.25).clamp(0.0, 0.5)).abs() < 1e-4);
// Far above the joystick, a touch stays a camera gesture.
t.settings.dynamic_joystick = true;
t.update(1, [0.1, 0.05], true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.update(1, [0.1, 0.05], false, true);
t.update(2, [0.1, move_y], true, false);
assert!(matches!(role(&t, 2), Role::Move(_)));
// The strip stays on the surface and follows a left-handed layout.
t.settings.dynamic_area_y = 1.0;
t.settings.left_handed = true;
let area = t.dynamic_area();
assert_eq!([area[0], area[1], area[3]], [0.75, 0.0, 1.0]);
}
#[test]
fn dynamic_joystick_spawns_in_its_strip_and_keeps_its_frame() {
let mut t = TouchControls::default();
t.settings.dynamic_joystick = true;
t.update(1, [0.5, 0.5], true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.update(1, [0.5, 0.5], false, true);
// The frame is where the finger first landed.
t.update(2, [0.12, 0.6], true, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == [0.12, 0.6]));
t.update(2, [0.20, 0.6], false, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == [0.12, 0.6]));
assert!(t.state().left[0] > 0.0);
t.update(2, [0.20, 0.6], false, true);
t.settings.left_handed = true;
t.update(3, [0.80, 0.6], true, false);
assert!(matches!(role(&t, 3), Role::Move(_)));
}
#[test]
fn dynamic_joystick_respects_hidden_controls_and_one_owner() {
let mut t = TouchControls::default();
t.settings.dynamic_joystick = true;
t.settings.dynamic_area_x = 0.05;
// The fixed joystick's home is outside a narrow strip: a camera gesture.
t.update(1, t.placement(Control::Move).center, true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.clear();
let edge = [0.025, 0.5];
assert_eq!(t.control_at(edge), Some(Control::Move));
t.update(2, edge, true, false);
assert!(matches!(role(&t, 2), Role::Move(_)));
// A second finger can't take the joystick.
t.update(3, [0.03, 0.6], true, false);
assert!(matches!(role(&t, 3), Role::Look { .. }));
t.update(2, [0.05, 0.5], false, false);
assert!(t.state().left[0] > 0.0);
t.update(2, [0.05, 0.5], false, true);
assert_eq!(t.state().left, [0.0; 2]);
t.clear();
t.settings.button_visibility[Control::Move as usize] = layout::HIDDEN;
t.update(4, edge, true, false);
assert!(matches!(role(&t, 4), Role::Look { .. }));
t.clear();
t.settings.button_visibility[Control::Move as usize] = layout::FOLLOW_SCHEME;
t.settings.dynamic_area_x = 0.0;
t.update(5, [0.0, 0.5], true, false);
assert!(matches!(role(&t, 5), Role::Look { .. }));
}
#[test]
fn fixed_joystick_dpad_and_buttons_win_over_the_dynamic_strip() {
let mut t = TouchControls::default();
let home = t.placement(Control::Move).center;
t.update(1, [home[0] + 0.01, home[1]], true, false);
assert!(matches!(role(&t, 1), Role::Move(a) if a == home));
t.clear();
t.settings.dynamic_joystick = true;
t.settings.mode = Mode::Dpad;
let home = t.placement(Control::Move).center;
let up = [home[0], home[1] - t.placement(Control::Move).size / 3.0];
t.update(2, up, true, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == home));
t.clear();
t.settings.mode = Mode::Crosshair;
t.settings.dynamic_area_x = 1.0;
t.update(3, t.placement(Control::Jump).center, true, false);
assert!(t.state().held(Button::South));
assert!(!t.moving());
}
#[test]
fn projectiles_aim_short_taps_at_the_crosshair() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.projectile_crosshair = true;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
t.poll_state();
assert_eq!(t.aim, Some([0.5, 0.5]));
assert_eq!(t.take_tap(), Some(true));
}
#[test]
fn world_taps_never_hold_attack_or_use_and_keep_the_landing_item() {
for (mode, split) in Mode::SCHEMES.map(|(m, s, _)| (m, s)) {
for held_use in [false, true] {
let mut t = TouchControls::default();
t.settings.mode = mode;
t.settings.split_controls = split;
t.settings.crosshair_gestures = true;
t.hold_uses_item = held_use;
t.update(1, [0.6, 0.4], true, false);
t.hold_uses_item = !held_use;
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(held_use));
assert_eq!(t.take_tap(), None);
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
t.clear();
assert_eq!(t.take_tap(), None);
}
}
}
#[test]
fn looking_around_with_food_never_starts_eating() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
t.update(1, [0.7, 0.4], false, false);
age(&mut t, 1, 1000);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.pointer(), Some([0.7, 0.4]));
}
#[test]
fn crosshair_gestures_aim_at_the_centre() {
for (mode, split) in [(Mode::Crosshair, false), (Mode::Dpad, true)] {
let mut t = TouchControls::default();
t.settings.mode = mode;
t.settings.split_controls = split;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.take_tap(), None, "off by default");
t.settings.crosshair_gestures = true;
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
assert_eq!(t.aim, Some([0.5, 0.5]));
}
}
#[test]
fn a_tap_aims_where_it_lands_and_a_drag_only_turns() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
assert_eq!(t.aim, Some([0.6, 0.4]));
t.poll_state();
assert_eq!(t.aim, None);
t.update(2, [0.6, 0.4], true, false);
assert_eq!(t.update(2, [0.605, 0.4], false, false), [0.0; 2]);
assert_ne!(t.update(2, [0.7, 0.4], false, false), [0.0; 2]);
t.update(2, [0.7, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), None);
}
#[test]
fn the_attack_button_can_turn_the_camera_while_mining() {
for enabled in [true, false] {
let mut t = TouchControls::default();
t.settings.attack_button_look = enabled;
let start = t.placement(Control::Attack).center;
t.update(1, start, true, false);
assert_eq!(t.poll_state().triggers[1], 1.0);
let end = [start[0] - 0.2, start[1] - 0.1];
assert_eq!(t.update(1, end, false, false) != [0.0; 2], enabled);
assert_eq!(t.poll_state().triggers[1], 1.0);
t.update(1, end, false, true);
assert_eq!(t.poll_state().triggers[1], 0.0);
}
}
#[test]
fn a_long_hold_keeps_mining_while_the_camera_turns() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers[1], 1.0);
assert_eq!(t.aim, Some([0.6, 0.4]));
assert!(t.holding && !t.holding_use);
assert_ne!(t.update(1, [0.7, 0.4], false, false), [0.0; 2]);
assert_eq!(t.poll_state().triggers[1], 1.0);
assert_eq!(t.aim, Some([0.7, 0.4]));
t.update(1, [0.7, 0.4], false, true);
assert_eq!(t.poll_state().triggers[1], 0.0);
assert_eq!(t.take_tap(), None);
}
#[test]
fn eating_survives_drift_and_ends_with_its_finger() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
assert!(t.holding_use);
assert_ne!(t.update(1, [0.66, 0.46], false, false), [0.0; 2]);
t.hold_uses_item = false; // the last item eaten can't turn into mining
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
assert_eq!(t.aim, Some([0.66, 0.46]));
// A second finger turning the camera doesn't change the hold.
t.update(2, [0.4, 0.3], true, false);
assert_ne!(t.update(2, [0.45, 0.3], false, false), [0.0; 2]);
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
t.update(1, [0.66, 0.46], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert!(!t.holding_use);
}
#[test]
fn latch_use_turns_a_hold_into_use_but_not_a_tap() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
t.poll_state();
assert!(!t.holding);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers, [0.0, 1.0]);
assert!(t.holding);
t.latch_use();
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
t.update(1, [0.66, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert!(!t.holding);
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
}
#[test]
fn the_marker_follows_a_look_finger_only_in_tap_schemes() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
assert_eq!(t.pointer(), None);
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.pointer(), Some([0.6, 0.4]));
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.aim, Some([0.6, 0.4]));
t.update(1, [0.7, 0.4], false, false);
t.poll_state();
assert_eq!(t.pointer(), Some([0.7, 0.4]));
assert_eq!(t.aim, None);
t.update(1, [0.7, 0.4], false, true);
t.clear();
t.settings.mode = Mode::Crosshair;
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.pointer(), None);
}
@@ -1,6 +1,8 @@
// Adapted from Starling's touch.rs tests (see the header of touch.rs).
use super::*;
mod gestures;
fn role(t: &TouchControls, id: u64) -> Role {
t.fingers[&id].0
}
+5 -6
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -67,7 +69,7 @@ chrono = { version = "0.4.42", default-features = false, features = ["clock", "s
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
@@ -117,18 +119,15 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["discord", "friends", "heartbeat"]
horizon-extras = ["discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# The anonymous "I'm playing" ping (docs/telemetry.md).
heartbeat = ["horizon-client-lib/heartbeat"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -447,6 +447,7 @@
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
"HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.": "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.",
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
+6 -118
View File
@@ -1,6 +1,8 @@
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
//! The optional Segment Heap manifest is embedded in the same resource compilation.
//! Generates the native mod API, and gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`, shared with the official executable's build script).
#[path = "../../packaging/windows/exe.rs"]
mod windows_exe;
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
@@ -19,119 +21,5 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ico = manifest_dir
.join("../..")
.join("packaging/icons/horizon.ico");
let target_os =
std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo must provide the build target OS");
println!("cargo:rerun-if-changed={}", ico.display());
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if target_os == "windows" {
println!("cargo:rerun-if-env-changed=WINDRES");
println!("cargo:rerun-if-env-changed=AR");
println!("cargo:rerun-if-env-changed=RC_PATH");
let target_env = std::env::var("CARGO_CFG_TARGET_ENV")
.expect("Cargo must provide the target environment");
let arg = match target_env.as_str() {
"msvc" => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
let (resource_manifest_dir, resource_icon) = match resource_path(&manifest_dir, &target_env)
.and_then(|dir| Ok((dir, resource_path(&ico, &target_env)?)))
{
Ok(paths) => paths,
// #10398: a local GNU build in a folder whose path has a space or a non-ASCII letter,
// on a volume without 8.3 names, still builds (the launcher's native mod builds use
// that toolchain), without the icon. CI never does.
Err(e) if std::env::var_os("CI").is_none() => {
println!("cargo:warning=building without Horizon's icon: {e}");
return;
}
Err(e) => panic!("{e}"),
};
let mut resource = winresource::WindowsResource::new();
resource.set_icon(&resource_icon);
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
let manifest = manifest_dir.join("windows-segment-heap.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
let resource_manifest =
resource_path(&manifest, &target_env).unwrap_or_else(|e| panic!("{e}"));
resource.set_manifest_file(&resource_manifest);
}
let original_manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").unwrap();
if cfg!(windows) && target_env == "gnu" {
// GNU windres can split paths with spaces or fail on non-ASCII paths while
// preprocessing the .rc file. Pass an 8.3 alias for the include directory; the icon
// and optional manifest use the same alias.
// This build script has no other Rust threads, and winresource reads this variable
// only while compiling the resource; restore the original value immediately after.
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", &resource_manifest_dir) };
}
let result = resource.compile();
if cfg!(windows) && target_env == "gnu" {
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", original_manifest_dir) };
}
result.expect("embedding Horizon's icon in Windows resources");
}
}
/// `path` as the resource compiler takes it: an error only when GNU windres would need an 8.3 alias
/// that the volume doesn't have.
fn resource_path(path: &std::path::Path, target_env: &str) -> Result<String, String> {
let path = path.canonicalize().unwrap_or_else(|error| {
panic!(
"resolving Windows resource path {}: {error}",
path.display()
)
});
#[cfg(windows)]
let path = {
let path_string = path.to_string_lossy();
if target_env == "gnu"
&& (!path_string.is_ascii() || path_string.chars().any(char::is_whitespace))
{
short_windows_path(&path).map_err(|error| {
format!(
"GNU windres needs an ASCII Windows resource path for {}: {error}",
path.display()
)
})?
} else {
path
}
};
#[cfg(not(windows))]
let _ = target_env;
Ok(path.to_string_lossy().replace('\\', "/"))
}
#[cfg(windows)]
fn short_windows_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetShortPathNameW(input: *const u16, output: *mut u16, capacity: u32) -> u32;
}
let input: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let mut output = vec![0u16; 32_768];
let length =
unsafe { GetShortPathNameW(input.as_ptr(), output.as_mut_ptr(), output.len() as u32) };
if length == 0 || length as usize >= output.len() {
return Err(std::io::Error::last_os_error());
}
let short_path = std::ffi::OsString::from_wide(&output[..length as usize]);
let short_path_string = short_path.to_string_lossy();
if !short_path_string.is_ascii() || short_path_string.chars().any(char::is_whitespace) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"the volume has no ASCII 8.3 path for this file",
));
}
Ok(short_path.into())
windows_exe::resources("horizon-client");
}
+9 -9
View File
@@ -2,6 +2,8 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
mod state_ranges;
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
@@ -619,6 +621,10 @@ pub trait World {
fn structures(&self, pos: [i32; 3]) -> Vec<String>;
/// Block name and properties.
fn block(&self, pos: [i32; 3]) -> (String, BTreeMap<String, String>);
/// Legal values of the actual registered property, unavailable without registry access.
fn block_property_values(&self, _block: &str, _property: &str) -> Option<Vec<String>> {
None
}
fn fluid(&self, pos: [i32; 3]) -> Option<String>;
fn can_see_sky(&self, pos: [i32; 3]) -> bool;
fn light(&self, pos: [i32; 3]) -> i32;
@@ -914,7 +920,9 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
Value::String(s) => have == *s,
Value::Bool(b) => have == b.to_string(),
Value::Number(n) => have == n.to_string(),
Value::Object(_) => have.parse::<f64>().is_ok_and(|h| range_f(want, h)),
Value::Object(bounds) => w
.block_property_values(&name, k)
.is_some_and(|domain| state_ranges::matches(bounds, &have, &domain)),
_ => false,
};
if !ok {
@@ -1955,7 +1963,6 @@ impl Tree {
mod tests {
use super::*;
use serde_json::json;
#[test]
fn advancement_reward_function_is_optional_and_namespaced() {
for (raw, expected) in [
@@ -1980,7 +1987,6 @@ mod tests {
.unwrap();
assert!(without.rewards.function.is_none());
}
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_RESOURCES set to the prepared resources.
@@ -2188,7 +2194,6 @@ mod tests {
&Flat
));
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
@@ -2211,7 +2216,6 @@ mod tests {
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
@@ -3360,7 +3364,6 @@ mod tests {
);
assert_eq!(date_millis("not a date"), None);
}
#[test]
fn simulate_progression_advancements() {
let mut tree = Tree::default();
@@ -3447,7 +3450,6 @@ mod tests {
tree.by_id.insert(id, tree.list.len());
tree.list.push(a);
}
fn get_list(tree: &Tree, target: Option<&str>, mode: &str) -> Vec<usize> {
match target {
None => (0..tree.list.len()).collect(),
@@ -3476,7 +3478,6 @@ mod tests {
}
}
}
fn grant(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -3491,7 +3492,6 @@ mod tests {
}
count
}
fn revoke(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -0,0 +1,157 @@
//! `StatePropertiesPredicate.RangedMatcher`: parse through the actual legal domain.
//!
//! The pinned vanilla registry's property kinds and all enum comparisons are checked
//! independently. Direction's possible-values order differs from Comparable order;
//! other vanilla enums preserve it. This does not establish modded/future enum order.
use horizon_core::{geometry::Direction, text::parse_java_integer};
use serde_json::{Map, Value};
fn direction(value: &str) -> Option<Direction> {
Some(match value {
"down" => Direction::Down,
"up" => Direction::Up,
"north" => Direction::North,
"south" => Direction::South,
"west" => Direction::West,
"east" => Direction::East,
_ => return None,
})
}
/// StatePropertiesPredicate.RangedMatcher.match: inclusive typed, legal-domain comparisons.
pub(super) fn matches(bounds: &Map<String, Value>, actual: &str, domain: &[String]) -> bool {
if domain.is_empty() || !domain.iter().any(|v| v == actual) {
return false;
}
let boolean = domain.len() == 2
&& domain.iter().any(|v| v == "true")
&& domain.iter().any(|v| v == "false");
// The existing feature-codec integer-family inference, independently checked
// against actual IntegerProperty kinds for every pinned registered property.
let integer = domain.iter().all(|v| {
!v.is_empty()
&& v.trim_start_matches('-')
.bytes()
.all(|b| b.is_ascii_digit())
});
let directional = domain.iter().all(|v| direction(v).is_some());
let rank = |value: &str| -> Option<i32> {
if integer {
let value = parse_java_integer(value)?;
return domain
.iter()
.any(|v| *v == value.to_string())
.then_some(value);
}
// Membership precedes comparison, including for filtered Direction domains.
let index = domain.iter().position(|v| v == value)?;
if boolean {
Some(i32::from(value == "true"))
} else if directional {
Some(i32::from(direction(value)? as u8))
} else {
// Only the separately validated remaining pinned vanilla enums.
i32::try_from(index).ok()
}
};
let Some(actual) = rank(actual) else {
return false;
};
for (key, lower) in [("min", true), ("max", false)] {
if let Some(bound) = bounds.get(key) {
let Some(bound) = bound.as_str().and_then(rank) else {
return false;
};
if (lower && actual < bound) || (!lower && actual > bound) {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn check(domain: &[&str], actual: &str, range: Value, expected: bool) {
let domain: Vec<_> = domain.iter().map(|v| (*v).to_owned()).collect();
assert_eq!(
matches(range.as_object().unwrap(), actual, &domain),
expected,
"actual={actual}, range={range}, domain={domain:?}"
);
}
#[test]
fn boolean_uses_comparable_not_values_order() {
let d = ["true", "false"];
check(&d, "false", json!({"max":"false"}), true);
check(&d, "true", json!({"max":"false"}), false);
check(&d, "true", json!({"min":"true"}), true);
check(&d, "false", json!({"min":"true"}), false);
check(&d, "true", json!({"min":"true","max":"false"}), false);
check(&d, "true", json!({"min":"True"}), false);
}
#[test]
fn integer_bounds_parse_then_require_legal_values() {
let d = ["0", "1", "2", "3", "4", "5", "6", "7"];
check(&d, "5", json!({"min":"2","max":"06"}), true);
check(&d, "7", json!({"min":"2","max":"06"}), false);
check(&d, "5", json!({"min":"6","max":"2"}), false);
for bound in ["10", "-1", "2147483648", "1e0", " 2"] {
check(&d, "5", json!({"min":bound}), false);
}
check(
&["-2", "-1", "0"],
"-1",
json!({"min":"-02","max":"0"}),
true,
);
}
#[test]
fn full_direction_compares_existing_native_enum() {
// Actual six-facing Property values; west follows south in Comparable.
let d = ["north", "east", "south", "west", "up", "down"];
for actual in d {
check(
&d,
actual,
json!({"min":"north","max":"west"}),
matches!(actual, "north" | "south" | "west"),
);
check(&d, actual, json!({"min":"up","max":"down"}), false);
}
}
#[test]
fn filtered_direction_membership_is_not_widened() {
let d = ["up", "down"];
check(&d, "down", json!({"max":"down"}), true);
check(&d, "up", json!({"max":"down"}), false);
check(&d, "up", json!({"min":"up"}), true);
check(&d, "down", json!({"min":"up"}), false);
check(&d, "up", json!({"min":"north"}), false);
check(
&["north", "south", "west", "east"],
"north",
json!({"min":"down"}),
false,
);
}
#[test]
fn other_enum_preserves_validated_nonlexical_order() {
let d = ["single", "left", "right"];
check(&d, "single", json!({"min":"left"}), false);
check(&d, "right", json!({"min":"left"}), true);
check(&d, "single", json!({"min":"unknown"}), false);
}
#[test]
fn unavailable_or_unknown_values_and_present_invalid_bounds_fail() {
check(&[], "north", json!({}), false);
check(&["north", "south"], "missing", json!({}), false);
check(&["north", "south"], "north", json!({}), true);
for bound in [json!(null), json!(2), json!(false), json!({})] {
check(&["north", "south"], "north", json!({"min":bound}), false);
}
}
}
+2 -5
View File
@@ -899,11 +899,8 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.startFalling`.
fn start_falling(&mut self) {
self.st.in_ground = false;
let v = self.p.velocity;
let x = f64::from(self.st.random.next_float() * 0.2);
let y = f64::from(self.st.random.next_float() * 0.2);
let z = f64::from(self.st.random.next_float() * 0.2);
self.p.velocity = Vec3::new(v.x * x, v.y * y, v.z * z);
self.p.velocity =
crate::projectiles::start_falling_velocity(self.p.velocity, &mut self.st.random);
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
@@ -253,6 +253,19 @@ fn an_arrow_owned_by_an_armor_stand_needs_mob_griefing_to_prime_tnt() {
assert!(primes_tnt(None, false));
}
#[test]
fn client_arrow_tick_waits_for_a_non_self_move_to_leave_its_block() {
let steps = run_with(&[], 1, false, |p| {
let arrow = p.arrow.as_mut().unwrap();
arrow.in_ground = true;
arrow.last_state = Some(BlockStateId(1));
});
let p = &steps[0];
let arrow = p.arrow.as_ref().unwrap();
assert!(arrow.in_ground);
assert_eq!(p.velocity, Vec3::new(1.0, 0.2, 0.0));
}
#[test]
#[ignore = "run by client-arrow differential harness"]
fn client_arrow_parity_fixture() {
-2
View File
@@ -77,8 +77,6 @@ pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
#[cfg(feature = "heartbeat")]
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
@@ -93,6 +93,23 @@ impl Button {
}
}
/// `AbstractContainerScreen.mouseClicked`'s double click: the same slot and
/// the same button within 250 ms (`MouseHandler`'s `doubleClick`), with
/// `lastClickSlot` replaced by every click that reaches the container logic —
/// `None` for a click outside the window, which ends the chain.
pub fn double_click(
last: &mut Option<(std::time::Instant, Option<usize>, bool)>,
slot: Option<usize>,
right: bool,
now: std::time::Instant,
) -> bool {
let double = last.is_some_and(|(at, was_slot, was_right)| {
was_slot == slot && was_right == right && now.duration_since(at).as_millis() < 250
});
*last = Some((now, slot, right));
double
}
/// The input state `mouseClicked`/`mouseReleased` keep between events.
#[derive(Clone, Debug, Default)]
pub struct Clicks {
@@ -381,4 +398,28 @@ mod tests {
Some(Input::Pickup)
);
}
/// `AbstractContainerScreen.mouseClicked`: `this.lastClickSlot = slot`
/// runs for every click that reaches the container logic, and
/// `doubleclick` is the same slot and button within `MouseHandler`'s
/// 250 ms. A click outside is a click on no slot (`null`), so the next
/// click on a real slot is not a double click.
#[test]
fn a_click_on_no_slot_ends_the_double_click_chain() {
use std::time::{Duration, Instant};
let start = Instant::now();
let mut last = None;
let at = |ms| start + Duration::from_millis(ms);
assert!(!double_click(&mut last, Some(4), false, at(0)));
assert!(double_click(&mut last, Some(4), false, at(100)));
// The click outside records null: the slot under the pointer is no
// longer the last click's slot.
assert!(!double_click(&mut last, None, false, at(120)));
assert!(!double_click(&mut last, Some(4), false, at(140)));
// The same slot from another button is not the same double click.
assert!(!double_click(&mut last, Some(4), true, at(150)));
assert!(double_click(&mut last, Some(4), true, at(200)));
// Over 250 ms is two clicks, not a double click.
assert!(!double_click(&mut last, Some(4), true, at(500)));
}
}
+3 -8
View File
@@ -621,14 +621,9 @@ pub fn mob_to_nbt(
("Air", Tag::Short(m.air as i16)),
("Health", Tag::Float(m.health.max(0.0).to_bits())),
("AbsorptionAmount", Tag::Float(m.absorption.to_bits())),
(
"HurtTime",
Tag::Short(m.hurt_time.clamp(0, i32::from(i16::MAX)) as i16),
),
(
"DeathTime",
Tag::Short(m.death_time.clamp(0, i32::from(i16::MAX)) as i16),
),
// LivingEntity.addAdditionalSaveData: (short) casts, nothing clamped.
("HurtTime", Tag::Short(m.hurt_time as i16)),
("DeathTime", Tag::Short(m.death_time as i16)),
// Mob.addAdditionalSaveData writes the current virtual getter, not raw input.
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("PersistenceRequired", Tag::Byte(i8::from(m.persistent))),
+4
View File
@@ -21,6 +21,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -303,6 +306,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
game::exit_code::ERROR
});
}
horizon_hooks::engine::shutdown();
logging::flush();
}
File diff suppressed because it is too large. Load diff
@@ -26,7 +26,6 @@ use std::{
sync::Arc,
time::Instant,
};
pub(super) struct Toast {
icon: String,
frame: Frame,
@@ -214,6 +213,10 @@ impl adv::World for View<'_, '_> {
.unwrap_or_default();
(name, props)
}
fn block_property_values(&self, block: &str, property: &str) -> Option<Vec<String>> {
let values = self.0.pack.assets.registry.property_values(block, property);
(!values.is_empty()).then(|| values.into_iter().map(str::to_owned).collect())
}
fn fluid(&self, p: [i32; 3]) -> Option<String> {
match self
.0
@@ -1886,7 +1889,6 @@ impl Game<'_> {
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
@@ -1908,7 +1910,6 @@ impl Game<'_> {
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity_for(name);
@@ -2550,7 +2551,6 @@ impl Game<'_> {
pub(super) fn text_of(v: &Value) -> Text {
horizon_client::scoreboard::comp_text(v)
}
/// An advancement's display as a server mod reads it: its title and
/// description as text (the components as the datapack wrote them), its
/// frame (`task`, `goal` or `challenge`), and whether it is announced in
@@ -131,6 +131,26 @@ struct View {
fn window(w: f32, h: f32) -> (f32, f32) {
(((w - 252.0) / 2.0).floor(), ((h - 140.0) / 2.0).floor())
}
/// `ServerboundSeenAdvancementsPacket.openedTab`: `ClientAdvancements.setSelectedTab`
/// with `showToast` true — the player picked a tab — tells the server which
/// one, so a LAN server can keep it (`handleSeenAdvancements`) and answer with
/// the tab to reopen. The tab the screen opens on is picked without the flag
/// and says nothing.
pub(super) fn opened_tab(id: &str) -> horizon_protocol::packets::play::SeenAdvancements {
horizon_protocol::packets::play::SeenAdvancements {
action: 0,
tab: Some(id.to_owned()),
}
}
/// `ServerboundSeenAdvancementsPacket.closedScreen()` (`AdvancementsScreen.removed`):
/// action 1, which carries no tab.
pub(super) fn closed_screen() -> horizon_protocol::packets::play::SeenAdvancements {
horizon_protocol::packets::play::SeenAdvancements {
action: 1,
tab: None,
}
}
impl Game<'_> {
pub(super) fn open_advancements(&mut self, from_pause: bool) {
self.load_advancements();
@@ -145,6 +165,8 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_advancements(&mut self) {
// AdvancementsScreen.removed: the server hears the screen closed.
self.send(horizon_protocol::packets::play::Serverbound::SeenAdvancements(closed_screen()));
if self.adv_ui.from_pause {
self.screen = Screen::Paused;
} else {
@@ -779,6 +801,13 @@ impl Game<'_> {
let (x, y) = (left + x, top + y);
let (cx, cy) = self.cursor;
if cx > x && cx < x + ty.1 && cy > y && cy < y + ty.2 {
// `ClientAdvancements.setSelectedTab(tab, true)`: the packet
// goes out before the tab is compared, so a click on the tab
// already open tells the server again.
let id = self.adv.tree.list[t].id.clone();
self.send(
horizon_protocol::packets::play::Serverbound::SeenAdvancements(opened_tab(&id)),
);
if self.adv_ui.tab != Some(t) {
self.adv_ui.tab = Some(t);
self.adv_ui.scroll = None;
@@ -808,3 +837,57 @@ impl Game<'_> {
self.adv_scroll_by(&view, f64::from(x) * 16.0, f64::from(y) * 16.0);
}
}
#[cfg(test)]
mod seen_advancement_tests {
use super::*;
use horizon_protocol::buf::{Codec, Reader, Writer};
use horizon_protocol::packets::play::SeenAdvancements;
/// `ServerboundSeenAdvancementsPacket.openedTab` and `closedScreen`: an
/// opened tab is action 0 with its advancement id, a closed screen action
/// 1 with no id at all.
#[test]
fn an_opened_tab_carries_its_id_and_a_close_carries_none() {
assert_eq!(
opened_tab("minecraft:story/root"),
SeenAdvancements {
action: 0,
tab: Some("minecraft:story/root".into()),
}
);
assert_eq!(
closed_screen(),
SeenAdvancements {
action: 1,
tab: None,
}
);
}
/// The wire form `handleSeenAdvancements` reads back: the id follows only
/// action 0, so a close is two bytes and a read of it yields no tab.
#[test]
fn a_closed_screen_writes_no_identifier() {
let mut w = Writer::new();
SeenAdvancements::write(&closed_screen(), &mut w);
let bytes = w.data;
assert_eq!(bytes, vec![1]);
let mut r = Reader::new(&bytes);
assert_eq!(
SeenAdvancements::read(&mut r).unwrap(),
SeenAdvancements {
action: 1,
tab: None,
}
);
let mut w = Writer::new();
SeenAdvancements::write(&opened_tab("minecraft:story/root"), &mut w);
let bytes = w.data;
let mut r = Reader::new(&bytes);
assert_eq!(
SeenAdvancements::read(&mut r).ok(),
Some(opened_tab("minecraft:story/root"))
);
}
}
@@ -0,0 +1,121 @@
//! Breaking blocks: a held tool mining a block, `break_block` and the player's break with its drops
//! (`Block.popResource` for a broken block), the destroy-progress cracks shown to everyone, and a
//! bundle dropping its content.
use super::*;
impl Game<'_> {
/// `ItemStack.mineBlock` for the held stack once a survival player has
/// mined the block `name`: an item with a `tool` component counts as used
/// and loses its `damage_per_block` on a block that doesn't break
/// instantly (`ShearsItem`: on any block but fire).
pub(super) fn mine_block_with_held(&mut self, name: &str, hardness: f32) {
let Some(stack) = self.inventory[self.selected].clone() else {
return;
};
let Some(per_block) = self.items().mining_damage(&stack) else {
return;
};
let max = self.items().mining_damage_capacity(&stack);
let wears = if &*stack.item == "minecraft:shears" {
!self.items().block_in_tag("minecraft:fire", name)
} else {
hardness != 0.0
};
if wears && per_block > 0 && max > 0 {
self.damage_held_with_max(per_block, max);
}
self.award_stat("used", &stack.item, 1);
}
pub(super) fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.player_break_block(pos).map(|_| ())
}
/// `ServerPlayerGameMode.destroyBlock`: whether the block was removed.
/// A break inside another (a mod breaking more blocks from its
/// `native_destroy_block`) keeps the outer break's own events.
pub(super) fn player_break_block(&mut self, pos: [i32; 3]) -> Result<bool> {
let outer = self.lan_break_pos.replace(pos);
let broken = self.with_player_actor(|game| game.break_block_as_player(pos));
self.lan_break_pos = outer;
broken
}
/// `Block.popResource` for a player's break, or into `break_collect`
/// when a mod takes the drops to the player's inventory: the stack still
/// gets what the block entity copies into it, and nothing is kept where
/// blocks don't drop.
pub(super) fn pop_break_drop(&mut self, pos: [i32; 3], mut stack: ItemStack) {
if self.break_collect.is_none() {
return self.pop_resource(pos, stack);
}
self.pending.attach(pos, &mut stack);
if self.ui.rules.bool("block_drops")
&& let Some(kept) = self.break_collect.as_mut()
{
kept.push(stack);
}
}
/// `BundleItem.dropContent`: `removeOneItemFromBundle` and `Player.drop`,
/// then the drop sound and the used statistic.
pub(super) fn drop_bundle_content(&mut self, slot: usize) {
let items = self.items();
let Some(mut bundle) = self.stack_in(slot).cloned() else {
return;
};
let Some(mut contents) = horizon_client::bundle::Contents::of(items, &bundle) else {
return;
};
let Some(removed) = contents.remove_one(items) else {
return;
};
contents.store(&mut bundle);
let item = Arc::clone(&bundle.item);
*self.stack_in_mut(slot) = Some(bundle);
let Some(position) = self.player.as_ref().map(|p| p.position) else {
return;
};
let at = [position.x, position.y, position.z];
// playRemoveOneSound, then Player.drop and playDropContentsSound at
// the player's block.
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio
.play(horizon_client::bundle::REMOVE_ONE, Some(at), 0.8, pitch);
self.player_drop(removed);
let block = BlockPos::containing(position);
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(
horizon_client::bundle::DROP_CONTENTS,
Some([block.x, block.y, block.z].map(|v| f64::from(v) + 0.5)),
0.8,
pitch,
);
self.award_stat("used", &item, 1);
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
pub(super) fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
/// `LevelRenderer.destroyBlockProgress`: a player's crack on a block (0..=9), or none
/// (any other progress). Cracks not updated for 400 ticks are dropped.
pub(crate) fn destroy_block_progress(&mut self, breaker: i32, pos: [i32; 3], progress: i32) {
let now = self.world.game_time;
self.destroying
.retain(|_, c| now.saturating_sub(c.2) <= 400);
if (0..10).contains(&progress) {
self.destroying.insert(breaker, (pos, progress as u8, now));
} else {
self.destroying.remove(&breaker);
}
}
}
@@ -1,4 +1,5 @@
//! Unit tests that lived in game.rs's inline `tests` module.
use super::inventory_input::drop_whole_stack;
use super::*;
use horizon_client::container_screen::{Button, Clicks, Release, Target};
use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys};
+2 -2
View File
@@ -1228,8 +1228,8 @@ impl Game<'_> {
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
move |x, z| levels.entity_ticking_at(x, z)
self.tickets
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
+25 -4
View File
@@ -272,6 +272,11 @@ pub(super) trait Entity: EntityRules {
let position = self.position_mut();
*position = position.add_vector(movement);
}
/// Bounds for `AbstractArrow.shouldFall` after a non-SELF piston move.
fn piston_release_bounds(&self) -> Option<Aabb> {
None
}
fn release_after_piston(&mut self, _no_block_collision: bool) {}
/// `setOnGroundWithMovement` at the end of a piston's or shulker lid's
/// `Entity.move`, for the kinds that keep the flag: on a server it runs
/// for every push, sideways ones and those the collision turned away included.
@@ -1816,12 +1821,16 @@ impl Entity for Projectile {
Projectile::bounds(self)
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
Projectile::move_by_piston(self, movement);
}
fn piston_release_bounds(&self) -> Option<Aabb> {
Projectile::piston_release_bounds(self)
}
fn release_after_piston(&mut self, no_block_collision: bool) {
Projectile::release_after_piston(self, no_block_collision);
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
Projectile::piston_slime_bounce(self, movement);
}
}
@@ -1964,6 +1973,18 @@ impl Entity for ThrownTrident {
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
}
fn move_by_piston(&mut self, movement: Vec3) {
ThrownTrident::move_by_piston(self, movement);
}
fn piston_release_bounds(&self) -> Option<Aabb> {
ThrownTrident::piston_release_bounds(self)
}
fn release_after_piston(&mut self, no_block_collision: bool) {
ThrownTrident::release_after_piston(self, no_block_collision);
}
fn piston_slime_bounce(&mut self, movement: Direction) {
ThrownTrident::piston_slime_bounce(self, movement);
}
/// `GameLevel::tick_blocks` sweeps tridents itself.
fn sweeps_blocks_inside(&self) -> bool {
false
@@ -10,6 +10,17 @@ use horizon_core::geometry::{Aabb, Vec3};
const MAX_ENTITY_SECTION_COVERAGE: i64 = 50;
/// The orb quad is asymmetric about its translated centre: its farthest corner
/// is sqrt(0.15² + 0.225²) blocks away. A sphere covers every billboard rotation
/// and pickup-animation position; include rounding of camera-relative vertices.
fn orb_billboard_radius(origin: glam::Vec3) -> f32 {
0.3 + origin.abs().max_element() * f32::EPSILON * 4.0
}
pub(super) fn orb_in_view(view: &horizon_client::gfx::Frustum, origin: glam::Vec3) -> bool {
view.intersects_sphere(origin, orb_billboard_radius(origin))
}
/// The inclusive range of sections an entity's padded render bounds can touch,
/// in the layout whose section 0 starts at `bottom_y`. `None` when the bounds
/// are not finite, hold block coordinates outside `i32`, or span more sections
@@ -638,3 +649,43 @@ mod vanilla_entity_tests {
#[cfg(test)]
#[path = "entity_culling_tests.rs"]
mod tests;
#[cfg(test)]
mod orb_tests {
use super::orb_billboard_radius;
#[test]
fn sphere_contains_the_asymmetric_billboard_including_vertex_rounding() {
use glam::Vec3;
use horizon_client::entities::{ORB_QUAD, ORB_QUAD_SCALE};
for origin in [
Vec3::ZERO,
Vec3::new(-47.25, 12.5, 81.0),
Vec3::new(30_000_000.0, -30_000_000.0, 256.0),
] {
let radius = orb_billboard_radius(origin);
for yaw in (0..360).step_by(15) {
for pitch in [-90.0_f32, -89.99, -45.0, 0.0, 45.0, 89.99, 90.0] {
let (yaw, pitch) = ((yaw as f32).to_radians(), pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let right = forward.cross(Vec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
for corner in ORB_QUAD {
let vertex = origin
+ right * (corner[0] * ORB_QUAD_SCALE)
+ up * (corner[1] * ORB_QUAD_SCALE);
assert!(
vertex.distance(origin) <= radius,
"{origin:?}, yaw {yaw}, pitch {pitch}: {vertex:?} outside {radius}"
);
}
}
}
}
}
}
@@ -1197,6 +1197,51 @@ impl Game<'_> {
}
Ok(())
}
/// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step.
/// A mob explosion (`ServerLevel.explode` with `ExplosionInteraction.MOB`:
/// blocks are kept without mobGriefing, else the mob_explosion_drop_decay
/// rule picks decay).
pub(super) fn explode(
&mut self,
center: [f64; 3],
radius: f32,
source: ExplosionSource,
) -> Result<()> {
self.explode_mob(center, radius, false, source)
}
/// `ServerLevel.explode` with `ExplosionInteraction.MOB` and the caller's fire flag.
pub(super) fn explode_mob(
&mut self,
center: [f64; 3],
radius: f32,
fire: bool,
source: ExplosionSource,
) -> Result<()> {
let interaction = if !self.ui.rules.bool("mob_griefing") {
BlockInteraction::Keep
} else {
self.destroy_type("mob_explosion_drop_decay")
};
self.server_explode_from(center, radius, interaction, fire, source)
}
/// `SulfurCube.tickFuse`: `ExplosionInteraction.TNT` (the
/// tnt_explosion_drop_decay rule) with mobGriefing, `NONE` without.
pub(super) fn explode_sulfur_cube(
&mut self,
center: [f64; 3],
radius: f32,
source: ExplosionSource,
) -> Result<()> {
let interaction = if !self.ui.rules.bool("mob_griefing") {
BlockInteraction::Keep
} else {
self.destroy_type("tnt_explosion_drop_decay")
};
self.server_explode_from(center, radius, interaction, false, source)
}
}
#[cfg(test)]
@@ -2753,3 +2753,6 @@ mod required_tag_tests {
std::fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
include!("functions/typed_state_range_tests.rs");
@@ -0,0 +1,300 @@
#[cfg(test)]
mod typed_state_range_read_tests {
use super::*;
#[test]
#[ignore = "needs prepared resources, actual pack and scratch homes/cwd"]
fn loaded_pack_state_ranges_match_typed_controls() {
let mut game = crate::game::native_machines::tests::server_game();
let source =
std::path::PathBuf::from(std::env::var_os("HORIZON_TYPED_RANGE_PACK").unwrap());
game.world_root = std::env::current_dir().unwrap().join("typed-range-world");
let target = game.world_root.join("datapacks/typed_ranges");
let preds = target.join("data/ranges/predicate");
std::fs::create_dir_all(&preds).unwrap();
std::fs::copy(source.join("pack.mcmeta"), target.join("pack.mcmeta")).unwrap();
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
std::fs::copy(&path, preds.join(path.file_name().unwrap())).unwrap();
}
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("reload"));
assert!(game.fns.enabled.contains(&"file/typed_ranges".to_owned()));
assert!(
game.fns.store.errors.is_empty(),
"{:?}",
game.fns.store.errors
);
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
13
);
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
let id = format!("ranges:{}", path.file_stem().unwrap().to_str().unwrap());
let expected: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(game.fns.store.predicates.get(&id), Some(&expected), "{id}");
}
// The helper's empty resident column has not passed server loading.
// Use actual streaming and forced-ticket paths, never a lit/status override.
game.world.chunks.remove(&(0, 0));
// Game::new pauses its placeholder streamer until world opening.
game.streaming.paused = false;
assert!(game.run_command("forceload add 0 0 15 15"));
game.update_chunk_tickets();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while !game.streaming.is_lit(0, 0) || !game.world.chunks.contains_key(&(0, 0)) {
let _ = game.streaming.update(&mut game.world);
game.spawn_loaded_entities();
assert!(
std::time::Instant::now() < deadline,
"server column did not finish loading"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
game.spawn_loaded_entities();
game.update_chunk_tickets();
assert!(game.run_command("execute if loaded 0 200 0"));
assert!(game.run_command("scoreboard objectives add ranges dummy"));
assert!(game.run_command(
"setblock 0 200 0 minecraft:chest[facing=north,type=single,waterlogged=false]"
));
assert!(game.run_command("item replace block 0 200 0 container.0 with minecraft:stone 3"));
let chest = game.block_entity_nbt([0, 200, 0]).unwrap();
let (expected_chest, _) = horizon_world::nbt::parse_snbt(r#"{components:{},x:0,y:200,z:0,id:"minecraft:chest",Items:[{count:3,Slot:0b,id:"minecraft:stone"}]}"#).unwrap();
assert_eq!(chest, expected_chest);
let clock = game.world.game_time;
let mut failures = Vec::new();
let mut callback = |game: &mut Game<'_>, x: i32, name: &str, expected: bool| {
for (sign, expected) in [("if", expected), ("unless", !expected)] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned {x} 200 0 {sign} predicate ranges:{name}"));
for h in ["#r", "#s"] {
let actual = game.score.board.score(h, "ranges").unwrap().value;
if actual != i32::from(expected) {
failures.push(format!(
"x={x} {name}/{sign}/{h}: expected {}, got {actual}",
i32::from(expected)
));
}
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
}
};
callback(&mut game, 0, "always", true);
callback(&mut game, 0, "never", false);
for sign in ["if", "unless"] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
assert!(!game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned 0 200 0 {sign} predicate ranges:missing")));
for h in ["#r", "#s"] {
assert_eq!(game.score.board.score(h, "ranges").unwrap().value, -99);
}
}
for (i, facing) in ["north", "south", "west", "east"].iter().enumerate() {
let x = 2 + 2 * i as i32;
let wet = i % 2 == 1;
assert!(game.run_command(&format!(
"setblock {x} 200 0 minecraft:chest[facing={facing},type=single,waterlogged={wet}]"
)));
assert!(game.run_command(&format!("execute if loaded {x} 200 0")));
assert!(game.run_command(&format!("execute if block {x} 200 0 minecraft:chest[facing={facing},type=single,waterlogged={wet}]")));
for (name, want) in [
("enum", matches!(*facing, "south" | "west")),
("enum_max", *facing != "east"),
("exact", *facing == "west"),
("invalid", false),
("inverted", false),
("boolean", !wet),
] {
callback(&mut game, x, name, want);
}
}
assert!(game.run_command("setblock 10 199 0 minecraft:farmland"));
for age in [0, 2, 5, 7] {
assert!(game.run_command(&format!("setblock 10 200 0 minecraft:wheat[age={age}]")));
assert!(game.run_command("execute if loaded 10 200 0"));
assert!(game.run_command(&format!(
"execute if block 10 200 0 minecraft:wheat[age={age}]"
)));
assert!(game.run_command("execute if block 10 199 0 minecraft:farmland"));
for (name, want) in [
("age", (2..=6).contains(&age)),
("age_padded", (2..=6).contains(&age)),
("age_invalid", false),
("age_inverted", false),
("age_exact", age == 5),
] {
callback(&mut game, 10, name, want);
}
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
assert!(
failures.is_empty(),
"typed state range failures:\n{}",
failures.join("\n")
);
}
#[test]
#[ignore = "needs prepared resources, actual pack and scratch homes/cwd"]
fn loaded_pack_direction_ranges_keep_full_and_filtered_domains() {
let mut game = crate::game::native_machines::tests::server_game();
let source =
std::path::PathBuf::from(std::env::var_os("HORIZON_TYPED_RANGE_PACK").unwrap());
game.world_root = std::env::current_dir()
.unwrap()
.join("typed-direction-world");
let target = game.world_root.join("datapacks/typed_ranges");
let preds = target.join("data/ranges/predicate");
std::fs::create_dir_all(&preds).unwrap();
std::fs::copy(source.join("pack.mcmeta"), target.join("pack.mcmeta")).unwrap();
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
std::fs::copy(&path, preds.join(path.file_name().unwrap())).unwrap();
}
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("reload"));
assert!(game.fns.enabled.contains(&"file/typed_ranges".to_owned()));
assert!(
game.fns.store.errors.is_empty(),
"{:?}",
game.fns.store.errors
);
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
13
);
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
let id = format!("ranges:{}", path.file_stem().unwrap().to_str().unwrap());
let expected: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(game.fns.store.predicates.get(&id), Some(&expected), "{id}");
}
// The helper's empty resident column has not passed server loading.
// Use actual streaming and forced-ticket paths, never a lit/status override.
game.world.chunks.remove(&(0, 0));
// Game::new pauses its placeholder streamer until world opening.
game.streaming.paused = false;
assert!(game.run_command("forceload add 0 0 15 15"));
game.update_chunk_tickets();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while !game.streaming.is_lit(0, 0) || !game.world.chunks.contains_key(&(0, 0)) {
let _ = game.streaming.update(&mut game.world);
game.spawn_loaded_entities();
assert!(
std::time::Instant::now() < deadline,
"server column did not finish loading"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
game.spawn_loaded_entities();
game.update_chunk_tickets();
assert!(game.run_command("execute if loaded 0 200 0"));
let cases = [
(
"direction_span",
"facing",
serde_json::json!({"min":"north","max":"west"}),
),
(
"direction_reversed",
"facing",
serde_json::json!({"min":"up","max":"down"}),
),
(
"vertical_up",
"vertical_direction",
serde_json::json!({"min":"up"}),
),
(
"vertical_invalid",
"vertical_direction",
serde_json::json!({"min":"north"}),
),
];
for (name, prop, bounds) in &cases {
std::fs::write(preds.join(format!("{name}.json")), serde_json::json!({"type":"minecraft:location_check","predicate":{"block":{"state":{prop.to_string():bounds}}}}).to_string()).unwrap();
}
assert!(game.run_command("reload"));
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
17
);
assert!(game.run_command("scoreboard objectives add ranges dummy"));
assert!(game.run_command("setblock 0 200 0 minecraft:chest"));
assert!(game.run_command("item replace block 0 200 0 container.0 with minecraft:stone 3"));
let chest = game.block_entity_nbt([0, 200, 0]).unwrap();
let clock = game.world.game_time;
let callback = |game: &mut Game<'_>, x: i32, name: &str, expected: bool| {
for (sign, want) in [("if", expected), ("unless", !expected)] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned {x} 200 0 {sign} predicate ranges:{name}"));
for h in ["#r", "#s"] {
assert_eq!(
game.score.board.score(h, "ranges").unwrap().value,
i32::from(want),
"{name}/{sign}/{h}"
);
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
}
};
for facing in ["down", "up", "north", "south", "west", "east"] {
assert!(game.run_command(&format!(
"setblock 2 200 0 minecraft:barrel[facing={facing}]"
)));
assert!(game.run_command(&format!(
"execute if block 2 200 0 minecraft:barrel[facing={facing}]"
)));
assert!(game.run_command("execute if loaded 2 200 0"));
callback(
&mut game,
2,
"direction_span",
matches!(facing, "north" | "south" | "west"),
);
callback(&mut game, 2, "direction_reversed", false);
}
for (x, block) in [(4, "pointed_dripstone"), (6, "sulfur_spike")] {
for vertical in ["up", "down"] {
assert!(game.run_command(&format!(
"setblock {x} 200 0 minecraft:{block}[vertical_direction={vertical}]"
)));
assert!(game.run_command(&format!(
"execute if block {x} 200 0 minecraft:{block}[vertical_direction={vertical}]"
)));
assert!(game.run_command(&format!("execute if loaded {x} 200 0")));
callback(&mut game, x, "vertical_up", vertical == "up");
callback(&mut game, x, "vertical_invalid", false);
}
}
}
}
@@ -32,6 +32,19 @@ impl Game<'_> {
}
}
/// `GameModeSwitcherScreen.mouseReleased`: releasing the debug
/// modifier's own mouse button applies the hovered mode and closes the
/// screen, as the modifier key's release does (`keyReleased`). A modifier
/// bound to a keyboard key matches no mouse button.
pub(super) fn switcher_release(&mut self, button: winit::event::MouseButton) {
if horizon_client::game_mode_switcher::release_applies(
&keybinds::mouse_name(button),
self.settings.keys.get("key.debug.modifier"),
) {
self.close_gamemode_switcher(true);
}
}
pub(super) fn draw_gamemode_switcher(&mut self, batch: &mut HudBatch) {
let (w, h) = (batch.width as i32, batch.height as i32);
let Some(switcher) = &mut self.gamemode_switcher else {
@@ -0,0 +1,118 @@
//! The player's hands and what it wears: the stack in the acting hand (`getItemInHand`), the hand of
//! the item in use and running code with the other hand acting, `Inventory.add`, and the worn glider
//! and freeze-immune gear.
use super::*;
impl Game<'_> {
/// The stack in the hand that acts (`getItemInHand(hand)`).
pub(super) fn held(&self) -> Option<&ItemStack> {
self.stack_in(self.held_slot())
}
pub(super) fn held_mut(&mut self) -> &mut Option<ItemStack> {
self.stack_in_mut(self.held_slot())
}
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide,
/// or a stack in a hand that glides there (`canGlideUsing` walks every
/// `EquipmentSlot`).
pub(super) fn glider_worn(&self) -> bool {
let items = self.res.items();
let hand = |stack: Option<&ItemStack>, slot: &str| {
stack.is_some_and(|s| horizon_client::item_sync::can_glide_using(s, slot, items))
};
gliding_pieces(items, &self.armor).contains(&true)
|| hand(self.inventory[self.selected].as_ref(), "mainhand")
|| hand(self.offhand.as_ref(), "offhand")
}
/// `LivingEntity.updateFallFlying`: the glide's wear. `Util.getRandom`
/// picks one of the gliders that will not break (from the entity's own
/// random), and
/// `ItemStack.hurtAndBreak` takes `points` of its durability (Unbreaking
/// may save it). Callers skip creative.
pub(super) fn wear_glider(&mut self, points: u32) {
let gliding = gliding_pieces(self.res.items(), &self.armor);
let pieces: Vec<usize> = (0..4).filter(|&i| gliding[i]).collect();
if pieces.is_empty() {
return;
}
// `Util.getRandom(slotsWithGliders, this.random)`: the entity's own
// random, which never touches the level's stream.
let pick = random_source::entity().next_int_bound(pieces.len() as i32);
let pick = pieces[pick.unwrap_or(0) as usize];
if let Some(stack) = self.armor[pick].clone() {
let loss = self.wear(&stack, points);
if let Some(piece) = self.armor[pick].as_mut() {
piece.damage += loss;
}
}
}
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
/// `freeze_immune_wearables`.
pub(crate) fn wears_freeze_immune(&self) -> bool {
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
self.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
}
pub(super) fn held_slot(&self) -> usize {
match self.hand {
Hand::Main => self.selected,
Hand::Off => shield::OFFHAND_SLOT,
}
}
/// `LivingEntity.getUsedItemHand`: the hand of the item in use.
pub(super) fn used_hand(&self) -> Option<Hand> {
self.using.map(|(slot, _)| {
if slot == shield::OFFHAND_SLOT {
Hand::Off
} else {
Hand::Main
}
})
}
/// Run `f` with `hand` acting (`held`, `consume_held`, `swing_arm`).
pub(super) fn with_hand<T>(&mut self, hand: Hand, f: impl FnOnce(&mut Self) -> T) -> T {
let prior = std::mem::replace(&mut self.hand, hand);
let out = f(self);
self.hand = prior;
out
}
/// Inventory.add: returns the stack that did not fit.
pub(super) fn give(&mut self, mut stack: ItemStack) -> Option<ItemStack> {
let tutorial_item = matches!(
self.tutorial.step,
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
)
.then(|| stack.clone());
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
let before = stack.count;
let left = inventory_add(
&mut self.inventory,
&mut self.offhand,
self.selected,
max,
&mut stack,
);
if stack.count < before {
self.tutorial_get_item(tutorial_item.as_ref());
}
left.then_some(stack)
}
}
@@ -0,0 +1,37 @@
//! The HUD-render gates `Hud`/`GameRenderer` apply before drawing.
/// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown, and only over
/// a level. The F3 overlay stays on when the world closes, so it must not reach the panorama.
pub(super) fn debug_crosshair_drawn(
enabled: bool,
first_person: bool,
hide_gui: bool,
panorama: bool,
) -> bool {
enabled && first_person && !hide_gui && !panorama
}
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
/// screen, LevelLoadingScreen's Reason.OTHER) replaces the level vanilla draws under it, so the
/// player's target must not leave its outline there.
pub(super) fn block_outline_drawn(
hide_gui: bool,
entity_targeted: bool,
can_build: bool,
panorama: bool,
) -> bool {
!hide_gui && !entity_targeted && can_build && !panorama
}
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
/// block, jigsaw and the test blocks.
pub(super) fn is_game_master_block(name: &str) -> bool {
horizon_client::command_block::Mode::of_block(name).is_some()
|| matches!(
name,
"minecraft:structure_block"
| "minecraft:jigsaw"
| "minecraft:test_block"
| "minecraft:test_instance_block"
)
}
@@ -2,6 +2,12 @@
//! drags, throws and the press/release of a slot click.
use super::*;
/// Q throws the whole stack with Control down: the same modifier word the
/// screens read (`has_control_down`), never a latched sprint toggle.
pub(super) fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
modifiers.control_key()
}
impl Game<'_> {
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
/// key over a slot swaps before the search box sees it.
@@ -118,6 +124,15 @@ impl Game<'_> {
return;
}
let (slot, outside) = self.inventory_target();
// mouseClicked: `lastClickSlot = slot` for every press that reaches
// the container, and the double click is the same slot and button
// within 250 ms. A click outside records no slot, ending the chain.
let double = horizon_client::container_screen::double_click(
&mut self.last_inv_click,
slot,
right,
std::time::Instant::now(),
);
let shift = self.has_shift_down();
let target = Target::of(slot, outside);
let input = self.inv_clicks.press(
@@ -146,11 +161,6 @@ impl Game<'_> {
&& shift
&& slot != 200
{
let now = std::time::Instant::now();
let double = self.last_inv_click.is_some_and(|(at, last, was_right)| {
last == slot && was_right == right && now.duration_since(at).as_millis() < 250
});
self.last_inv_click = Some((now, slot, right));
// mouseClicked's QUICK_MOVE (and Horizon's at-press QUICK_MOVE
// of a carried stack, mouseReleased's in vanilla) remember the
// stack it moves.
@@ -190,16 +200,12 @@ impl Game<'_> {
{
// An item tab's display slots take no drags or PICKUP_ALL.
let display = slot >= Creative::DISPLAY;
let now = std::time::Instant::now();
let double = !shift
let double = double
&& !shift
&& !right
&& !display
&& self.carried.is_some()
&& self.last_inv_click.is_some_and(|(at, last, was_right)| {
last == slot && !was_right && now.duration_since(at).as_millis() < 250
})
&& self.pick_all_allowed(slot);
self.last_inv_click = Some((now, slot, right));
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
self.last_inv_click = None;
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
@@ -374,4 +374,138 @@ impl Game<'_> {
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
}
/// Slot ids: 0..36 player inventory, 100..109 crafting grid, 200 result.
pub(super) fn container_kind(&self) -> Option<&BlockEntity> {
self.container
.and_then(|k| self.world.block_entities.get(&k))
}
/// 300.. are the open container's slots.
pub(super) fn slots(&self) -> Vec<usize> {
if self.creative_inventory_open() {
return Creative::inventory_slots().collect();
}
if self.creative_tab_open() {
return Creative::tab_slots().collect();
}
if let Some(v) = self.station_slots().or_else(|| self.mount_slots()) {
return v;
}
let mut v: Vec<usize> = (0..36).collect();
if let Some(shown) = self.native_screen_slots() {
v.extend(shown);
return v;
}
match self.container_kind() {
Some(BlockEntity::Chest(_)) => v.extend((0..self.container_size()).map(|i| 300 + i)),
Some(BlockEntity::Furnace(_)) => v.extend(300..303),
Some(BlockEntity::Brewing(_)) => v.extend(300..305),
Some(BlockEntity::Crafter(_)) => v.extend(300..310),
Some(
BlockEntity::Sign(_)
| BlockEntity::Jukebox(_)
| BlockEntity::Beacon(_)
| BlockEntity::Lectern(_)
| BlockEntity::Sculk(_)
| BlockEntity::Banner(_)
| BlockEntity::CopperGolemStatue(_)
| BlockEntity::CreakingHeart(_)
| BlockEntity::Spawner(_)
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EndPortal
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_)
| BlockEntity::Mod(_)
| BlockEntity::PotentSulfur(_)
| BlockEntity::StructureBlock(_)
| BlockEntity::JigsawBlock(_)
| BlockEntity::TestBlock(_)
| BlockEntity::TestInstanceBlock(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
v.push(200);
if self.craft_size == 2 {
v.extend(500..505);
}
}
}
v
}
/// How many slots `slots` lists, counted without building the list: the
/// screen render hook asks once a frame and must not allocate. Same
/// branches in the same order, so the two stay in step.
pub(super) fn slot_count(&self) -> usize {
if self.creative_inventory_open() {
return Creative::inventory_slots().count();
}
if self.creative_tab_open() {
return Creative::tab_slots().count();
}
if let Some(n) = self
.station_slot_count()
.or_else(|| self.mount_slot_count())
{
return n;
}
let inventory = 36;
if let Some(shown) = self.native_screen_slot_count() {
return inventory + shown;
}
let container = match self.container_kind() {
Some(BlockEntity::Chest(_)) => self.container_size(),
Some(BlockEntity::Furnace(_)) => 3,
Some(BlockEntity::Brewing(_)) => 5,
Some(BlockEntity::Crafter(_)) => 10,
Some(
BlockEntity::Sign(_)
| BlockEntity::Jukebox(_)
| BlockEntity::Beacon(_)
| BlockEntity::Lectern(_)
| BlockEntity::Sculk(_)
| BlockEntity::Banner(_)
| BlockEntity::CopperGolemStatue(_)
| BlockEntity::CreakingHeart(_)
| BlockEntity::Spawner(_)
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EndPortal
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_)
| BlockEntity::Mod(_)
| BlockEntity::PotentSulfur(_)
| BlockEntity::StructureBlock(_)
| BlockEntity::JigsawBlock(_)
| BlockEntity::TestBlock(_)
| BlockEntity::TestInstanceBlock(_),
) => 0,
None => {
self.craft_size * self.craft_size + 1 + if self.craft_size == 2 { 5 } else { 0 }
}
};
inventory + container
}
}
@@ -573,6 +573,259 @@ impl Game<'_> {
self.give_orb_points(gained);
}
}
/// `Player.dropEquipment` for the player who died: unless keep_inventory
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
/// the off hand (40), each thrown in a random direction. The mods
/// (`native_loot_drops`, the dying player `who`: uuid and entity id)
/// may add to, change or take out the stacks before they are thrown.
pub(super) fn drop_inventory(&mut self, at: [f64; 3], who: (u128, i32)) {
if self.ui.rules.bool("keep_inventory") {
return;
}
let mut stacks = death_drops(&mut self.inventory, &mut self.offhand, &mut self.armor);
self.apply_equipment();
let context = horizon_client::mod_api::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((who.1 as u32, "minecraft:player")),
player: Some(who.0),
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for stack in stacks {
let r = &mut self.world.random;
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, true);
self.spawn_item(stack, at, v, 40);
}
}
/// Item entity `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_dropped(&mut self, i: usize) -> ItemEntity {
let e = self.dropped.remove(i);
self.native_entity_left(e.id);
e
}
/// Experience orb `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_orb(&mut self, i: usize) -> ExperienceOrb {
let o = self.orbs.remove(i);
self.native_entity_left(o.id);
o
}
/// The item entity `spawn_item` just added, unless a native mod kept it
/// out of the level (`native_entity_join_level`).
pub(super) fn just_spawned_item(&mut self) -> Option<&mut ItemEntity> {
let id = self.next_entity;
self.dropped.last_mut().filter(|d| d.id == id)
}
/// `new ItemEntity(level, x, y, z, stack, dx, dy, dz)`.
pub(super) fn spawn_item(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: [f64; 3],
pickup_delay: u32,
) {
self.add_item_entity(stack, position, Some(velocity), pickup_delay);
}
/// `new ItemEntity(level, x, y, z, stack)`: the item leaves with its own
/// random's motion, which this returns.
pub(super) fn spawn_item_own(
&mut self,
stack: ItemStack,
position: [f64; 3],
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity(stack, position, None, pickup_delay)
}
pub(super) fn add_item_entity(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
// bobOffs and yaw, then (without a given motion) the constructor's motion.
let mut own = random_source::entity();
let uuid = horizon_client::save::insecure_uuid(&mut own);
self.persist.uuids.insert(self.next_entity, uuid);
let bob = own.next_float() * std::f32::consts::PI * 2.0;
let yaw = own.next_float() * 360.0;
let velocity = velocity.unwrap_or_else(|| {
let x = own.next_double() * 0.2 - 0.1;
[x, 0.2, own.next_double() * 0.2 - 0.1]
});
self.dropped.push(ItemEntity {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
random: own,
..ItemEntity::new(
self.next_entity,
stack,
p,
Vec3::new(velocity[0], velocity[1], velocity[2]),
bob,
)
});
// ServerLevel.addFreshEntity.
if self.native_joins_flush(false, self.next_entity) {
self.native_item_spawn(self.next_entity, &item, count, position);
}
velocity
}
/// Block.popResource
pub(super) fn pop_resource(&mut self, pos: [i32; 3], mut stack: ItemStack) {
self.pending.attach(pos, &mut stack);
// Block.popResource: Mth.nextDouble offsets from the level random; the
// motion is `new ItemEntity(..)`'s own, from the item's random.
let p = spill::pop_position(&mut self.world.random, pos);
// Block.popResource draws its offsets before the private overload checks the gamerule.
if !self.ui.rules.bool("block_drops") {
return;
}
self.spawn_item_own(stack, p, 10);
}
/// `Block.popResourceFromFace`: the loot leaves the clicked face with the
/// face's own motion instead of the item's random one.
pub(super) fn pop_resource_from_face(
&mut self,
pos: [i32; 3],
face: [i32; 3],
mut stack: ItemStack,
) {
self.pending.attach(pos, &mut stack);
let (p, v) = spill::pop_position_from_face(&mut self.world.random, pos, face);
// The private overload checks the gamerule after the offsets are drawn.
if !self.ui.rules.bool("block_drops") {
return;
}
self.spawn_item(stack, p, v, 10);
}
/// Player.drop: thrown from the eyes along the view direction.
pub(super) fn drop_held(&mut self, all: bool) {
if self.eye_position().is_none() {
return;
}
let spectator = self.spectator();
let Some(thrown) = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
spectator,
) else {
return;
};
self.player_drop(thrown);
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
/// drop key) names the player as the thrower; `drop(stack, false)`, for
/// what no longer fits in the inventory, does not.
pub(super) fn throw_from_eyes(
&mut self,
thrown: ItemStack,
eye: [f64; 3],
thrown_from_hand: bool,
) {
let r = &mut self.world.random;
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, false);
// `getEyeY() - 0.3F`.
let y = eye[1] - f64::from(0.3f32);
self.spawn_item(thrown, [eye[0], y, eye[2]], v, 40);
// A native mod can keep the item out of the level (native_entity_join_level).
if thrown_from_hand {
let uuid = self.acting_player_uuid();
if let Some(item) = self.just_spawned_item() {
item.thrower = Some(uuid);
}
}
}
/// DropExperienceBlock / RedStoneOreBlock / SculkBlock experience ranges.
pub(super) fn block_experience(&mut self, name: &str) -> u32 {
let (min, max) = match name.trim_start_matches("minecraft:") {
"coal_ore" | "deepslate_coal_ore" => (0, 2),
"nether_gold_ore" => (0, 1),
"lapis_ore" | "deepslate_lapis_ore" | "nether_quartz_ore" => (2, 5),
"diamond_ore" | "deepslate_diamond_ore" | "emerald_ore" | "deepslate_emerald_ore" => {
(3, 7)
}
"redstone_ore" | "deepslate_redstone_ore" => (1, 5),
"sculk" => (1, 1),
"sculk_catalyst" | "sculk_sensor" | "calibrated_sculk_sensor" | "sculk_shrieker" => {
(5, 5)
}
// SpawnerBlock.spawnAfterBreak: 15 + nextInt(15) + nextInt(15).
"spawner" => {
let r = &mut self.world.random;
let a = r.next_int_bound(15).unwrap_or(0);
return (15 + a + r.next_int_bound(15).unwrap_or(0)) as u32;
}
_ => (0, 0),
};
// ConstantInt.sample (sculk) does not advance the level random.
if min == max {
return min as u32;
}
(min + self.world.random.next_int_bound(max - min + 1).unwrap_or(0)) as u32
}
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
/// furnace at `pos`, which pops the experience of what it made at them
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
let used = match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::Furnace(f)) => {
let used = f.recipe_experience(world_recipes!(self));
f.recipes_used.clear();
used
}
_ => return,
};
for (count, experience) in used {
let xp = horizon_client::containers::create_experience(
count,
experience,
&mut self.world.random,
);
self.spawn_orbs(at, xp);
}
}
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let Some(gained) = self.xp_change(gained as i32) else {
return;
};
let gained = gained.max(0);
let old_level = self.experience.level;
let gave = self.experience.give(gained);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = gave {
self.play_level_up(1, level);
}
}
}
#[cfg(test)]
+2 -3
View File
@@ -2729,9 +2729,8 @@ impl Game<'_> {
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
for d in &mut self.dropped {
d.needs_sync = false;
}
self.tridents.iter_mut().for_each(|t| t.needs_sync = false);
self.dropped.iter_mut().for_each(|d| d.needs_sync = false);
// The step command reaches clients before the events its tick
// generated, as ServerTickRateManager.updateStepTicks does.
host.hub.sync_ticking(
@@ -881,6 +881,7 @@ impl Game<'_> {
);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
e.needs_sync = t.needs_sync;
// Projectile.getAddEntityPacket: the owner's id, the guest's that
// threw it.
e.data = data;
+64 -3
View File
@@ -544,6 +544,16 @@ impl Game<'_> {
let free_after = scene
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
// Entity.move in `vehicle.move`, before the checks: a long move through
// water or a reset block resets the fall, which a move put back keeps
// (`absSnapTo` restores only the position).
if let Some(i) = mount {
self.mobs[i].fall_distance =
self.vehicle_fall_reset(self.mobs[i].fall_distance, old, resolved);
} else if let Some(i) = boat {
self.boats[i].fall_distance =
self.vehicle_fall_reset(self.boats[i].fall_distance, old, resolved);
}
if free_before && (wrongly || !free_after) {
log::warn!("vehicle {id} moved wrongly");
back(host, old, yaw);
@@ -660,9 +670,7 @@ impl Game<'_> {
.push_by_fluid(&self.world, &self.res.motion().fluids)
.0
> 0.0;
// Entity.move: a long move through water or a reset block resets it first.
self.boats[i].fall_distance =
self.vehicle_fall_reset(self.boats[i].fall_distance, old, resolved);
// (A long move's reset ran before the checks above.)
let fall = if in_water {
0.0
} else {
@@ -935,12 +943,25 @@ mod controller_tests {
.default_state("minecraft:stone")
.unwrap()
.0;
let cobweb = game
.pack
.assets
.registry
.default_state("minecraft:cobweb")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
// Off the fall's line: only the long moves at the end cross them.
chunk.set(6, 205, 4, cobweb);
chunk.set(8, 205, 4, cobweb);
for y in 205..=207 {
chunk.set(10, y, 4, stone);
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
@@ -1031,5 +1052,45 @@ mod controller_tests {
_ => false,
});
assert!(dust, "the guest sees the landing dust");
// Entity.move in `vehicle.move`: a move of a block or more casts along
// it (FALLDAMAGE_RESETTING, WATER), and the cobweb it crosses resets
// the fall (a fall loaded from its save, here) before the move counts.
game.guest_move_vehicle(&mut host, 2, &move_to(205.5, false));
assert_eq!(game.mobs[0].position.y, 205.5, "the guest lifts the horse");
game.mobs[0].fall_distance = 4.0;
game.guest_move_vehicle(
&mut host,
2,
&play::MoveVehicleRequest {
position: [6.5, 205.5, 4.5],
yaw: 0.0,
pitch: 0.0,
on_ground: false,
},
);
assert_eq!(game.mobs[0].position.x, 6.5, "the guest moves the horse on");
assert_eq!(
game.mobs[0].fall_distance, 0.0,
"a long move through a cobweb resets the fall"
);
// handleMoveVehicle moves the horse before it checks the move: one
// into a wall is put back (moved wrongly), and keeps the reset.
game.mobs[0].fall_distance = 4.0;
game.guest_move_vehicle(
&mut host,
2,
&play::MoveVehicleRequest {
position: [11.5, 205.5, 4.5],
yaw: 0.0,
pitch: 0.0,
on_ground: false,
},
);
assert_eq!(game.mobs[0].position.x, 6.5, "the move is put back");
assert_eq!(
game.mobs[0].fall_distance, 0.0,
"a move put back keeps the cobweb's reset"
);
}
}
+90 -8
View File
@@ -94,13 +94,36 @@ pub(super) fn register_layer_definitions() {
pub(super) fn content_root(
root: &std::path::Path,
enabled: &std::collections::BTreeSet<String>,
disabled: &std::collections::BTreeSet<String>,
dev: &[std::path::PathBuf],
) -> std::path::PathBuf {
let here = std::env::current_dir().unwrap_or_default();
let version = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_owned()));
content_root_at(root, enabled, disabled, dev, &here, version.as_deref())
}
fn content_root_at(
root: &std::path::Path,
enabled: &std::collections::BTreeSet<String>,
disabled: &std::collections::BTreeSet<String>,
dev: &[std::path::PathBuf],
here: &std::path::Path,
version: Option<&std::path::Path>,
) -> std::path::PathBuf {
use horizon_client::mod_content::{self, ContentMod};
use horizon_mods::{Capability, Package};
let _step = horizon_client::spans::startup::step("mods.content_root");
let here = std::env::current_dir().unwrap_or_default();
let mut paths = horizon_mods::find(&here.join(mod_list::DIR));
let native = version
.map(|p| horizon_mods::find(&p.join("native-content")))
.unwrap_or_default();
let native_ids: std::collections::BTreeSet<_> = native
.iter()
.filter_map(|path| Package::read(path).ok().map(|p| p.manifest.id))
.collect();
let mut paths = native.clone();
paths.extend(horizon_mods::find(&here.join(mod_list::DIR)));
paths.retain(|p| !dev.contains(p));
paths.extend(dev.iter().cloned());
let mut known = None;
@@ -113,8 +136,17 @@ pub(super) fn content_root(
.filter_map(|path| Some((path, Package::read(path).ok()?)))
.filter(|(path, package)| {
let m = &package.manifest;
(enabled.contains(&m.id) || dev.contains(path))
&& m.capabilities.contains(&Capability::Registries)
// A legacy content copy kept for an older build cannot replace this build's
// snapshot, including when the native mod is off.
if !native.contains(path) && native_ids.contains(&m.id) && !dev.contains(path) {
return false;
}
let on = if native.contains(path) {
!disabled.contains(&m.id)
} else {
enabled.contains(&m.id)
} || dev.contains(path);
on && m.capabilities.contains(&Capability::Registries)
})
.collect();
let read = read_contents(&packages);
@@ -843,6 +875,50 @@ mod tests {
use super::*;
use std::io::Write;
#[test]
#[ignore = "prepared resources: run explicitly for native content"]
fn built_native_sound_content_reaches_the_resource_root_and_obeys_disable() {
let base = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let scratch =
std::env::temp_dir().join(format!("horizon-native-content-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let version = scratch.join("version");
let home = scratch.join("game");
std::fs::create_dir_all(&home).unwrap();
let content = version.join("native-content/soundproof");
std::fs::create_dir_all(content.join("assets/soundproof/sounds")).unwrap();
std::fs::write(content.join("horizon-mod.toml"), "id=\"soundproof\"\nname=\"Sound proof\"\nversion=\"1\"\napi=\"0.2\"\ncapabilities=[\"registries\"]\n").unwrap();
std::fs::write(
content.join("assets/soundproof/sounds.json"),
r#"{"proof":{"sounds":["soundproof:proof"]}}"#,
)
.unwrap();
std::fs::write(
content.join("assets/soundproof/sounds/proof.ogg"),
b"synthetic existence marker; no audio decode",
)
.unwrap();
let legacy = home.join(mod_list::DIR).join("soundproof");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::copy(
content.join("horizon-mod.toml"),
legacy.join("horizon-mod.toml"),
)
.unwrap();
let enabled = ["soundproof".to_owned()].into();
let empty = Default::default();
let root = content_root_at(&base, &empty, &empty, &[], &home, Some(&version));
assert_ne!(root, base);
let sounds = horizon_client::sound_events::SoundRegistry::load(&root);
assert!(!sounds.tracks("soundproof:proof").is_empty());
let disabled = ["soundproof".to_owned()].into();
let off = content_root_at(&base, &enabled, &disabled, &[], &home, Some(&version));
assert_eq!(off, base, "disabled native mods contribute no assets");
std::fs::remove_dir_all(scratch).unwrap();
}
/// A content mod's living type is a mob of its template's species under
/// its own id, with its own attributes: it saves its id with every field
/// its template's class saves, loads back as itself, and rolls its own
@@ -969,10 +1045,16 @@ mod tests {
assert_eq!(verdict(&ruby, &manifest, true), switches_on_restart);
assert_eq!(verdict(&ruby, &manifest, false), Some(Ok(())));
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::File::open(&ruby)
.unwrap()
.set_modified(later)
.unwrap();
let mut folder = std::fs::OpenOptions::new();
folder.read(true);
// Windows opens a folder only with FILE_FLAG_BACKUP_SEMANTICS, and sets its time only
// with write access.
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
folder.write(true).custom_flags(0x0200_0000);
}
folder.open(&ruby).unwrap().set_modified(later).unwrap();
assert_eq!(verdict(&ruby, &manifest, false), switches_on_restart);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -104,7 +104,7 @@ fn placements(
NativeStructureBlock {
pos: std::array::from_fn(|i| relative[i] + at[i]),
state: transforms.rotate(transforms.mirror(block.state, mirror), rotation),
nbt: block.nbt.clone(),
nbt: block.nbt.as_deref().cloned(),
}
})
.collect();
+26 -12
View File
@@ -3,6 +3,7 @@
use super::{Game, Screen};
use horizon_client::{
Result,
hdr_calibration::WindowState,
hud::HudBatch,
lang,
options_ui::{Event, Page},
@@ -188,7 +189,7 @@ impl Game<'_> {
.map_or(Default::default(), |r| *r.output());
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration(batch.height);
self.show_calibration((batch.width, batch.height));
}
/// Whose controller settings these are and what this game sees (`controller_ui`): the
/// player's badge in splitscreen with its name, and its own controller.
@@ -206,10 +207,10 @@ impl Game<'_> {
}
}
/// HDR calibration's test picture to the renderer, full screen while it runs: the
/// window goes fullscreen for it and back after, if it wasn't already.
pub(super) fn show_calibration(&mut self, h: f32) {
/// window goes fullscreen for it and back after, as it was (maximized, say).
pub(super) fn show_calibration(&mut self, size: (f32, f32)) {
let picture = (self.screen == Screen::Options)
.then(|| self.ui.options.calibration_picture(h))
.then(|| self.ui.options.calibration_picture(&self.gui.atlas, size))
.flatten();
if let Some(r) = self.renderer.as_mut() {
r.set_hdr_calibration(picture.map(|(step, nits, band)| {
@@ -223,20 +224,25 @@ impl Game<'_> {
let Some(window) = &self.window else {
return;
};
match (picture.is_some(), self.ui.options.calibration_fullscreen) {
match (picture.is_some(), self.ui.options.calibration_window) {
(true, None) => {
let was = window.fullscreen().is_some();
if !was {
// Before entering fullscreen, which un-maximizes a window on Windows.
let was = WindowState::of(window.fullscreen().is_some(), window.is_maximized());
if was != WindowState::Fullscreen {
let borderless = horizon_client::window::borderless_fullscreen(window);
horizon_client::window::enter_fullscreen(window, borderless);
}
self.ui.options.calibration_fullscreen = Some(was);
self.ui.options.calibration_window = Some(was);
}
(false, Some(was)) => {
if !was {
let (leave_fullscreen, maximize) = was.leaving();
if leave_fullscreen {
window.set_fullscreen(None);
}
self.ui.options.calibration_fullscreen = None;
if maximize {
window.set_maximized(true);
}
self.ui.options.calibration_window = None;
}
_ => {}
}
@@ -397,7 +403,7 @@ impl Game<'_> {
}
self.dragging = false;
// Closed from calibration (horizon-ctl, a lost window): out of it.
self.show_calibration(0.0);
self.show_calibration((0.0, 0.0));
}
Event::Language(code) => {
self.load_language(&code);
@@ -551,6 +557,14 @@ impl Game<'_> {
self.reload_packs();
}
self.apply_fullscreen();
// A failed HDR start that named no driver becomes the window's driver's, and another
// driver's is dropped, so HDR output is tried again.
if let Some(driver) = self.renderer.as_ref().and_then(|r| r.hdr_driver())
&& self.settings.horizon.settle_failed_start(driver)
&& let Some(dir) = Self::options_path().parent()
{
self.settings.horizon.save(dir);
}
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
@@ -628,7 +642,7 @@ impl Game<'_> {
pub(super) fn observe_fullscreen(&mut self) {
// HDR calibration's own fullscreen is not the player's option: it
// goes back to what it was when the calibration ends.
if self.ui.options.calibration_fullscreen.is_some() {
if self.ui.options.calibration_window.is_some() {
return;
}
let Some(w) = &self.window else { return };
+6 -4
View File
@@ -306,10 +306,11 @@ impl Game<'_> {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, server, mods, dev_mods) = (
let (selected, server, mods, disabled, dev_mods) = (
self.settings.resource_packs.clone(),
self.server_packs.active.clone(),
self.applied_mods(),
self.settings.mods.disabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
@@ -320,8 +321,8 @@ impl Game<'_> {
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
self.pack_reload = Some(PackReload::spawn(kind, move |progress| {
stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode,
japanese, progress,
&vanilla, &selected, &server, &mods, &disabled, &dev_mods, &assets, &res, &context,
unicode, japanese, progress,
)
.map_err(|e| e.to_string())
})?);
@@ -522,6 +523,7 @@ pub(super) fn stage_pack(
selected: &[String],
server: &[Pack],
mods_enabled: &BTreeSet<String>,
mods_disabled: &BTreeSet<String>,
dev_mods: &[PathBuf],
assets: &Arc<Assets>,
res: &Resources,
@@ -530,7 +532,7 @@ pub(super) fn stage_pack(
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let base = super::mods::content_root(vanilla_root, mods_enabled, dev_mods);
let base = super::mods::content_root(vanilla_root, mods_enabled, mods_disabled, dev_mods);
let root = startup_root(vanilla_root, &base, selected, server)?;
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
@@ -13,6 +13,7 @@ use std::time::{Duration, Instant};
mod face_buttons;
mod hid_layout;
mod sdl_row;
mod trigger_effects;
mod triggers;
/// The controllers gilrs sees and the DualSense's output.
@@ -576,7 +577,7 @@ impl Game<'_> {
};
let lights = self.settings.controller.lightbar != gamepad::Lightbar::Off;
let state = horizon_dualsense::device::State {
triggers: self.settings.controller.trigger_effects(using, attacking),
triggers: trigger_effects::effects(&self.settings.controller, using, attacking),
lightbar: lightbar.filter(|_| lights),
player_leds: lights.then_some(horizon_dualsense::PLAYER_1),
};
@@ -0,0 +1,109 @@
//! What a DualSense's adaptive triggers do for a [`TriggerFeel`] (docs/controllers.md "Adaptive
//! triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the others may
//! leave the firmware.
use horizon_client::gamepad::{Button, PadSettings, TriggerFeel};
use horizon_dualsense::{TRIGGER_OFF, TriggerEffect, trigger};
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub(super) fn effect(feel: TriggerFeel, threshold: f32) -> TriggerEffect {
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match feel {
TriggerFeel::Free => TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => TRIGGER_OFF,
}
}
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub(super) fn effects(
settings: &PadSettings,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|button| {
let actions = settings.actions_of(button);
let feel = if !settings.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
effect(feel, settings.trigger_threshold)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triggers_feel_their_action() {
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = effects(&s, TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
effects(&s, TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(effect(TriggerFeel::Click, 0.4), catch(7));
assert_eq!(effect(TriggerFeel::Boost, 0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(effect(TriggerFeel::Click, 0.7)[1..3], [0xa0, 0x00]);
assert_eq!(effect(TriggerFeel::Click, 0.1), trigger::weapon(2, 3, 8));
assert_eq!(effect(TriggerFeel::Brace, 0.4), trigger::feedback(2, 3));
assert_eq!(effect(TriggerFeel::Locked, 0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
effect(TriggerFeel::Texture(0.5), 0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
effect(TriggerFeel::Texture(3.0), 0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
effect(TriggerFeel::Texture(50.0), 0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(effect(TriggerFeel::Texture(0.0), 0.4), TRIGGER_OFF);
assert_eq!(effect(TriggerFeel::Texture(-1.0), 0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
effects(&s, TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
}
+20 -2
View File
@@ -13,7 +13,9 @@ use super::{
Game,
entity::{Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
use horizon_client::collisions::{
EntityCollisions, SceneCollisions, no_collision_empty, with_piston_noclip,
};
use horizon_client::motion::MotionWorld;
use horizon_core::geometry::{Aabb, Axis, Direction, ShapeOp, Vec3, border::BorderGeometry};
use horizon_core::position::BlockPos;
@@ -834,7 +836,23 @@ impl Game<'_> {
}
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement))
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
}
}
@@ -1,4 +1,5 @@
//! A key press while a world is open (`KeyboardHandler.keyPress` and the gameplay mappings).
use super::inventory_input::drop_whole_stack;
use super::*;
impl Game<'_> {
+40 -1
View File
@@ -2,7 +2,7 @@
//! game event when an armour or off-hand slot takes a different item, from
//! every path that calls it (use, the inventory menu, the creative screen,
//! the swap-hands key, dispensers), for the host and for LAN guests.
use super::Game;
use super::{Game, equipment_modifier_applies};
use horizon_client::items::ItemStack;
/// The equipment slots `onEquipItem` can act on, as `EquipmentSlot` names:
@@ -92,4 +92,43 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
}
/// Equipment attribute modifiers (LivingEntity.collectEquipmentChanges).
pub(super) fn apply_equipment(&mut self) {
let Some(player) = self.player.as_mut() else {
return;
};
for (attribute, id) in std::mem::take(&mut self.applied_modifiers) {
let _ = player.set_modifier_unclamped(&attribute, &id, 0.0, "add_value", false);
}
let slots = ["head", "chest", "legs", "feet"];
let mut equipped: Vec<(Option<&ItemStack>, &str)> = self
.armor
.iter()
.zip(slots)
.map(|(s, n)| (s.as_ref(), n))
.collect();
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
continue;
};
for (attribute, id, amount, op, group) in &info.modifiers {
// The attack code still reads the held item's attack damage and
// speed directly; the map copy is what `/attribute` reports.
if !equipment_modifier_applies(group, slot) {
continue;
}
if player
.set_modifier_unclamped(attribute, id, *amount, op, true)
.is_ok()
{
self.applied_modifiers.push((attribute.clone(), id.clone()));
}
}
}
let _ = player.clamp_health_to_max();
self.apply_enchantment_attributes();
}
}
+19 -7
View File
@@ -353,6 +353,24 @@ fn panic_text(payload: &(dyn std::any::Any + Send)) -> &str {
.unwrap_or("Box<dyn Any>")
}
/// Resolve vanilla and native content before applying the selected resource packs.
pub(super) fn startup_resource_root(
vanilla_root: &std::path::Path,
settings: &Settings,
dev_mods: &[PathBuf],
) -> (PathBuf, Option<String>) {
let base = mods::content_root(
vanilla_root,
&settings.mods.enabled,
&settings.mods.disabled,
dev_mods,
);
match packs::startup_root(vanilla_root, &base, &settings.resource_packs, &[]) {
Ok(root) => (root, None),
Err(error) => (base, Some(error.to_string())),
}
}
/// Each generation has its own channel and progress. Pack changes wait for the
/// old tail before starting another generation, including cache/global writes.
pub(super) fn spawn_loader(
@@ -371,13 +389,7 @@ pub(super) fn spawn_loader(
let handle = std::thread::Builder::new()
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let base = mods::content_root(&vanilla_root, &settings.mods.enabled, &dev_mods);
let root = packs::startup_root(&vanilla_root, &base, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
base.clone()
});
let (root, pack_error) = startup_resource_root(&vanilla_root, &settings, &dev_mods);
let result = load_resources(
root,
world.as_deref(),
@@ -223,7 +223,7 @@ impl Game<'_> {
blocks.push(BlockInfo {
pos: [x, y, z],
state,
nbt,
nbt: nbt.map(Box::new),
});
}
}
+7 -6
View File
@@ -959,12 +959,13 @@ impl Game<'_> {
is_singleplayer,
horizon_core::release::VERSION.into(),
);
match r
.session
.start_recording_with_init(&path, meta, |recorder| {
self.dump_world_snapshot_to_recorder(recorder);
}) {
Ok(()) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
match horizon_client::studio::StudioRecorder::start(path.clone(), meta) {
Ok(recorder) => {
r.session.start_recording(recorder, |recorder| {
self.dump_world_snapshot_to_recorder(recorder);
});
Ok(json!({ "ok": true, "path": path.display().to_string() }))
}
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
}
}
@@ -0,0 +1,42 @@
//! `Hud.extractRenderState`'s subtitle rule and `Screen.isInGameUi`.
use super::Screen;
/// `Screen.isInGameUi`: the screens that keep the game behind them — the
/// container screens (`AbstractContainerScreen`) and the block editors
/// (`AbstractCommandBlockEditScreen`, structure block, jigsaw, test block) —
/// plus the two Horizon draws over `Screen::Playing`: a book (`BookViewScreen`)
/// and a sign editor (`AbstractSignEditScreen`).
pub(super) fn in_game_ui(screen: Screen, book_or_sign: bool) -> bool {
book_or_sign
|| matches!(
screen,
Screen::Inventory | Screen::Creative | Screen::CommandBlock | Screen::BlockEditor
)
}
/// Where `Hud.extractRenderState` draws the subtitles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Subtitles {
/// Hidden with the rest of the HUD: F1 with no screen over the game.
Hidden,
/// In the HUD's own pass, under any screen that draws later.
Now,
/// Deferred (`Hud.extractDeferredSubtitles`): an in-game screen draws
/// them from `Screen.extractBackground`, under its slots and tooltips,
/// whether or not the HUD is hidden. Horizon draws them just before the
/// screen, which keeps that order (only the screen's dimming goes over them).
Deferred,
}
/// `Hud.extractRenderState`'s subtitle rule: with no in-game screen the
/// subtitles follow the HUD (`!isHidden`); with one they are deferred and
/// draw over it, which is the one HUD element F1 does not take away.
pub(super) fn subtitles(show_hud: bool, in_game_ui: bool) -> Subtitles {
if in_game_ui {
Subtitles::Deferred
} else if show_hud {
Subtitles::Now
} else {
Subtitles::Hidden
}
}
+34 -4
View File
@@ -1338,10 +1338,16 @@ mod tests {
)
.unwrap();
game.options.dedicated = Some(Vec::new());
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
// Entity ticking waits for every chunk within 2 columns
// (`ChunkMap.prepareEntityTickingChunk`).
for z in -2..=2 {
for x in -2..=2 {
game.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
}
}
game.streaming.forced.insert((0, 0));
Some(game)
}
@@ -1805,6 +1811,30 @@ mod tests {
game.particles.requested.as_ref().unwrap().len()
}
/// #13852: a chunk enters entity ticking only once every chunk within 2
/// columns is loaded (`ChunkMap.prepareEntityTickingChunk`), so TNT beside
/// a missing chunk keeps its fuse until the neighbourhood is whole.
#[test]
fn tnt_waits_while_a_chunk_two_columns_away_is_missing() {
let Some(mut game) = server_game() else {
return;
};
game.world.chunks.remove(&(2, 0));
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 80, None, None);
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
for _ in 0..3 {
game.tick_world(true).unwrap();
}
assert_eq!(game.tnts[0].fuse, 80, "no entity ticking yet");
game.world.chunks.insert(
(2, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(2, 0)),
);
game.tick_world(true).unwrap();
assert!(game.tnts[0].fuse < 80, "ticks once the chunk is back");
}
/// #10063: TNT lit between level ticks (flint and steel, a command) has its
/// first tick in the next level tick, so its fuse reads 79 after it, and it
/// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that
+78 -1
View File
@@ -28,6 +28,7 @@ pub(super) struct ThrownTrident {
pub(super) position: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
pub(super) needs_sync: bool,
pub(super) yaw: f32,
pub(super) pitch: f32,
pub(super) in_ground: bool,
@@ -176,6 +177,7 @@ impl ThrownTrident {
position,
previous: position,
velocity: Vec3::ZERO,
needs_sync: false,
yaw,
pitch,
in_ground: false,
@@ -202,6 +204,29 @@ impl ThrownTrident {
last_state: None,
}
}
/// `Entity.move(PISTON)`: the position only; `release_after_piston` follows
/// (`AbstractArrow.move`).
pub(super) fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
}
pub(super) fn piston_release_bounds(&self) -> Option<Aabb> {
if !self.in_ground || self.no_physics {
return None;
}
Some(Aabb::new(self.position, self.position).inflate(Vec3::new(0.06, 0.06, 0.06)))
}
pub(super) fn release_after_piston(&mut self, no_block_collision: bool) {
if self.in_ground && !self.no_physics && no_block_collision {
self.start_falling();
}
}
/// A slime block's bounce: the velocity only; the piston's move after it
/// releases a stuck trident (`AbstractArrow.move`).
pub(super) fn piston_slime_bounce(&mut self, movement: horizon_core::geometry::Direction) {
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
}
/// `AbstractArrow.onHitBlock`: plays `getHitGroundSoundEvent()`, then
/// `setSoundEvent(SoundEvents.ARROW_HIT)` for every later hit and the save.
fn hit_ground_sound(&mut self) -> String {
@@ -240,6 +265,9 @@ impl ThrownTrident {
let y = f64::from(self.random.next_float() * 0.2);
let z = f64::from(self.random.next_float() * 0.2);
self.velocity = Vec3::new(v.x * x, v.y * y, v.z * z);
// `this.life = 0`; and a LAN client hears of it.
self.in_ground_time = 0;
self.needs_sync = true;
}
/// `ThrownTrident.tick`'s Loyalty return: pulled up and towards the
/// owner's eye.
@@ -276,6 +304,8 @@ impl ThrownTrident {
self.in_ground = false;
self.loyalty_pull(eye);
}
// AbstractArrow.tick releases a changed block only on the server;
// clients release from the non-SELF move after a piston push.
if self.in_ground && !self.no_physics {
self.in_ground_time += 1;
return;
@@ -567,6 +597,7 @@ impl Game<'_> {
position: start,
previous: start,
velocity: projectile.velocity,
needs_sync: false,
yaw: projectile.yaw,
pitch: projectile.pitch,
in_ground: false,
@@ -1331,7 +1362,21 @@ horizon_client::impl_joined!(ThrownTrident);
#[cfg(test)]
mod tests {
use super::{ThrownTrident, breeze_deflect};
use horizon_core::geometry::Vec3;
use horizon_client::{mobs::Who, projectiles::ProjectileWorld};
use horizon_core::geometry::{Aabb, Vec3};
struct Air;
impl ProjectileWorld for Air {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Aabb) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
&[]
}
}
/// A trident summoned at rest still reads pitch 0 after its first tick
/// and -17.999998 after the second, when it starts falling; one summoned
@@ -1448,6 +1493,38 @@ mod tests {
assert_eq!(yaw, 185.0);
}
#[test]
fn remote_trident_waits_for_a_non_self_move_to_leave_its_block() {
let mut trident = ThrownTrident::remote(1, Vec3::new(0.5, 100.0, 0.5), 0.0, 0.0);
trident.in_ground = true;
trident.velocity = Vec3::new(1.0, -2.0, 3.0);
trident.remote_tick(&Air, None);
assert!(trident.in_ground);
assert_eq!(trident.velocity, Vec3::new(1.0, -2.0, 3.0));
}
#[test]
fn piston_move_releases_a_stuck_trident_with_start_falling_velocity() {
let mut trident = ThrownTrident::remote(1, Vec3::new(0.5, 100.0, 0.5), 0.0, 0.0);
trident.in_ground = true;
trident.velocity = Vec3::new(1.0, -2.0, 3.0);
let mut random = trident.random.clone();
let expected =
horizon_client::projectiles::start_falling_velocity(trident.velocity, &mut random);
trident.move_by_piston(Vec3::new(0.5, 0.0, 0.0));
assert!(trident.piston_release_bounds().is_some());
trident.release_after_piston(false);
assert!(trident.in_ground);
assert_eq!(trident.velocity, Vec3::new(1.0, -2.0, 3.0));
trident.release_after_piston(true);
assert!(!trident.in_ground);
assert_eq!(trident.velocity, expected);
assert!(trident.needs_sync);
assert!(trident.piston_release_bounds().is_none());
}
/// A stack can reach its final durability point after use began. The release
/// guard is independent of admission, and short charges still do nothing.
#[test]
+94
View File
@@ -0,0 +1,94 @@
//! Weather and sky light for the player's level: whether it can have weather, the rain and thunder
//! levels, where it rains or snows (`Level.isRainingAt`, the motion-blocking height), the weather's
//! effects each tick and `Level.getSkyDarken` with `isDarkOutside`.
use super::*;
impl Game<'_> {
/// `Level.isDarkOutside`.
pub(super) fn dark_outside(&self) -> bool {
!self.dimension_type().has_fixed_time && self.sky_darken() >= 4
}
pub(super) fn can_have_weather(&self) -> bool {
level_can_have_weather(
self.world.shape.sky_light,
self.world.shape.has_ceiling,
self.dim == Dim::End,
)
}
/// The rain and thunder levels (`Level.getRainLevel`, `getThunderLevel`):
/// the world's, where the level can have weather, else none.
pub(super) fn weather_levels(&self, partial: f32) -> (f32, f32) {
if self.can_have_weather() {
self.world.weather.levels(partial)
} else {
(0.0, 0.0)
}
}
pub(super) fn sky_darken(&self) -> i32 {
if self.dim == Dim::Nether {
return 11;
}
if self.dim == Dim::End {
let sky = self
.res
.end_environment()
.sample(&horizon_client::environment::Sample {
day_time: self.world.day_time,
..Default::default()
});
// Level.updateSkyBrightness truncates (`(int)`), see `environment::sky_darken`.
return (15.0 - 15.0 * sky.sky_light_level) as i32;
}
let (rain, thunder) = self.weather_levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
if let Some((k, darken)) = self.sky_darken_memo.get()
&& k == key
{
return darken;
}
let darken = horizon_client::environment::sky_darken(
self.res.environment(),
self.world.day_time,
rain,
thunder,
);
self.sky_darken_memo.set(Some((key, darken)));
darken
}
/// Whether precipitation at a column is snow (Biome.coldEnoughToSnow:
/// height adjusted temperature) or none (no precipitation).
pub(super) fn precipitation(&self, x: i32, y: i32, z: i32) -> Option<bool> {
let chunk = self.world.chunks.get(&(x >> 4, z >> 4))?;
self.res
.biomes()
.get(chunk.biome(x, y, z))
.snows_at(x, y, z)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
pub(super) fn raining_at(&self, p: BlockPos) -> bool {
self.weather_levels(1.0).0 > 0.2
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
&& self.rain_height(p.x, p.z) <= p.y
&& self.precipitation(p.x, p.y, p.z) == Some(false)
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
pub(super) fn rain_height(&self, x: i32, z: i32) -> i32 {
render_tick::motion_blocking_height(&self.world, &self.pack.assets.properties, x, z)
}
pub(super) fn tick_weather_effects(&mut self) {
if self.flash > 0 {
self.flash -= 1;
}
// The rain sounds come with the rain splashes (`tick_particles`).
}
}
@@ -144,6 +144,13 @@ impl Game<'_> {
/// The loop ended: close the pads' output, save the world, write the perf report.
pub(super) fn shut_down(&mut self) {
// Closing without an error before HDR output showed a frame (the window minimized, say)
// isn't HDR output taking the game down: the next start mustn't hold it back.
if self.error.is_none()
&& let Some(renderer) = &mut self.renderer
{
renderer.drop_hdr_marker();
}
self.end_screenshot_sequence(Err("cancelled: the game is closing".into()));
self.pad_close_output();
if let Err(e) = self.save_for_exit() {
@@ -21,6 +21,15 @@ pub fn slot_origin(width: i32, height: i32, index: usize) -> (i32, i32) {
(width / 2 - 59 + index as i32 * 31, height / 2 - 31)
}
/// `GameModeSwitcherScreen.mouseReleased`: `keyDebugModifier.matchesMouse`
/// picks the hovered mode and closes the screen, as the modifier key's own
/// release does in `keyReleased`. `button` and `modifier` are mapping key
/// names (`key.mouse.4`); a modifier bound to a keyboard key matches no mouse
/// release.
pub fn release_applies(button: &str, modifier: &str) -> bool {
modifier.starts_with("key.mouse.") && button == modifier
}
pub struct GameModeSwitcher {
pub selected: GameMode,
first_mouse: Option<(i32, i32)>,
@@ -116,4 +125,15 @@ mod tests {
state.hover(320, 240, (x + 25, y + 25));
assert_eq!(state.selected, GameMode::Adventure);
}
/// `GameModeSwitcherScreen.mouseReleased`: only the debug modifier's own
/// button applies the selection (and closes the screen). A modifier on
/// F3 matches no mouse button, and another button is an ordinary release.
#[test]
fn only_the_debug_modifiers_mouse_button_applies_on_release() {
assert!(release_applies("key.mouse.4", "key.mouse.4"));
assert!(!release_applies("key.mouse.4", "key.keyboard.f3"));
assert!(!release_applies("key.mouse.middle", "key.mouse.4"));
assert!(!release_applies("key.mouse.4", "key.mouse.middle"));
}
}
+26
View File
@@ -315,3 +315,29 @@ fn integrated_server_save_persists_owner_player_data() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `Hud.extractRenderState`: the subtitles follow the HUD when no screen
/// is over the game, are deferred over a screen that keeps the game
/// behind it (so F1 does not take them away), and vanish under F1 alone.
#[test]
fn the_subtitles_follow_the_hud_unless_a_screen_keeps_them() {
use super::{Screen, Subtitles, in_game_ui, subtitles};
// A container screen defers them above itself, hidden HUD or not.
assert_eq!(subtitles(true, true), Subtitles::Deferred);
assert_eq!(subtitles(false, true), Subtitles::Deferred);
// No such screen: in the HUD's pass, and gone under F1.
assert_eq!(subtitles(true, false), Subtitles::Now);
assert_eq!(subtitles(false, false), Subtitles::Hidden);
// `Screen.isInGameUi` is `AbstractContainerScreen` and the block
// editors' answer; a pause menu, the chat and the death screen say no.
assert!(in_game_ui(Screen::Inventory, false));
assert!(in_game_ui(Screen::Creative, false));
assert!(in_game_ui(Screen::CommandBlock, false));
assert!(in_game_ui(Screen::BlockEditor, false));
// A book and a sign editor are in-game UI drawn over `Playing`.
assert!(in_game_ui(Screen::Playing, true));
assert!(!in_game_ui(Screen::Paused, false));
assert!(!in_game_ui(Screen::Chat, false));
assert!(!in_game_ui(Screen::Dead, false));
assert!(!in_game_ui(Screen::Playing, false));
}
+118 -366
View File
@@ -7,10 +7,8 @@
//! (crate::auth): the login joins the server through the session service and
//! encrypts the connection (ClientHandshakePacketListenerImpl.handleHello).
use crate::auth::AuthSession;
use crate::studio::{Metadata, RecorderStatus, StudioRecorder};
use horizon_protocol::{
PROTOCOL_VERSION,
buf::Reader,
connection::{ConnReader, ConnWriter, Connection, Counters},
crypto::{generate_secret, offline_uuid, server_hash},
packets::{Empty, configuration as cfg, handshake, login, play},
@@ -38,6 +36,37 @@ use std::{
/// connection (`TransferState`), so a proxy's backend switch keeps them.
pub type Cookies = Arc<Mutex<HashMap<String, Vec<u8>>>>;
/// A recording of the session's packets: the tap a replay recorder plugs into. Replay recording is a
/// platform feature (docs/layers.md, "Controllers, splitscreen, replays and UI pages"); the engine only
/// hands it the packets: the network thread what it receives, the game thread what the client makes for
/// the recording itself (its own movement, pose and swings).
pub trait PacketRecorder: Send {
/// Whether it still takes packets (false once stopped or failed).
fn is_active(&self) -> bool;
/// A play packet the network thread received, decoded, with its id and payload as they came.
/// The recorder decides what it keeps.
fn received(&self, packet: &play::Clientbound, id: i32, payload: &[u8]);
/// A packet the client made for the recording, encoded, stamped now.
fn record_packet(&self, id: i32, payload: &[u8]);
fn status(&self) -> RecorderStatus;
/// Finishes the recording; the file it wrote.
fn stop(self: Box<Self>) -> std::io::Result<PathBuf>;
}
/// Where a recording stands.
#[derive(Clone, Debug, PartialEq)]
pub struct RecorderStatus {
pub active: bool,
pub path: PathBuf,
pub elapsed: Duration,
pub packet_count: u64,
pub bytes_written: u64,
pub error: Option<String>,
}
/// The session's recorder, shared with its network thread.
type RecorderSlot = Arc<Mutex<Option<Box<dyn PacketRecorder>>>>;
/// What the network thread reports to the game.
pub enum Event {
/// Progress text for the connecting screen.
@@ -116,8 +145,8 @@ pub struct Session {
debug: bool,
/// The far terrain requests waiting for the server (`lod_net`).
far: Arc<crate::lod_net::Inbox>,
/// Active packet recorder for Studio replay recordings.
pub recorder: Arc<Mutex<Option<StudioRecorder>>>,
/// The recording running, if any.
recorder: RecorderSlot,
}
impl Session {
/// The negotiated wire release, once the connection has been established.
@@ -308,31 +337,16 @@ impl Session {
pub fn poll(&self) -> Vec<Event> {
self.events.try_iter().collect()
}
/// Start recording network packets to `.mcpr` format at `path`.
pub fn start_recording(
/// Starts recording into `recorder`. `initialize` runs first, while the network thread
/// can't record, so what it writes (a snapshot of the world) comes before any live packet.
pub fn start_recording<R: PacketRecorder + 'static>(
&self,
path: impl Into<PathBuf>,
metadata: Metadata,
) -> std::io::Result<()> {
self.start_recording_with_init(path, metadata, |_| {})
}
/// Start recording and initialize the stream while live packet recording is
/// excluded. This lets callers write the initial snapshot before packets
/// arriving on the network thread.
pub fn start_recording_with_init(
&self,
path: impl Into<PathBuf>,
metadata: Metadata,
initialize: impl FnOnce(&StudioRecorder),
) -> std::io::Result<()> {
let rec = StudioRecorder::start(path.into(), metadata)?;
let mut recorder = self.recorder.lock().unwrap();
*recorder = Some(rec);
if let Some(recorder) = recorder.as_ref() {
initialize(recorder);
}
Ok(())
recorder: R,
initialize: impl FnOnce(&R),
) {
let mut slot = self.recorder.lock().unwrap();
initialize(&recorder);
*slot = Some(Box::new(recorder));
}
/// Stop recording and finalize the `.mcpr` archive.
pub fn stop_recording(&self) -> Option<std::io::Result<PathBuf>> {
@@ -351,25 +365,15 @@ impl Session {
pub fn recorder_status(&self) -> Option<RecorderStatus> {
self.recorder.lock().unwrap().as_ref().map(|r| r.status())
}
/// Record an explicit packet into the active recording session at `timestamp_ms`.
pub fn record_packet_at(&self, timestamp_ms: u32, id: i32, payload: &[u8]) {
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
rec.record_packet_at(timestamp_ms, id, payload);
}
}
/// Record a packet into the active recording session at current elapsed time.
pub fn record_packet(&self, id: i32, payload: &[u8]) {
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
rec.record_packet(id, payload);
}
}
/// Record an encoded Clientbound packet into the active recording session at current elapsed time.
pub fn record_clientbound(&self, packet: &horizon_protocol::packets::play::Clientbound) {
if self.is_recording() {
let encoded = packet.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
if let Ok(id) = reader.varint() {
self.record_packet(id, reader.rest());
if let Ok(id) = reader.varint()
&& let Some(rec) = self.recorder.lock().unwrap().as_ref()
{
rec.record_packet(id, reader.rest());
}
}
}
@@ -782,7 +786,7 @@ fn run(
transferred: bool,
resources: &Path,
lan: bool,
recorder: &Arc<Mutex<Option<StudioRecorder>>>,
recorder: &RecorderSlot,
) -> Result<Reason, Reason> {
let (host, port, mut c) = if let Some(pipe) = local {
(address.to_owned(), 0, Connection::local(pipe))
@@ -983,7 +987,7 @@ fn receive_play(
cookies: &Cookies,
far: &crate::lod_net::Inbox,
resources: &Path,
recorder: &Arc<Mutex<Option<StudioRecorder>>>,
recorder: &RecorderSlot,
) -> Result<Reason, Reason> {
loop {
let (id, payload) = reader
@@ -1002,14 +1006,10 @@ fn receive_play(
continue;
}
};
// Only while a recording runs: the filter copies what it keeps, and re-encodes
// AddEntity with a fresh UUID.
if let Some(rec) = recorder.lock().unwrap().as_ref()
&& rec.is_active()
&& let Some((recorded_id, recorded_payload)) =
studio_recording_packet(&packet, id, &payload)
{
rec.record_packet(recorded_id, &recorded_payload);
rec.received(&packet, id, &payload);
}
let reply = |p: play::Serverbound| {
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
@@ -1083,52 +1083,6 @@ fn receive_play(
}
}
/// Return the packet bytes safe to persist in a Studio recording.
///
/// Unknown packets fail closed so a newly added protocol packet cannot bypass
/// the privacy policy. `AddEntity` retains its entity id and state, but gets a
/// fresh UUID so the recording cannot link that entity to an account UUID.
fn studio_recording_packet<'a>(
packet: &play::Clientbound,
packet_id: i32,
payload: &'a [u8],
) -> Option<(i32, std::borrow::Cow<'a, [u8]>)> {
use play::Clientbound as P;
use std::borrow::Cow;
match packet {
P::AddEntity(entity) => {
let mut anonymous = entity.clone();
anonymous.uuid = u128::from_be_bytes(generate_secret().ok()?);
let encoded = P::AddEntity(anonymous).encode();
let mut reader = Reader::new(&encoded);
let id = reader.varint().ok()?;
Some((id, Cow::Owned(reader.rest().to_vec())))
}
P::CookieRequest(_)
| P::CustomChatCompletions(_)
| P::CustomPayload(_)
| P::DeleteChat(_)
| P::Disconnect(_)
| P::DisguisedChat(_)
| P::PlayerChat(_)
| P::PlayerCombatKill(_)
| P::PlayerInfoRemove(_)
| P::PlayerInfoUpdate(_)
| P::ResetScore(_)
| P::ResourcePackPush(_)
| P::ServerLinks(_)
| P::SetPlayerTeam(_)
| P::SetScore(_)
| P::StoreCookie(_)
| P::SystemChat(_)
| P::TabList(_)
| P::TrackedWaypoint(_)
| P::Transfer(_)
| P::Unknown(..) => None,
_ => Some((packet_id, Cow::Borrowed(payload))),
}
}
/// The configuration phase: registries, tags and known packs until the
/// server finishes it (ClientConfigurationPacketListenerImpl).
fn configure(
@@ -1626,170 +1580,6 @@ mod tests {
assert_eq!(line("[::1]:25566"), "Connecting to ::1, 25566");
}
#[test]
fn studio_recording_drops_packets_with_direct_player_identity_or_private_text() {
use horizon_protocol::packets::configuration::{
ResourcePackPush, ServerLink, ServerLinkKind, ServerLinks, StoreCookie, Transfer,
};
use play::{
ChatTypeBound, Clientbound as P, CustomChatCompletions, PlayerInfo, PlayerInfoRemove,
PlayerInfoUpdate, ResetScore, SetPlayerTeam, SetScore, TrackedWaypoint, WaypointId,
WaypointTarget,
};
let packets = [
P::CookieRequest(cfg::CookieRequest {
key: "private_cookie".into(),
}),
P::CustomChatCompletions(CustomChatCompletions {
action: 2,
entries: vec!["/msg PlayerName private text".into()],
}),
P::CustomPayload(cfg::CustomPayload {
channel: "private:channel".into(),
data: b"private payload".to_vec(),
}),
P::DeleteChat(play::DeleteChat {
signature: (3, Some(vec![1, 2, 3])),
}),
P::DisguisedChat(play::DisguisedChat {
message: Tag::String("private message".into()),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::PlayerCombatKill(play::PlayerCombatKill {
id: 7,
message: Tag::String("PlayerName was slain".into()),
}),
P::PlayerChat(play::PlayerChat {
global_index: 1,
sender: 7,
index: 2,
signature: None,
content: "private message".into(),
timestamp: 3,
salt: 4,
last_seen: vec![],
unsigned_content: None,
filter: (0, vec![]),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::ResetScore(ResetScore {
owner: "PlayerName".into(),
objective: Some("deaths".into()),
}),
P::SetScore(SetScore {
owner: "PlayerName".into(),
objective: "deaths".into(),
score: 1,
display: None,
number_format: None,
}),
P::PlayerInfoRemove(PlayerInfoRemove { uuids: vec![7] }),
P::PlayerInfoUpdate(PlayerInfoUpdate {
actions: 1,
entries: vec![PlayerInfo {
uuid: 7,
name: Some("PlayerName".into()),
..PlayerInfo::default()
}],
}),
P::ResourcePackPush(ResourcePackPush {
id: 8,
url: "https://private.example/pack.zip".into(),
hash: "hash".into(),
required: false,
prompt: None,
}),
P::ServerLinks(ServerLinks {
links: vec![ServerLink {
kind: ServerLinkKind::Known(0),
link: "https://private.example".into(),
}],
}),
P::SetPlayerTeam(SetPlayerTeam {
name: "red".into(),
method: 3,
parameters: None,
players: vec!["PlayerName".into()],
}),
P::StoreCookie(StoreCookie {
key: "private_cookie".into(),
payload: b"secret".to_vec(),
}),
P::SystemChat(play::SystemChat {
content: Tag::String("private message".into()),
overlay: false,
}),
P::TabList(play::TabList {
header: Tag::String("PlayerName".into()),
footer: Tag::End,
}),
P::TrackedWaypoint(TrackedWaypoint {
operation: play::waypoint_operation::TRACK,
id: WaypointId::Uuid(7),
style: "minecraft:default".into(),
color: None,
target: WaypointTarget::Empty,
}),
P::Transfer(Transfer {
host: "private.example".into(),
port: 25565,
}),
P::Disconnect(cfg::Disconnect {
reason: Tag::String("PlayerName was disconnected".into()),
}),
P::Unknown(999, b"unclassified private payload".to_vec()),
];
for packet in packets {
assert!(
studio_recording_packet(&packet, packet.id(), b"raw packet payload").is_none(),
"{} should be excluded from recordings",
packet.name()
);
}
}
#[test]
fn studio_recording_replaces_entity_uuids_but_keeps_entity_state() {
let original = play::AddEntity {
id: 18,
uuid: 0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10,
kind: 42,
position: [1.0, 64.0, -2.0],
velocity: [0.0, 0.0, 0.0],
pitch: 1,
yaw: 2,
head_yaw: 3,
data: 4,
};
let packet = play::Clientbound::AddEntity(original.clone());
let (id, payload) = studio_recording_packet(&packet, packet.id(), b"unused").unwrap();
let play::Clientbound::AddEntity(recorded) =
play::Clientbound::decode(id, &payload).unwrap()
else {
panic!("sanitized packet changed type")
};
assert_ne!(recorded.uuid, original.uuid);
assert_eq!(recorded.id, original.id);
assert_eq!(recorded.kind, original.kind);
assert_eq!(recorded.position, original.position);
assert_eq!(recorded.velocity, original.velocity);
assert_eq!(recorded.pitch, original.pitch);
assert_eq!(recorded.yaw, original.yaw);
assert_eq!(recorded.head_yaw, original.head_yaw);
assert_eq!(recorded.data, original.data);
}
#[test]
fn direct_login_preserves_hostname_and_negotiates_brand() {
for server_brand in [
@@ -2415,8 +2205,45 @@ mod tests {
}
}
/// A recorder that keeps what reaches it, as (id, payload).
#[derive(Clone, Default)]
struct TestRecorder(Arc<Mutex<Vec<(i32, Vec<u8>)>>>);
impl PacketRecorder for TestRecorder {
fn is_active(&self) -> bool {
true
}
fn received(&self, _packet: &play::Clientbound, id: i32, payload: &[u8]) {
self.0.lock().unwrap().push((id, payload.to_vec()));
}
fn record_packet(&self, id: i32, payload: &[u8]) {
self.0.lock().unwrap().push((id, payload.to_vec()));
}
fn status(&self) -> RecorderStatus {
RecorderStatus {
active: true,
path: PathBuf::from("test.mcpr"),
elapsed: Duration::ZERO,
packet_count: self.0.lock().unwrap().len() as u64,
bytes_written: 0,
error: None,
}
}
fn stop(self: Box<Self>) -> std::io::Result<PathBuf> {
Ok(PathBuf::from("test.mcpr"))
}
}
impl TestRecorder {
fn decoded(&self) -> Vec<play::Clientbound> {
let packets = self.0.lock().unwrap();
packets
.iter()
.map(|(id, payload)| play::Clientbound::decode(*id, payload).unwrap())
.collect()
}
}
#[test]
fn session_studio_recording_tap_records_packets_to_mcpr() {
fn session_recorder_gets_the_play_packets_the_network_thread_receives() {
let (client, server) = horizon_protocol::connection::Pipe::pair();
let (mut reader, writer) = Connection::local(client).split();
let (send, events) = channel();
@@ -2433,20 +2260,8 @@ mod tests {
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
};
let temp_dir = std::env::temp_dir().join(format!(
"horizon-studio-test-{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_tap.mcpr");
let meta = Metadata::new("Singleplayer World".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
assert!(session.is_recording());
let (thread_writer, thread_configuring, thread_recorder) = (
@@ -2478,54 +2293,36 @@ mod tests {
relatives: 0,
});
server.send(&pos_pkt.encode()).unwrap();
// Disconnect ends receive_play loop
// Disconnect ends receive_play's loop.
let disc_pkt = play::Clientbound::Disconnect(cfg::Disconnect {
reason: Tag::String("Replay tap test completed".into()),
});
server.send(&disc_pkt.encode()).unwrap();
let _ = thread.join().unwrap();
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert_eq!(
session.stop_recording().unwrap().unwrap(),
PathBuf::from("test.mcpr")
);
assert!(!session.is_recording());
assert!(mcpr_path.exists());
// Verify that the recording archive contains the clientbound packets.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 1);
assert_eq!(recording.metadata.server_name, "Singleplayer World");
assert!(recording.metadata.singleplayer);
assert!(recording.metadata.players.is_empty());
let packet1 = recording.next_packet().unwrap().unwrap();
assert!(recording.next_packet().unwrap().is_none());
let decoded1 = play::Clientbound::decode(packet1.id, &packet1.payload).unwrap();
assert_eq!(decoded1, pos_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
// The recorder is handed both; what a recording keeps is the recorder's choice.
assert_eq!(recorder.decoded(), [pos_pkt, disc_pkt]);
}
#[test]
fn studio_session_record_local_player_motion() {
use crate::studio::Metadata;
use horizon_protocol::packets::play;
fn session_recorder_gets_the_packets_the_client_makes() {
use horizon_protocol::entity_data::{Value, serializer};
let session = Session::replay("StudioTester");
let temp_dir =
std::env::temp_dir().join(format!("horizon-studio-motion-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_motion.mcpr");
let meta = Metadata::new("local".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
assert!(session.is_recording());
// Simulate local player movement packet recorded during recording session
let pos_pkt = play::Clientbound::PlayerPosition(play::PlayerPosition {
session.record_clientbound(&play::Clientbound::SwingAnimation(play::SwingAnimation {
id: 1,
hand: 0,
kind: 0,
duration: 6,
}));
let recorder = TestRecorder::default();
// What `initialize` writes comes first.
let snapshot = play::Clientbound::PlayerPosition(play::PlayerPosition {
teleport: 0,
position: [12.5, 64.0, -88.5],
delta: [0.0; 3],
@@ -2533,43 +2330,12 @@ mod tests {
pitch: 15.0,
relatives: 0,
});
let encoded = pos_pkt.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
let id = reader.varint().unwrap();
session.record_packet(id, reader.rest());
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert!(!session.is_recording());
// Verify the recording preserves the motion packet for replay playback.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 1);
let packet = recording.next_packet().unwrap().unwrap();
let decoded = play::Clientbound::decode(packet.id, &packet.payload).unwrap();
assert_eq!(decoded, pos_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn studio_session_record_local_player_animations() {
use crate::studio::Metadata;
use horizon_protocol::entity_data::{Value, serializer};
use horizon_protocol::packets::play;
let session = Session::replay("StudioTester");
let temp_dir =
std::env::temp_dir().join(format!("horizon-studio-anim-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_anim.mcpr");
let meta = Metadata::new("local".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
assert!(session.is_recording());
// 1. Arm swing animation
session.start_recording(recorder.clone(), |rec| {
let encoded = snapshot.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
let id = reader.varint().unwrap();
rec.record_packet(id, reader.rest());
});
let swing_pkt = play::Clientbound::SwingAnimation(play::SwingAnimation {
id: 42,
hand: 0,
@@ -2577,8 +2343,6 @@ mod tests {
duration: 6,
});
session.record_clientbound(&swing_pkt);
// 2. Crouch and sprint entity metadata
let data_pkt = play::Clientbound::SetEntityData(play::SetEntityData {
id: 42,
values: vec![
@@ -2587,24 +2351,12 @@ mod tests {
],
});
session.record_clientbound(&data_pkt);
assert_eq!(session.recorder_status().unwrap().packet_count, 3);
session.stop_recording().unwrap().unwrap();
// Nothing reaches a stopped recording.
session.record_clientbound(&swing_pkt);
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert!(!session.is_recording());
// Verify the recording preserves both animation packets for playback.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 2);
let packets = [
recording.next_packet().unwrap().unwrap(),
recording.next_packet().unwrap().unwrap(),
];
let decoded_swing = play::Clientbound::decode(packets[0].id, &packets[0].payload).unwrap();
assert_eq!(decoded_swing, swing_pkt);
let decoded_data = play::Clientbound::decode(packets[1].id, &packets[1].payload).unwrap();
assert_eq!(decoded_data, data_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
assert_eq!(recorder.decoded(), [snapshot, swing_pkt, data_pkt]);
}
#[test]
+75
View File
@@ -11,6 +11,17 @@ use horizon_core::{
random::RandomSource,
};
/// `AbstractArrow.startFalling`'s per-axis random velocity damping.
pub fn start_falling_velocity(
velocity: Vec3,
random: &mut horizon_core::random::LegacyRandom,
) -> Vec3 {
let x = f64::from(random.next_float() * 0.2);
let y = f64::from(random.next_float() * 0.2);
let z = f64::from(random.next_float() * 0.2);
Vec3::new(velocity.x * x, velocity.y * y, velocity.z * z)
}
/// a content mod's thrown item projectile, a copy of `Snowball` that deals
/// `damage` and flies as its `item`.
#[derive(Debug, PartialEq)]
@@ -856,6 +867,38 @@ impl Projectile {
self.previous_yaw = self.yaw;
self.previous_pitch = self.pitch;
}
/// `Entity.move(PISTON)`: the position only; `release_after_piston` follows
/// (`AbstractArrow.move`).
pub fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
}
pub fn piston_release_bounds(&self) -> Option<Aabb> {
let arrow = self.arrow.as_ref()?;
if !arrow.in_ground || arrow.no_physics {
return None;
}
Some(Aabb::new(self.position, self.position).inflate(Vec3::new(0.06, 0.06, 0.06)))
}
pub fn release_after_piston(&mut self, no_block_collision: bool) {
let Some(arrow) = self.arrow.as_mut() else {
return;
};
if arrow.in_ground && !arrow.no_physics && no_block_collision {
self.velocity = start_falling_velocity(self.velocity, &mut arrow.random);
arrow.in_ground = false;
arrow.life = 0;
self.stuck = false;
self.needs_sync = true;
}
}
/// `PistonMovingBlockEntity.moveCollidedEntities` on a slime block: the
/// velocity along the axis. The piston's move after it (`AbstractArrow.move`)
/// is what takes a stuck arrow out, with `startFalling`'s random damping.
pub fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
}
/// `ShulkerBullet.<init>(level, owner, target, invalidStartAxis)`: a
/// shulker's bullet at `at`, the centre of its owner's box
/// (`owner.getBoundingBox().getCenter()`), toward `target`, first leaving
@@ -1726,6 +1769,38 @@ mod tests {
p.tick_remote(&Empty(vec![]));
assert_eq!(p.velocity.y, -0.03 * f64::from(0.8_f32));
}
#[test]
fn piston_move_releases_a_stuck_arrow_with_start_falling_velocity() {
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::new(0.5, 100.0, 0.5),
None,
);
p.velocity = Vec3::new(1.0, -2.0, 3.0);
p.stuck = true;
p.arrow.as_mut().unwrap().in_ground = true;
let mut random = p.arrow.as_ref().unwrap().random.clone();
let expected = start_falling_velocity(p.velocity, &mut random);
p.move_by_piston(Vec3::new(0.5, 0.0, 0.0));
assert!(p.piston_release_bounds().is_some());
p.release_after_piston(false);
assert!(p.arrow.as_ref().unwrap().in_ground);
assert_eq!(p.velocity, Vec3::new(1.0, -2.0, 3.0));
p.release_after_piston(true);
let arrow = p.arrow.as_ref().unwrap();
assert!(!arrow.in_ground);
assert_eq!(p.velocity, expected);
assert!(!p.stuck);
assert!(p.needs_sync);
assert!(p.piston_release_bounds().is_none());
}
#[test]
fn first_tick_shoot_event_uses_base_tick_position_and_saved_flag() {
let w = Empty(vec![]);
+238 -12
View File
@@ -4,6 +4,9 @@
//! thread so gameplay frame rates and latency are unaffected.
use super::mcpr::{McprWriter, Metadata, TimestampedPacket};
use crate::net::PacketRecorder;
pub use crate::net::RecorderStatus;
use horizon_protocol::{buf::Reader, crypto::generate_secret, packets::play};
use std::{
io,
path::{Path, PathBuf},
@@ -13,7 +16,7 @@ use std::{
mpsc::{Receiver, SyncSender, TrySendError, sync_channel},
},
thread::{self, JoinHandle},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
time::{Instant, SystemTime, UNIX_EPOCH},
};
static RECORDING_NAME_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -26,17 +29,6 @@ enum RecordCommand {
const RECORD_QUEUE_PACKETS: usize = 2048;
/// Status summary of the live recording.
#[derive(Clone, Debug, PartialEq)]
pub struct RecorderStatus {
pub active: bool,
pub path: PathBuf,
pub elapsed: Duration,
pub packet_count: u64,
pub bytes_written: u64,
pub error: Option<String>,
}
/// Asynchronous packet recorder that writes `.mcpr` sessions on a worker thread.
pub struct StudioRecorder {
active: Arc<AtomicBool>,
@@ -196,6 +188,74 @@ impl StudioRecorder {
}
}
impl PacketRecorder for StudioRecorder {
fn is_active(&self) -> bool {
StudioRecorder::is_active(self)
}
/// Keeps what [`recordable`] lets through: the filter copies what it keeps, and re-encodes
/// AddEntity with a fresh UUID.
fn received(&self, packet: &play::Clientbound, id: i32, payload: &[u8]) {
if let Some((id, payload)) = recordable(packet, id, payload) {
StudioRecorder::record_packet(self, id, &payload);
}
}
fn record_packet(&self, id: i32, payload: &[u8]) {
StudioRecorder::record_packet(self, id, payload);
}
fn status(&self) -> RecorderStatus {
StudioRecorder::status(self)
}
fn stop(self: Box<Self>) -> io::Result<PathBuf> {
StudioRecorder::stop(*self)
}
}
/// Return the packet bytes safe to persist in a Studio recording.
///
/// Unknown packets fail closed so a newly added protocol packet cannot bypass
/// the privacy policy. `AddEntity` retains its entity id and state, but gets a
/// fresh UUID so the recording cannot link that entity to an account UUID.
fn recordable<'a>(
packet: &play::Clientbound,
packet_id: i32,
payload: &'a [u8],
) -> Option<(i32, std::borrow::Cow<'a, [u8]>)> {
use play::Clientbound as P;
use std::borrow::Cow;
match packet {
P::AddEntity(entity) => {
let mut anonymous = entity.clone();
anonymous.uuid = u128::from_be_bytes(generate_secret().ok()?);
let encoded = P::AddEntity(anonymous).encode();
let mut reader = Reader::new(&encoded);
let id = reader.varint().ok()?;
Some((id, Cow::Owned(reader.rest().to_vec())))
}
P::CookieRequest(_)
| P::CustomChatCompletions(_)
| P::CustomPayload(_)
| P::DeleteChat(_)
| P::Disconnect(_)
| P::DisguisedChat(_)
| P::PlayerChat(_)
| P::PlayerCombatKill(_)
| P::PlayerInfoRemove(_)
| P::PlayerInfoUpdate(_)
| P::ResetScore(_)
| P::ResourcePackPush(_)
| P::ServerLinks(_)
| P::SetPlayerTeam(_)
| P::SetScore(_)
| P::StoreCookie(_)
| P::SystemChat(_)
| P::TabList(_)
| P::TrackedWaypoint(_)
| P::Transfer(_)
| P::Unknown(..) => None,
_ => Some((packet_id, Cow::Borrowed(payload))),
}
}
impl Drop for StudioRecorder {
fn drop(&mut self) {
self.active.store(false, Ordering::SeqCst);
@@ -242,8 +302,174 @@ pub fn generate_recording_filename(name: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::packets::configuration as cfg;
use horizon_world::nbt::Tag;
use std::fs;
#[test]
fn studio_recording_drops_packets_with_direct_player_identity_or_private_text() {
use horizon_protocol::packets::configuration::{
ResourcePackPush, ServerLink, ServerLinkKind, ServerLinks, StoreCookie, Transfer,
};
use play::{
ChatTypeBound, Clientbound as P, CustomChatCompletions, PlayerInfo, PlayerInfoRemove,
PlayerInfoUpdate, ResetScore, SetPlayerTeam, SetScore, TrackedWaypoint, WaypointId,
WaypointTarget,
};
let packets = [
P::CookieRequest(cfg::CookieRequest {
key: "private_cookie".into(),
}),
P::CustomChatCompletions(CustomChatCompletions {
action: 2,
entries: vec!["/msg PlayerName private text".into()],
}),
P::CustomPayload(cfg::CustomPayload {
channel: "private:channel".into(),
data: b"private payload".to_vec(),
}),
P::DeleteChat(play::DeleteChat {
signature: (3, Some(vec![1, 2, 3])),
}),
P::DisguisedChat(play::DisguisedChat {
message: Tag::String("private message".into()),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::PlayerCombatKill(play::PlayerCombatKill {
id: 7,
message: Tag::String("PlayerName was slain".into()),
}),
P::PlayerChat(play::PlayerChat {
global_index: 1,
sender: 7,
index: 2,
signature: None,
content: "private message".into(),
timestamp: 3,
salt: 4,
last_seen: vec![],
unsigned_content: None,
filter: (0, vec![]),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::ResetScore(ResetScore {
owner: "PlayerName".into(),
objective: Some("deaths".into()),
}),
P::SetScore(SetScore {
owner: "PlayerName".into(),
objective: "deaths".into(),
score: 1,
display: None,
number_format: None,
}),
P::PlayerInfoRemove(PlayerInfoRemove { uuids: vec![7] }),
P::PlayerInfoUpdate(PlayerInfoUpdate {
actions: 1,
entries: vec![PlayerInfo {
uuid: 7,
name: Some("PlayerName".into()),
..PlayerInfo::default()
}],
}),
P::ResourcePackPush(ResourcePackPush {
id: 8,
url: "https://private.example/pack.zip".into(),
hash: "hash".into(),
required: false,
prompt: None,
}),
P::ServerLinks(ServerLinks {
links: vec![ServerLink {
kind: ServerLinkKind::Known(0),
link: "https://private.example".into(),
}],
}),
P::SetPlayerTeam(SetPlayerTeam {
name: "red".into(),
method: 3,
parameters: None,
players: vec!["PlayerName".into()],
}),
P::StoreCookie(StoreCookie {
key: "private_cookie".into(),
payload: b"secret".to_vec(),
}),
P::SystemChat(play::SystemChat {
content: Tag::String("private message".into()),
overlay: false,
}),
P::TabList(play::TabList {
header: Tag::String("PlayerName".into()),
footer: Tag::End,
}),
P::TrackedWaypoint(TrackedWaypoint {
operation: play::waypoint_operation::TRACK,
id: WaypointId::Uuid(7),
style: "minecraft:default".into(),
color: None,
target: WaypointTarget::Empty,
}),
P::Transfer(Transfer {
host: "private.example".into(),
port: 25565,
}),
P::Disconnect(cfg::Disconnect {
reason: Tag::String("PlayerName was disconnected".into()),
}),
P::Unknown(999, b"unclassified private payload".to_vec()),
];
for packet in packets {
assert!(
recordable(&packet, packet.id(), b"raw packet payload").is_none(),
"{} should be excluded from recordings",
packet.name()
);
}
}
#[test]
fn studio_recording_replaces_entity_uuids_but_keeps_entity_state() {
let original = play::AddEntity {
id: 18,
uuid: 0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10,
kind: 42,
position: [1.0, 64.0, -2.0],
velocity: [0.0, 0.0, 0.0],
pitch: 1,
yaw: 2,
head_yaw: 3,
data: 4,
};
let packet = play::Clientbound::AddEntity(original.clone());
let (id, payload) = recordable(&packet, packet.id(), b"unused").unwrap();
let play::Clientbound::AddEntity(recorded) =
play::Clientbound::decode(id, &payload).unwrap()
else {
panic!("sanitized packet changed type")
};
assert_ne!(recorded.uuid, original.uuid);
assert_eq!(recorded.id, original.id);
assert_eq!(recorded.kind, original.kind);
assert_eq!(recorded.position, original.position);
assert_eq!(recorded.velocity, original.velocity);
assert_eq!(recorded.pitch, original.pitch);
assert_eq!(recorded.yaw, original.yaw);
assert_eq!(recorded.head_yaw, original.head_yaw);
assert_eq!(recorded.data, original.data);
}
#[test]
fn test_studio_recorder_lifecycle() {
let temp_dir = std::env::temp_dir().join(format!(
@@ -186,6 +186,10 @@ impl Levels {
fn get(&self, x: i32, z: i32) -> Option<(u8, u8)> {
Some(self.0.as_ref()?.get(x, z))
}
/// Whether any ticket exists; with none, everything ticks.
pub fn has_tickets(&self) -> bool {
self.0.is_some()
}
/// `DistanceManager.inEntityTickingRange`, in an entity-ticking chunk:
/// where entities tick.
pub fn entity_ticking(&self, x: i32, z: i32) -> bool {
@@ -252,9 +256,41 @@ pub struct ChunkTickets {
sources: Option<Sources>,
levels: Levels,
portal: Vec<PortalTicket>,
/// The resident chunk keys at one membership revision.
resident: std::sync::Mutex<(u64, Arc<crate::fast_hash::FxHashSet<(i32, i32)>>)>,
}
impl ChunkTickets {
/// `Levels::entity_ticking_at`, held back as `ChunkMap.prepareEntityTickingChunk`
/// does: a chunk waits until every chunk within 2 columns is loaded. With no
/// tickets everything ticks.
pub fn entity_ticking_area(
&self,
chunks: &crate::chunk_map::ChunkMap,
gate: bool,
) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.levels.clone();
let resident = (gate && levels.0.is_some()).then(|| {
let revision = chunks.membership_revision();
let mut cache = self.resident.lock().unwrap_or_else(|e| e.into_inner());
if cache.0 != revision {
*cache = (revision, Arc::new(chunks.keys().copied().collect()));
}
cache.1.clone()
});
// Callers ask once per entity: remember each chunk's answer, so an
// entity costs one lookup instead of the 25 of its neighbourhood.
let surrounded = std::cell::RefCell::new(crate::fast_hash::FxHashMap::default());
move |x, z| {
levels.entity_ticking_at(x, z)
&& resident.as_ref().is_none_or(|keys| {
let (cx, cz) = (x >> 4, z >> 4);
*surrounded.borrow_mut().entry((cx, cz)).or_insert_with(|| {
(cz - 2..=cz + 2).all(|z| (cx - 2..=cx + 2).all(|x| keys.contains(&(x, z))))
})
})
}
}
/// `None`: tick everywhere.
pub fn update(&mut self, mut sources: Option<Sources>) {
// The game submits a fresh `Sources` value every tick even when no
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-game"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "The game as the platform runs it: the engine's entry with the platform's parts registered, for an executable to start (docs/layers.md)"
[dependencies]
horizon-client = { path = "../horizon-client" }
horizon-threads = { path = "../horizon-threads" }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
[lints]
workspace = true
+35
View File
@@ -0,0 +1,35 @@
//! The game as the platform runs it, for an executable to start (docs/layers.md, "How a Horizon
//! crate plugs in"). The official executable (`horizon-official`, Horizon layer) registers its
//! hooks (`horizon_platform::hooks`) and then calls [`main`]; it never names an engine crate.
//!
//! An executable built on it:
//!
//! ```ignore
//! #[global_allocator]
//! static ALLOCATOR: horizon_game::Allocator = horizon_game::ALLOCATOR;
//! static MOD_API_LEVEL: [u8; horizon_game::MOD_API_LEVEL.len()] = horizon_game::MOD_API_LEVEL;
//!
//! fn main() {
//! std::hint::black_box(&MOD_API_LEVEL);
//! my_crate::register();
//! horizon_game::main();
//! }
//! ```
//!
//! The platform's own parts register here, in [`main`], as they leave the engine (layer plan M6).
#![forbid(unsafe_code)]
/// The game's allocator (mimalloc, with its thread tags), for the executable's
/// `#[global_allocator]`.
pub use horizon_threads::{ALLOCATOR, Allocator};
/// The mod API level this game serves. The executable keeps it in the program, where the
/// launcher reads it without running it (`horizon_mods::api_level::MARK`): a `static` its
/// `main` reads, so the linker keeps it.
pub const MOD_API_LEVEL: [u8; horizon_mods::api_level::MARK.len()] = horizon_mods::api_level::MARK;
/// Runs the game with the hooks registered so far, and returns when it ends. Register hooks
/// (`horizon_platform::hooks::register`) before calling it.
pub fn main() {
horizon_client::entry::main();
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-heartbeat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "The anonymous \"I'm playing\" ping behind the launcher's \"N playing now\" (docs/telemetry.md)"
[dependencies]
horizon-platform = { path = "../horizon-platform" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
log = "0.4"
[lints]
workspace = true
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff. Show more