mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
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:
commit
daf537e0d4
293 files changed
+10331
-2691
No files matched your search
@@ -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')) {
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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)" \
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
File renamed without changes.
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user