Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 27740e2e8f geometry.rs: GbuffersInputs only in its tests; lockfiles: the pipeline crate takes naga
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:06 +01:00
Horizon agentandClaude Opus 5.5 a7d211a1fc Translated programs' types move to horizon-render-pipeline
A translated program as the renderer builds pipelines from it (Program, ComputeProgram,
Interface, Uniform, Sampler, Image, Target, Error, DRAW_UNIFORMS) and the vertex inputs the
renderer offers it (GbuffersInputs, GeometryInputs, AlphaTest, WATER_MASK, WATER_PATCHES) move
unchanged from horizon-shaders' translator, which re-exports them. The renderer's library code
takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:42:43 +01:00
Ludovic Marie 7ae28b9d3e Merge branch 'split/render-pipeline-stages' into split/render-pipeline-textures 2026-11-09 16:35:58 +01:00
Ludovic Marie 0b7d3f8105 Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:35:02 +01:00
Horizon agentandClaude Opus 5.5 950c368a3c Custom textures and render stages move to horizon-render-pipeline
The textures a pipeline's programs read (CustomTexture, Texture, Texels, TextureSource, Dim,
EVERY_STAGE) and the render stages they are told they draw in (RENDER_STAGES, render_stage)
move unchanged from horizon-shaders, which still reads them from a pack and re-exports them.
The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:07 +01:00
Horizon agentandClaude Opus 5.5 a6093def90 launcher and registry lockfiles: horizon-shader-api depends on horizon-render-pipeline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:43 +01:00
Horizon agentandClaude Opus 5.5 80aa1a5b06 Cargo.lock: horizon-shader-api takes bytemuck through the pipeline crate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:20:23 +01:00
Ludovic Marie 8da02c530e Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:20:23 +01:00
Horizon agentandClaude Opus 5.5 a23f327048 WGSL stage interface moves to horizon-render-pipeline
The stages Horizon's own packs fill, their preludes, bindings and entry points (stages.rs),
the frame uniform the renderer uploads (inputs.rs) and the API lines (Api) are the renderer's
interface, so they move unchanged into the engine's pipeline crate. horizon-shader-api keeps
reading, composing and checking packs against them and re-exports them under the same paths
(launcher and registry unchanged). The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:58:12 +01:00
328 changed files with 4723 additions and 10539 deletions

No files matched your search

+5 -5
View File
@@ -217,17 +217,17 @@ jobs:
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (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: 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: Check the game's PE machine
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
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
- name: Check game DLL imports
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
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
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.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')) {
+2 -7
View File
@@ -168,14 +168,9 @@ 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 official game, horizon-ctl, horizon-prepare and horizon-server
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-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 }}"
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
+3 -3
View File
@@ -181,7 +181,7 @@ jobs:
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
"${arm64[@]}" "${musl[@]}" \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -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)" \
-3
View File
@@ -44,8 +44,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **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.
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Crates
@@ -81,7 +79,6 @@ 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` |
Generated
+8 -37
View File
@@ -1450,7 +1450,6 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1501,6 +1500,7 @@ 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,6 +1588,7 @@ dependencies = [
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
@@ -1676,15 +1677,6 @@ 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"
@@ -1696,19 +1688,6 @@ 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"
@@ -1789,17 +1768,6 @@ 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"
@@ -1814,7 +1782,6 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-hooks",
"horizon-system",
]
@@ -1889,6 +1856,10 @@ dependencies = [
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
]
[[package]]
name = "horizon-server"
@@ -1938,7 +1909,7 @@ dependencies = [
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"horizon-render-pipeline",
"naga",
"png",
"serde",
-4
View File
@@ -30,10 +30,6 @@ 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",
+2 -3
View File
@@ -129,9 +129,8 @@ 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` 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,
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,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
-1
View File
@@ -90,7 +90,6 @@ impl Report {
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
-7
View File
@@ -377,13 +377,6 @@ 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 = [
+3 -1
View File
@@ -13,9 +13,11 @@ 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 = []
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
+109 -2
View File
@@ -1523,8 +1523,9 @@ impl Rumble {
}
}
/// 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.
/// 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.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TriggerFeel {
Free,
@@ -1543,6 +1544,66 @@ 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 {
@@ -2377,6 +2438,52 @@ 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();
+8 -5
View File
@@ -22,8 +22,6 @@ 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).
@@ -75,13 +73,16 @@ 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).
# 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.
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"
@@ -113,16 +114,18 @@ 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"]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
# 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", "horizon-client-input/controllers"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+3
View File
@@ -76,6 +76,9 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
+1 -1
View File
@@ -244,7 +244,7 @@ fn arch_label(build: &str, machine: &str) -> String {
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
pub(crate) fn operating_system() -> String {
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| {
+147 -38
View File
@@ -194,7 +194,6 @@ 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};
@@ -216,7 +215,6 @@ 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;
@@ -1528,8 +1526,6 @@ 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
@@ -1542,8 +1538,6 @@ 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.
@@ -2991,13 +2985,11 @@ 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) =
@@ -5354,12 +5346,10 @@ 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,
@@ -5528,7 +5518,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<&crate::core_shader::CoreProgram>) {
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
let format = self.output.frame.format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
@@ -5537,8 +5527,8 @@ impl Renderer {
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
@@ -5641,7 +5631,6 @@ 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.
@@ -7097,7 +7086,14 @@ 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 {
output_apply::surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
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
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
@@ -7153,11 +7149,106 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.caps_for(settings),
self.output_caps,
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) {
@@ -7167,6 +7258,24 @@ 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.
@@ -7552,7 +7661,7 @@ impl Renderer {
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
.is_some_and(|p| p.0.manifest.api == horizon_render_pipeline::Api::V0_2);
if (linear || self.output.frame.format == LINEAR_FRAME)
&& linear != (self.output.frame.format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
@@ -7605,7 +7714,7 @@ impl Renderer {
inputs.declarations.push_str(
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
@@ -7628,7 +7737,7 @@ impl Renderer {
}
}
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
fn pack_terrain_inputs(&mut self) -> Option<horizon_render_pipeline::GbuffersInputs> {
// Flat draws whenever the device has them (`render` keeps them on with a pack).
let flat = self.flat.is_some() && self.flat_on;
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
@@ -10301,7 +10410,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat
@@ -10334,7 +10443,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
@@ -10366,7 +10475,7 @@ impl Renderer {
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
@@ -10587,7 +10696,7 @@ impl Renderer {
let gpu_occluding = gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding());
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
if let Some((_, group)) = material {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
@@ -11975,7 +12084,7 @@ impl Renderer {
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
.is_none_or(|n| n.frame.api == horizon_render_pipeline::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -15290,10 +15399,6 @@ 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);
@@ -15334,7 +15439,9 @@ impl DisplayWatch {
return (watch, wayland_reading(now));
}
let _ = window;
let now = DisplayReading::read(surface, adapter);
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
log_os_reported(now.luminance);
let watch = Self {
#[cfg(target_os = "linux")]
@@ -15343,28 +15450,30 @@ impl DisplayWatch {
};
(watch, now)
}
/// 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(
/// What the system reports, if that changed from `last`.
fn changed(
&mut self,
last: DisplayReading,
time: std::time::Instant,
read: impl FnOnce() -> DisplayReading,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
) -> 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 = read();
if now != last {
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
(now != last).then(|| {
log_os_reported(now.luminance);
}
Some(now)
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 = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
let program = horizon_shaders::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 = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
let program = horizon_shaders::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 = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
let program = horizon_shaders::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 = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
let vanilla = horizon_shaders::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 = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
@@ -1,25 +1,3 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
-107
View File
@@ -419,26 +419,6 @@ 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) {
@@ -1561,93 +1541,6 @@ 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.
@@ -1,65 +0,0 @@
//! 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);
}
}
}
@@ -8,7 +8,8 @@
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
//! pack's frame at group 3.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -369,7 +370,7 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
@@ -398,7 +399,7 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
@@ -417,7 +418,7 @@ impl NativeMaterial {
vertex: bool,
displaced: Option<&DisplacedShape<'_>>,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let writes_surface = horizon_render_pipeline::stages::calls_set_surface(&stage.source);
let module = module(
stage,
shape.sprite_tags,
@@ -586,7 +587,7 @@ impl NativeMaterial {
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
pub(super) fn coverage_test_shader(api: horizon_render_pipeline::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
@@ -829,7 +830,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ horizon_render_pipeline::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1,7 +1,8 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky, material and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use horizon_render_pipeline::StageKind;
use horizon_shader_api::Report;
use super::displace::Displacement;
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
@@ -253,7 +254,7 @@ impl NativePack {
.transpose()?;
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
let post = (!post.is_empty() || api == horizon_render_pipeline::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
@@ -7,9 +7,9 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use horizon_render_pipeline::stages::{GROUP, POST_TARGET_BINDING};
use horizon_render_pipeline::{Api, Frame as FrameInputs, StageKind};
use horizon_shader_api::check::CheckedStage;
use super::Frame;
@@ -1014,10 +1014,9 @@ impl NativePost {
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
entries.extend(
(0..targets.len() as u32).map(|j| texture_entry(POST_TARGET_BINDING + j, float)),
);
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &entries,
@@ -1346,7 +1345,7 @@ impl NativePost {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
binding: POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
@@ -1655,7 +1654,7 @@ pub fn pack_textures(
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
@@ -1684,7 +1683,7 @@ pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEnt
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
@@ -2677,8 +2676,7 @@ 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. An HDR output switched
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
@@ -2688,9 +2686,8 @@ 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, the
// output switched).
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
@@ -2748,11 +2745,6 @@ 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;
@@ -2820,16 +2812,14 @@ 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.
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}");
}
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}");
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false, false);
let sdr = shown(pack_curve, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
@@ -11,9 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
/// 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;
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
@@ -1,71 +0,0 @@
// @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);
}
}
@@ -7,7 +7,8 @@
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_render_pipeline::{Api, Frame as FrameInputs, stages::GROUP};
use horizon_shader_api::manifest::Shadow as Settings;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -387,7 +388,7 @@ fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
)
}
@@ -486,7 +487,7 @@ fn shader_source(
(Some(material), _) => (
format!(
"{material}{}{table}{}{}{GRID_ENTRIES}",
horizon_shader_api::stages::PBR_STUB,
horizon_render_pipeline::stages::PBR_STUB,
super::displace::RELIEF,
super::displace::GRID,
),
@@ -504,7 +505,7 @@ fn shader_source(
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
@@ -6,7 +6,8 @@
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -40,7 +41,7 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
fn shader_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
@@ -198,7 +199,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let text = shader_source(&stage(own).source, horizon_render_pipeline::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -261,7 +262,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
horizon_render_pipeline::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
+4 -163
View File
@@ -18,43 +18,6 @@ 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
@@ -134,8 +97,6 @@ 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,
@@ -157,7 +118,6 @@ impl EffectiveOutput {
system_hdr: None,
reported: None,
wide_frame_supported: false,
held: false,
encoding: None,
frame: FrameConfiguration {
format: wgpu::TextureFormat::Bgra8Unorm,
@@ -176,10 +136,9 @@ 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, neither while HDR output is held back. The output still needs
/// an offer and a frame that can take it.
/// the display reports HDR. The output still needs an offer and a frame that can take it.
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
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
@@ -210,10 +169,6 @@ 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.
@@ -224,7 +179,7 @@ pub(crate) fn resolve_frame(
reading: DisplayReading,
swapchain: wgpu::TextureFormat,
) -> FrameConfiguration {
let wanted = wanted(settings, caps, reading);
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let (format, hud_layer) = super::frame_configuration(
wanted && settings.hdr_smooth_gradients,
(
@@ -255,13 +210,12 @@ pub(crate) fn resolve_output(
frame: FrameConfiguration,
presents: bool,
) -> EffectiveOutput {
let wanted = wanted(settings, caps, reading);
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
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),
@@ -293,7 +247,6 @@ pub(crate) mod tests {
wide_frame: true,
float_frame: true,
hud_layer: true,
held: false,
},
DisplayReading {
luminance: Some(Reported {
@@ -425,118 +378,6 @@ 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() {
@@ -1,532 +0,0 @@
//! 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);
}
}
@@ -597,9 +597,9 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
terrain: &horizon_render_pipeline::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
) -> [horizon_render_pipeline::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
@@ -619,7 +619,7 @@ pub fn terrain_parts(
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
pub fn terrain_inputs(&self, flat: bool) -> horizon_render_pipeline::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -753,7 +753,7 @@ impl PackAtlas {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -2,7 +2,7 @@
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
//! dynamic offset at each draw.
use horizon_shaders::{RENDER_STAGES, render_stage};
use horizon_render_pipeline::{RENDER_STAGES, render_stage};
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
@@ -6,7 +6,7 @@
use std::ops::Range;
use horizon_shaders::render_stage;
use horizon_render_pipeline::render_stage;
use super::Vertex;
use super::pack_draw::EntitySpan;
@@ -31,7 +31,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -112,7 +112,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -121,7 +121,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -138,7 +138,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let pull = format!(
" uint hz_pw = hz_vertex_index * {words}u;\n \
@@ -150,7 +150,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs: inputs(),
pull,
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
@@ -163,7 +163,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
pub fn cloud_inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
@@ -183,7 +183,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -17,7 +17,7 @@ const UNIFORM: u64 = 2 * 64;
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
@@ -270,7 +270,7 @@ fn translucent_quad(quad: &[Vertex]) -> bool {
/// outside block entities, and `currentRenderedItemId`.
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage(if translucent {
let stage = horizon_render_pipeline::render_stage(if translucent {
"HAND_TRANSLUCENT"
} else {
"HAND_SOLID"
@@ -8,7 +8,7 @@
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
@@ -41,7 +41,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -125,7 +125,7 @@ enum Source {
ShadowDepth(usize, bool),
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
ShadowColor(usize),
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
/// The boats' water patches' depth (`horizon_render_pipeline::WATER_MASK`), for translucent terrain.
WaterMask,
}
@@ -883,7 +883,7 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
/// `hz_depth_linear` for a program: 1 for each of shadowtex0, shadowtex1, `shadow` and
/// `watershadow` whose depth filters (`linear`, bit n: shadowtex n). `shadow` is shadowtex1 in a
/// program that also declares `watershadow` (6.3).
fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4] {
fn depth_linear(interface: &horizon_render_pipeline::Interface, linear: u8) -> [f32; 4] {
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
[0, 1, usize::from(watershadow), 0].map(|n| f32::from(linear >> n & 1))
}
@@ -921,7 +921,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
horizon_render_pipeline::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1503,7 +1503,7 @@ fn owned_buffers(
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn bindings(
features: wgpu::Features,
interface: &horizon_shaders::Interface,
interface: &horizon_render_pipeline::Interface,
name: &str,
group: Group,
buffers: &[u32],
@@ -1574,7 +1574,7 @@ fn bindings(
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
// own, where a raw texture was bound; this rule is a fit to those cases.
let unbound = t.png
&& t.stage != horizon_shaders::EVERY_STAGE
&& t.stage != horizon_render_pipeline::textures::EVERY_STAGE
&& source(&s.name, false, true, false) == Source::Dummy;
!unbound
&& (t.stage != pack_textures::stage(group)
@@ -2283,7 +2283,7 @@ fn build_compute(
device: &wgpu::Device,
name: String,
group: Group,
c: horizon_shaders::ComputeProgram,
c: horizon_render_pipeline::ComputeProgram,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
@@ -6065,7 +6065,7 @@ mod tests {
texture("composite", "tr", false),
texture("composite", "gaux2", true),
texture("composite", "noisetex", true),
texture(horizon_shaders::EVERY_STAGE, "cp", true),
texture(horizon_render_pipeline::textures::EVERY_STAGE, "cp", true),
];
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
let interface = horizon_shaders::Interface {
@@ -58,7 +58,7 @@ pub(super) const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
/// What `gbuffers_skybasic` reads from these vertices.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uint hz_v_kind;\n\
layout(set = 1, binding = 0) uniform HzSky { mat4 hz_sky_stars; vec4 hz_sky_color; \
@@ -90,7 +90,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
);
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: vec![
@@ -111,8 +111,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
/// What `gbuffers_skytextured` reads from the sun and moon's vertices.
pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
horizon_shaders::GbuffersInputs {
pub fn celestial_inputs() -> horizon_render_pipeline::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations:
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in vec4 hz_v_color;\n"
@@ -2,7 +2,10 @@
//! which of them a program's sampler reads.
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::textures::{
CustomTexture, Dim as TextureDim, EVERY_STAGE, Texels, Texture, TextureSource,
};
use horizon_shaders::Group;
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -127,16 +130,16 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
textures
.iter()
.position(|t| t.stage == EVERY_STAGE && t.dimension == dimension && t.name == name)
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
@@ -351,7 +354,7 @@ mod tests {
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let every = EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
@@ -2717,7 +2717,7 @@ pub(super) mod initial_history_tests {
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
fn water_patch_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
(k < 2 * horizon_render_pipeline::WATER_PATCHES).then_some(k)
}
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
@@ -2729,7 +2729,7 @@ pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
let mut points: Vec<[f32; 3]> = Vec::new();
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
if let Some(b) = patch_box(points)
&& out.len() < horizon_shaders::WATER_PATCHES
&& out.len() < horizon_render_pipeline::WATER_PATCHES
{
out.push(b);
}
@@ -11,7 +11,7 @@ use super::pack_post::Weather;
/// What `gbuffers_weather` reads from a weather vertex: camera-relative position, the UV in
/// repeats of the sprite, colour (with vanilla's fade) and light. Weather has no normal; the
/// pack sees straight up.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -31,7 +31,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -40,7 +40,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Vanilla's weather drops texels below 0.1, as Horizon's own weather shader does.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -158,7 +158,7 @@ impl Weather {
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
@@ -225,7 +225,7 @@ struct HorizonWeatherOut {
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
fn native_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
@@ -245,7 +245,7 @@ pub(super) fn native_pipeline(
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
use horizon_render_pipeline::stages::GROUP;
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
@@ -322,7 +322,7 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::{Api, stages::StageKind};
use horizon_render_pipeline::{Api, StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
@@ -50,98 +50,6 @@ 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,
@@ -655,25 +563,33 @@ impl Post {
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
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 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 pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, shown, pipeline, timing);
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
+5 -155
View File
@@ -5,23 +5,9 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// 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.
/// 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.
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
@@ -823,11 +809,6 @@ 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.
@@ -903,7 +884,6 @@ 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,
@@ -929,50 +909,15 @@ impl Graphics {
Vec::new()
}
};
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());
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
g.save(dir);
log::warn!(
"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})"
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, 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() {
@@ -1081,7 +1026,6 @@ 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),
@@ -1160,9 +1104,6 @@ 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
}
@@ -1302,85 +1243,6 @@ 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 {
@@ -1433,9 +1295,6 @@ 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,
@@ -1444,15 +1303,6 @@ 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,10 +708,6 @@ 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,
@@ -720,7 +716,6 @@ 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."
}
@@ -1091,9 +1086,7 @@ 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);
@@ -1162,11 +1155,7 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
HDR_HELD
} else {
"Turn HDR on to use it."
}),
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1533,7 +1522,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 = s.horizon.undone_to(&b.horizon);
s.horizon = b.horizon.clone();
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
s.gamma = b.gamma;
@@ -134,68 +134,3 @@ 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,7 +17,6 @@
//! 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,
@@ -34,10 +33,6 @@ 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 {
@@ -48,30 +43,6 @@ 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`.
@@ -82,11 +53,8 @@ pub struct Calibration {
dragging: bool,
}
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
/// Height of the band of controls at the bottom, 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.
@@ -147,24 +115,18 @@ 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),
}
/// 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();
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
let cx = (w / 2.0).floor();
let by = top + more + 58.0;
let by = top + 58.0;
Layout {
top,
hint,
slider: (cx - 100.0, top + more + 32.0, 200.0),
slider: (cx - 100.0, top + 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),
@@ -197,9 +159,6 @@ 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();
@@ -225,17 +184,18 @@ impl Calibration {
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The current step's test picture in a GUI of `size`.
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
/// 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) {
(
self.current(),
self.values[self.step],
self.layout(font, size).top / size.1.max(1.0),
layout(0.0, h).top / h.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = self.layout(&atlas.font, (w, h));
let l = layout(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));
@@ -245,10 +205,16 @@ impl Calibration {
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
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 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),
);
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -295,14 +261,8 @@ impl Calibration {
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(
&mut self,
font: &Font,
cursor: (f32, f32),
size: (f32, f32),
drag: bool,
) -> Outcome {
let l = self.layout(font, size);
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
@@ -376,14 +336,6 @@ 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();
@@ -392,14 +344,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!((picture(&c).1 - 0.03).abs() < 1e-6);
assert!((c.picture(100.0).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!(picture(&c).0, Step::PaperWhite);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
@@ -418,10 +370,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!(picture(&c).0, Step::Peak);
assert_eq!(picture(&c).1, peak);
assert_eq!(c.picture(1.0).0, Step::Peak);
assert_eq!(c.picture(1.0).1, peak);
c.key(KeyCode::Enter);
assert_eq!(picture(&c).1, paper_white);
assert_eq!(c.picture(1.0).1, paper_white);
}
}
@@ -440,14 +392,14 @@ mod tests {
];
let mut c = Calibration::new(&Settings::default(), &NONE);
for (i, expected) in expected.iter().enumerate() {
assert_eq!(picture(&c).0, STEPS[i]);
assert_eq!(c.picture(1.0).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![picture(&c).1];
let mut seen = vec![c.picture(1.0).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = picture(&c).1;
let v = c.picture(1.0).1;
if seen.last() != Some(&v) {
seen.push(v);
}
@@ -470,7 +422,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!((picture(&c).1 - 0.02).abs() < 1e-6);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
@@ -483,89 +435,14 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
assert_eq!(picture(&c).1, 10_000.0);
assert_eq!(c.picture(1.0).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(picture(&c).1, 9500.0);
assert_eq!(c.picture(1.0).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!(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));
assert_eq!(c.picture(1.0).1, 1950.0);
}
}
+31 -6
View File
@@ -100,13 +100,13 @@ pub struct HudAtlas {
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub text_shader: Option<std::sync::Arc<horizon_shaders::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<crate::core_shader::CoreProgram>>,
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::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.
@@ -1427,7 +1427,32 @@ impl HudAtlas {
}
if full {
// Official Recipes artwork from Just Enough Items, under MIT.
for (name, png) in crate::jei_sprites::SPRITES {
for (name, png) in [
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
),
] {
let (width, height, rgba) = decode_bytes(png)?;
images.push((format!("horizon/jei/{name}"), width, height, rgba));
}
@@ -2870,7 +2895,7 @@ impl HudRenderer {
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&crate::core_shader::CoreProgram>,
shader: Option<&horizon_shaders::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 = crate::core_shader::CoreProgram {
let program = horizon_shaders::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 = crate::core_shader::CoreProgram {
let program = horizon_shaders::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 crate::core_shader::{CoreProgram, Glue};
use horizon_shaders::{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 crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
match horizon_shaders::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 crate::core_shader::SAMPLERS {
for (_, binding) in horizon_shaders::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -175,7 +175,7 @@ impl PackText {
}),
})
.collect();
for (_, binding) in crate::core_shader::SAMPLERS {
for (_, binding) in horizon_shaders::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
@@ -1,30 +0,0 @@
//! The official Recipes viewer's artwork from Just Enough Items, under MIT (`jei_recipes/LICENSE.txt`,
//! THIRD_PARTY_NOTICES.md): `HudAtlas` loads each as the sprite `horizon/jei/<name>`.
/// Each sprite's name and its PNG.
pub(crate) const SPRITES: &[(&str, &[u8])] = &[
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png"),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png"),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png"),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png"),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png"),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png"),
),
];
-2
View File
@@ -38,7 +38,6 @@ 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;
@@ -63,7 +62,6 @@ pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
@@ -8,7 +8,6 @@ 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},
@@ -1683,8 +1682,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>,
/// The window as calibration found it before making it fullscreen (the game keeps it).
pub calibration_window: Option<crate::hdr_calibration::WindowState>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
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).
@@ -1763,7 +1762,7 @@ impl Default for OptionsUi {
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_window: None,
calibration_fullscreen: None,
split_setup: Default::default(),
studio: Default::default(),
agent_status: Vec::new(),
@@ -4031,7 +4030,7 @@ impl OptionsUi {
if right {
return Vec::new();
}
let outcome = c.click(&atlas.font, (x, y), size, drag);
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.shell().is_some() {
@@ -4633,12 +4632,13 @@ impl OptionsUi {
}
}
}
/// 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> {
/// 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)> {
self.calibration
.as_ref()
.filter(|_| self.page() == Page::HdrCalibration)
.map(|c| c.picture(&atlas.font, size))
.map(|c| c.picture(h))
}
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
@@ -143,34 +143,3 @@ 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);
}
}
+12 -208
View File
@@ -8,8 +8,8 @@
//!
//! [`TouchControls`] turns fingers into the controller state the game already
//! reads ([`PadState`]). Each finger gets a role when it lands (the joystick or
//! D-pad, a button, or a look gesture on the world) and keeps it until it
//! lifts, so moving, looking and pressing never take each other's fingers.
//! 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.
pub mod aim;
pub mod layout;
pub use aim::{
@@ -22,10 +22,6 @@ 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
@@ -39,19 +35,8 @@ const SWAP_HANDS: &str = "action:key.swapOffhand";
enum Role {
/// Drives the joystick or D-pad, measured from this anchor.
Move([f32; 2]),
/// 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 the world: turns the camera once it moves past [`DRAG_SLOP`].
Look { start: [f32; 2], dragged: bool },
/// Landed on a control that acted at once; nothing more until it lifts.
Ignore,
/// Holds the shield up.
@@ -67,8 +52,6 @@ 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
@@ -81,16 +64,6 @@ 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,
@@ -102,10 +75,6 @@ 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,
@@ -130,27 +99,6 @@ 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!(
@@ -175,17 +123,7 @@ 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| {
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)
self.visible(c) && layout::contains(self.placement(c).rect(self.aspect()), point)
})
}
@@ -324,41 +262,12 @@ 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.lift(id);
self.fingers.remove(&id);
return [0.0; 2];
}
if started {
@@ -366,26 +275,14 @@ 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,
since,
dragged,
committed,
..
} => {
if !*dragged && since.elapsed() >= HOLD_DELAY {
*committed = true;
}
Role::Look { start, dragged } => {
*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]];
@@ -393,34 +290,11 @@ 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(if self.dynamic_joystick() {
point
} else {
self.placement(Control::Move).center
}),
Some(Control::Move) if !self.moving() => {
Role::Move(self.placement(Control::Move).center)
}
Some(Control::Shield) => {
self.clicked = true;
self.pending.triggers[0] = 1.0;
@@ -474,10 +348,7 @@ 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));
@@ -496,87 +367,20 @@ 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, and
/// sets [`Self::aim`], [`Self::holding`] and [`Self::holding_use`].
/// This tick's controller state, with presses that already lifted.
pub fn poll_state(&mut self) -> PadState {
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;
self.state().merge(&std::mem::take(&mut self.pending))
}
/// The controller state the fingers hold right now.
@@ -176,21 +176,9 @@ 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,
}
@@ -232,12 +220,7 @@ 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,
}
}
@@ -282,8 +265,6 @@ 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.
@@ -456,19 +437,14 @@ 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.attack_button_look && !read.sprint_toggle && !read.dynamic_joystick);
assert_eq!([read.dynamic_area_x, read.dynamic_area_y], [0.25, 1.0]);
assert!(!read.sprint_toggle && !read.contextual_interact);
assert_eq!(read.button_scale, 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, s.dynamic_area_x, s.dynamic_area_y],
[1.0, 0.0, 1.0]
);
assert_eq!(s.button_scale, 1.0);
s.button_scale = 9.0;
s.sanitize();
assert_eq!(s.button_scale, 2.0);
@@ -1,285 +0,0 @@
//! 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,8 +1,6 @@
// 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
}
+14 -101
View File
@@ -1227,13 +1227,6 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
/// `EditBox.isEditable`: a read-only box keeps the focus, the caret keys
/// and copy, but takes no typed text, Backspace/Delete or paste (`AnvilScreen`
/// sets it by its input slot).
pub editable: bool,
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
@@ -1525,8 +1518,6 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
frame: None,
}
@@ -1571,12 +1562,6 @@ impl EditBox {
}
/// EditBox.insertText: replace the selection, respecting the maximum length.
pub fn insert_text(&mut self, text: &str) {
// `EditBox.charTyped` only inserts through `canConsumeInput`, which a
// read-only box fails; the screens here hand typed text to this method
// directly.
if !self.editable {
return;
}
let (a, b) = self.selected();
let filtered = crate::clipboard::filter_text(text, self.multiline);
let room = self
@@ -1588,12 +1573,8 @@ impl EditBox {
let pos = a + char_len(&insert);
self.set_cursor(pos, false);
}
/// Delete `n` characters (negative: backwards) or the selection. A
/// read-only box consumes the key without changing its value.
/// Delete `n` characters (negative: backwards) or the selection.
pub fn delete_chars(&mut self, n: isize) {
if !self.editable {
return;
}
if self.cursor != self.highlight {
self.insert_text("");
return;
@@ -1696,24 +1677,20 @@ impl EditBox {
use winit::keyboard::KeyCode as K;
match key {
K::Backspace => {
if self.editable {
let n = if ctrl {
self.word_position(-1) as isize - self.cursor as isize
} else {
-1
};
self.delete_chars(n);
}
let n = if ctrl {
self.word_position(-1) as isize - self.cursor as isize
} else {
-1
};
self.delete_chars(n);
}
K::Delete => {
if self.editable {
let n = if ctrl {
self.word_position(1) as isize - self.cursor as isize
} else {
1
};
self.delete_chars(n);
}
let n = if ctrl {
self.word_position(1) as isize - self.cursor as isize
} else {
1
};
self.delete_chars(n);
}
K::ArrowLeft => {
let p = if ctrl {
@@ -1747,19 +1724,6 @@ impl EditBox {
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
use crate::clipboard::EditShortcut;
if !self.editable {
match shortcut {
// EditBox.keyPressed: Cut copies the selection, then does not
// take it out of a read-only box.
EditShortcut::Cut => {
clipboard.set(&self.selected_text());
return false;
}
EditShortcut::Paste => return false,
EditShortcut::SelectAll | EditShortcut::Copy => {}
}
}
let before = self.value.clone();
crate::clipboard::apply(self, shortcut, clipboard);
self.reset_blink();
@@ -1813,11 +1777,7 @@ impl EditBox {
u32::MAX,
);
}
let color = if self.editable {
self.text_color
} else {
self.text_color_uneditable
};
let color = self.text_color;
let rest: String = self.value.chars().skip(self.display).collect();
let shown_len = atlas.font.fit(&rest, self.inner_width());
let displayed: String = rest.chars().take(shown_len).collect();
@@ -2571,51 +2531,4 @@ mod tests {
(112, 180)
);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
/// in `textColorUneditable`.
#[test]
fn a_read_only_edit_box_keeps_the_caret_and_copy_but_refuses_editing() {
use crate::clipboard::{EditShortcut, MemoryClipboard};
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
edit.set_value("name");
edit.editable = false;
let mut clipboard = MemoryClipboard("pasted".into());
// The caret keys still work.
assert!(edit.key(winit::keyboard::KeyCode::ArrowLeft, false, false));
assert_eq!(edit.cursor, 3);
assert!(edit.key(winit::keyboard::KeyCode::Home, false, false));
assert_eq!(edit.cursor, 0);
// Backspace and Delete are consumed but change nothing.
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert!(edit.key(winit::keyboard::KeyCode::Delete, false, false));
assert_eq!(edit.value, "name");
// Typed text (and an IME commit) goes through insert_text: refused.
edit.insert_text("typed");
assert_eq!(edit.value, "name");
edit.delete_chars(-1);
assert_eq!(edit.value, "name");
// Paste changes nothing; Cut copies and keeps the text; Copy copies.
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert!(!edit.shortcut(EditShortcut::Paste, &mut clipboard));
assert_eq!(edit.value, "name");
assert!(!edit.shortcut(EditShortcut::Cut, &mut clipboard));
assert_eq!(edit.value, "name");
assert_eq!(clipboard.0, "name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert!(!edit.shortcut(EditShortcut::Copy, &mut clipboard));
assert_eq!(clipboard.0, "name");
// An editable box does all of it again. (Set the caret past the
// selection first: insertion replaces a selection.)
edit.editable = true;
edit.set_cursor(4, false);
edit.insert_text("x");
assert_eq!(edit.value, "namex");
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
}
+6 -5
View File
@@ -20,8 +20,6 @@ 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).
@@ -69,7 +67,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 the Horizon layer's (`horizon-heartbeat`, in the official executable).
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
@@ -119,15 +117,18 @@ 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 and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# 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
# 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"]
horizon-extras = ["discord", "friends", "heartbeat"]
# 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,7 +447,6 @@
"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.",
+118 -6
View File
@@ -1,8 +1,6 @@
//! 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;
//! 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.
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
@@ -21,5 +19,119 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
windows_exe::resources("horizon-client");
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())
}
+9 -9
View File
@@ -2,8 +2,6 @@
//! 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::{
@@ -621,10 +619,6 @@ 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;
@@ -920,9 +914,7 @@ 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(bounds) => w
.block_property_values(&name, k)
.is_some_and(|domain| state_ranges::matches(bounds, &have, &domain)),
Value::Object(_) => have.parse::<f64>().is_ok_and(|h| range_f(want, h)),
_ => false,
};
if !ok {
@@ -1963,6 +1955,7 @@ impl Tree {
mod tests {
use super::*;
use serde_json::json;
#[test]
fn advancement_reward_function_is_optional_and_namespaced() {
for (raw, expected) in [
@@ -1987,6 +1980,7 @@ 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.
@@ -2194,6 +2188,7 @@ 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 —
@@ -2216,6 +2211,7 @@ 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.
@@ -3364,6 +3360,7 @@ mod tests {
);
assert_eq!(date_millis("not a date"), None);
}
#[test]
fn simulate_progression_advancements() {
let mut tree = Tree::default();
@@ -3450,6 +3447,7 @@ 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(),
@@ -3478,6 +3476,7 @@ mod tests {
}
}
}
fn grant(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -3492,6 +3491,7 @@ mod tests {
}
count
}
fn revoke(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -1,157 +0,0 @@
//! `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);
}
}
}
+5 -2
View File
@@ -899,8 +899,11 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.startFalling`.
fn start_falling(&mut self) {
self.st.in_ground = false;
self.p.velocity =
crate::projectiles::start_falling_velocity(self.p.velocity, &mut self.st.random);
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.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
@@ -253,19 +253,6 @@ fn an_arrow_owned_by_an_armor_stand_needs_mob_griefing_to_prime_tnt() {
assert!(primes_tnt(None, false));
}
#[test]
fn client_arrow_tick_waits_for_a_non_self_move_to_leave_its_block() {
let steps = run_with(&[], 1, false, |p| {
let arrow = p.arrow.as_mut().unwrap();
arrow.in_ground = true;
arrow.last_state = Some(BlockStateId(1));
});
let p = &steps[0];
let arrow = p.arrow.as_ref().unwrap();
assert!(arrow.in_ground);
assert_eq!(p.velocity, Vec3::new(1.0, 0.2, 0.0));
}
#[test]
#[ignore = "run by client-arrow differential harness"]
fn client_arrow_parity_fixture() {
+2
View File
@@ -77,6 +77,8 @@ 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,23 +93,6 @@ 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 {
@@ -398,28 +381,4 @@ 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)));
}
}
+8 -3
View File
@@ -621,9 +621,14 @@ 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())),
// LivingEntity.addAdditionalSaveData: (short) casts, nothing clamped.
("HurtTime", Tag::Short(m.hurt_time as i16)),
("DeathTime", Tag::Short(m.death_time as i16)),
(
"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),
),
// Mob.addAdditionalSaveData writes the current virtual getter, not raw input.
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("PersistenceRequired", Tag::Byte(i8::from(m.persistent))),
-4
View File
@@ -21,9 +21,6 @@ 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();
@@ -306,7 +303,6 @@ 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,6 +26,7 @@ use std::{
sync::Arc,
time::Instant,
};
pub(super) struct Toast {
icon: String,
frame: Frame,
@@ -213,10 +214,6 @@ 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
@@ -1889,6 +1886,7 @@ 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) {
@@ -1910,6 +1908,7 @@ 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);
@@ -2551,6 +2550,7 @@ 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,26 +131,6 @@ 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();
@@ -165,8 +145,6 @@ 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 {
@@ -801,13 +779,6 @@ 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;
@@ -837,57 +808,3 @@ 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"))
);
}
}
@@ -1,121 +0,0 @@
//! 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,5 +1,4 @@
//! 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};
+88 -3
View File
@@ -2,7 +2,6 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod projectile_world;
mod pufferfish_player;
use super::{
@@ -149,6 +148,92 @@ pub(super) struct ProjWorld<'a, 'w> {
/// block would (`clipIncludingBorder`); None: no border.
pub border: Option<[f64; 4]>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
binding: self.w.collision,
subject: None,
};
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
let state = self.w.world.block(p.x, p.y, p.z);
if self.w.facts.is_air(state) {
return None;
}
// ClipContext hands CollisionContext.of(projectile): scaffolding
// is open for a projectile below it.
let shape = self
.w
.collision
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
let hit = r.ok().flatten().map(|hit| {
(
hit.location,
[hit.block.x, hit.block.y, hit.block.z],
hit.direction.normal(),
)
});
match self.border {
Some(bounds) => clip_including_border(hit, from, to, bounds),
None => hit,
}
}
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
/// `BaseFireBlock.entityInside` for a thrown projectile: fire and soul
/// fire set it alight for 160 ticks.
fn ignites(&self, p: [i32; 3]) -> bool {
let state = self.w.world.block(p[0], p[1], p[2]);
matches!(
&*self.w.facts.get(state).name,
"minecraft:fire" | "minecraft:soul_fire"
)
}
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
/// `WebBlock.entityInside` and `PowderSnowBlock.entityInside`.
fn stuck_speed(&self, p: [i32; 3]) -> Option<Vec3> {
horizon_client::projectiles::stuck_speed_of(
&self.w.facts.get(self.w.world.block(p[0], p[1], p[2])).name,
)
}
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
horizon_client::motion::bubble_column_at(
self.w.world,
self.w.registry,
self.w.facts,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
)
.map(|c| (c.drag, c.surface))
}
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
horizon_client::collisions::projectile_can_stand_on(
self.w.world,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
entity_y,
)
}
}
/// `Projectile.isPickable`: a projectile in `#minecraft:redirectable_projectile`
/// (a ghast's fireball and both wind charges) is pickable, so another
/// projectile's `ProjectileUtil.getEntityHitResult` can strike it
@@ -1143,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<> {
self.tickets
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
let levels = self.tickets.levels();
move |x, z| levels.entity_ticking_at(x, z)
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
@@ -1,89 +0,0 @@
//! What a flying projectile asks of the world (`ProjectileWorld` for `ProjWorld`): block
//! contacts, collisions, fluids and the world border.
use super::*;
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
binding: self.w.collision,
subject: None,
};
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
let state = self.w.world.block(p.x, p.y, p.z);
if self.w.facts.is_air(state) {
return None;
}
// ClipContext hands CollisionContext.of(projectile): scaffolding
// is open for a projectile below it.
let shape = self
.w
.collision
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
let hit = r.ok().flatten().map(|hit| {
(
hit.location,
[hit.block.x, hit.block.y, hit.block.z],
hit.direction.normal(),
)
});
match self.border {
Some(bounds) => clip_including_border(hit, from, to, bounds),
None => hit,
}
}
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
/// `BaseFireBlock.entityInside` for a thrown projectile: fire and soul
/// fire set it alight for 160 ticks.
fn ignites(&self, p: [i32; 3]) -> bool {
let state = self.w.world.block(p[0], p[1], p[2]);
matches!(
&*self.w.facts.get(state).name,
"minecraft:fire" | "minecraft:soul_fire"
)
}
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
/// `WebBlock.entityInside` and `PowderSnowBlock.entityInside`.
fn stuck_speed(&self, p: [i32; 3]) -> Option<Vec3> {
horizon_client::projectiles::stuck_speed_of(
&self.w.facts.get(self.w.world.block(p[0], p[1], p[2])).name,
)
}
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
horizon_client::motion::bubble_column_at(
self.w.world,
self.w.registry,
self.w.facts,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
)
.map(|c| (c.drag, c.surface))
}
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
horizon_client::collisions::projectile_can_stand_on(
self.w.world,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
entity_y,
)
}
}
+68 -2
View File
@@ -4805,8 +4805,74 @@ impl Game<'_> {
}
}
mod death_render;
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
/// The dying model's colour, `ARGB.white(1 - deathTime / 200)`: `Mth.floor` of the alpha
/// shifted as a Java int, so past 200 it wraps as vanilla's does.
fn dissolve_color(death: f32) -> u32 {
let alpha = ((1.0 - death / 200.0) * 255.0).floor() as i32;
(alpha << 24) as u32 | 0x00FF_FFFF
}
/// `DissolveMaskSampler` at the vertex's `UV0`: the same place in the mask's sprite as in the
/// skin's, as the dissolve overlay reads it from `glint_uv` (entity.wgsl).
fn dissolve_mask(quads: &mut [Vertex], skin: [f32; 4], mask: [f32; 4]) {
for v in quads {
let u = (v.uv[0] - skin[0]) / (skin[2] - skin[0]);
let w = (v.uv[1] - skin[1]) / (skin[3] - skin[1]);
v.glint_uv = [
mask[0] + (mask[2] - mask[0]) * u,
mask[1] + (mask[3] - mask[1]) * w,
];
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
// its border can round into transparent neighbours during interpolation.
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
/// `Entity.push` on the server's copy of a player, then `hurt`. A player's
/// own client hears of its velocity only when a hurt marks it
@@ -1,76 +0,0 @@
//! The dying dragon's dissolve and death rays (`EnderDragonRenderer`).
use super::*;
/// The dying model's colour, `ARGB.white(1 - deathTime / 200)`: `Mth.floor` of the alpha
/// shifted as a Java int, so past 200 it wraps as vanilla's does.
pub(super) fn dissolve_color(death: f32) -> u32 {
let alpha = ((1.0 - death / 200.0) * 255.0).floor() as i32;
(alpha << 24) as u32 | 0x00FF_FFFF
}
/// `DissolveMaskSampler` at the vertex's `UV0`: the same place in the mask's sprite as in the
/// skin's, as the dissolve overlay reads it from `glint_uv` (entity.wgsl).
pub(super) fn dissolve_mask(quads: &mut [Vertex], skin: [f32; 4], mask: [f32; 4]) {
for v in quads {
let u = (v.uv[0] - skin[0]) / (skin[2] - skin[0]);
let w = (v.uv[1] - skin[1]) / (skin[3] - skin[1]);
v.glint_uv = [
mask[0] + (mask[2] - mask[0]) * u,
mask[1] + (mask[3] - mask[1]) * w,
];
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
pub(super) fn dragon_rays(
base: glam::Mat4,
t: f32,
white: horizon_client::hud::Rect,
out: &mut Vec<Vertex>,
) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
// its border can round into transparent neighbours during interpolation.
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
+4 -25
View File
@@ -272,11 +272,6 @@ 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.
@@ -1821,16 +1816,12 @@ impl Entity for Projectile {
Projectile::bounds(self)
}
fn move_by_piston(&mut self, movement: Vec3) {
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);
self.position = self.position.add_vector(movement);
self.needs_sync = true;
}
fn piston_slime_bounce(&mut self, movement: Direction) {
Projectile::piston_slime_bounce(self, movement);
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
}
}
@@ -1973,18 +1964,6 @@ 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,17 +10,6 @@ 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
@@ -649,43 +638,3 @@ 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,51 +1197,6 @@ 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,6 +2753,3 @@ mod required_tag_tests {
std::fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
include!("functions/typed_state_range_tests.rs");
@@ -1,300 +0,0 @@
#[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,19 +32,6 @@ 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 {
@@ -1,118 +0,0 @@
//! 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)
}
}
@@ -1,37 +0,0 @@
//! 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,12 +2,6 @@
//! 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.
@@ -124,15 +118,6 @@ 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(
@@ -161,6 +146,11 @@ 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.
@@ -200,12 +190,16 @@ impl Game<'_> {
{
// An item tab's display slots take no drags or PICKUP_ALL.
let display = slot >= Creative::DISPLAY;
let double = double
&& !shift
let now = std::time::Instant::now();
let 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,138 +374,4 @@ 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,259 +573,6 @@ 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)]
+3 -2
View File
@@ -2729,8 +2729,9 @@ impl Game<'_> {
for p in &mut self.creatures.projectiles {
p.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);
for d in &mut self.dropped {
d.needs_sync = false;
}
// The step command reaches clients before the events its tick
// generated, as ServerTickRateManager.updateStepTicks does.
host.hub.sync_ticking(
+25 -2
View File
@@ -6296,8 +6296,31 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
[range(at[0]), range(at[2])]
}
mod dig_stop;
use dig_stop::{StopDig, stop_dig};
/// What `ServerPlayerGameMode.handleBlockBreakAction`'s STOP_DESTROY_BLOCK branch does with a
/// guest's STOP.
#[derive(Clone, Copy, Debug, PartialEq)]
enum StopDig {
/// A block the last START did not name: the STOP is not this dig's.
Other,
/// Already broken, by the guest's own tick: only the crack goes.
Gone,
/// The dig the START counts is at least 70 % done: the block breaks outright.
Break,
/// Less than that: the server finishes it alone from that same START.
Delay(u64),
}
/// The STOP branch's decision: a STOP is measured from the START that named its block
/// (`destroyPos`, which an abort or a STOP from out of reach leaves alone), whose ticks times
/// `getDestroyProgress` decide between the break and the delayed dig (#4124).
fn stop_dig(play: &GuestPlay, pos: [i32; 3], air: bool, ticks: u64, per_tick: f32) -> StopDig {
match play.dig_start_for(pos) {
None => StopDig::Other,
Some(_) if air => StopDig::Gone,
Some(start) if per_tick * (ticks - start + 1) as f32 >= 0.7 => StopDig::Break,
Some(start) => StopDig::Delay(start),
}
}
/// ServerPlayer.swingAndResetAttackStrength resets only when a use starts a swing.
fn reset_use_attack_strength(ticker: &mut u32, before: u32, after: u32) {
@@ -1,34 +0,0 @@
//! A guest's STOP_DESTROY_BLOCK (`ServerPlayerGameMode.handleBlockBreakAction`).
use super::*;
/// What `ServerPlayerGameMode.handleBlockBreakAction`'s STOP_DESTROY_BLOCK branch does with a
/// guest's STOP.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum StopDig {
/// A block the last START did not name: the STOP is not this dig's.
Other,
/// Already broken, by the guest's own tick: only the crack goes.
Gone,
/// The dig the START counts is at least 70 % done: the block breaks outright.
Break,
/// Less than that: the server finishes it alone from that same START.
Delay(u64),
}
/// The STOP branch's decision: a STOP is measured from the START that named its block
/// (`destroyPos`, which an abort or a STOP from out of reach leaves alone), whose ticks times
/// `getDestroyProgress` decide between the break and the delayed dig (#4124).
pub(super) fn stop_dig(
play: &GuestPlay,
pos: [i32; 3],
air: bool,
ticks: u64,
per_tick: f32,
) -> StopDig {
match play.dig_start_for(pos) {
None => StopDig::Other,
Some(_) if air => StopDig::Gone,
Some(start) if per_tick * (ticks - start + 1) as f32 >= 0.7 => StopDig::Break,
Some(start) => StopDig::Delay(start),
}
}
@@ -881,7 +881,6 @@ 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;
+3 -64
View File
@@ -544,16 +544,6 @@ 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);
@@ -670,7 +660,9 @@ impl Game<'_> {
.push_by_fluid(&self.world, &self.res.motion().fluids)
.0
> 0.0;
// (A long move's reset ran before the checks above.)
// 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);
let fall = if in_water {
0.0
} else {
@@ -943,25 +935,12 @@ 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))
@@ -1052,45 +1031,5 @@ 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"
);
}
}
+53 -3
View File
@@ -184,9 +184,59 @@ pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
/// `EntityEvent.TNT_PRIME`. `MinecartTNT.primeFuse` broadcasts it;
/// `MinecartTNT.handleEntityEvent` answers by calling `primeFuse`.
pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// `AbstractMinecart.getDefaultDisplayOffset` (6), in sixteenths of a block, as
/// `MinecartChest` (8) and `MinecartHopper` (1) override it.
pub(super) fn default_display_offset(kind: CartKind) -> f64 {
match kind {
CartKind::Chest => 8.0,
CartKind::Hopper => 1.0,
CartKind::Rideable
| CartKind::Furnace
| CartKind::Tnt
| CartKind::CommandBlock
| CartKind::Spawner => 6.0,
}
}
/// `AbstractMinecart.getDisplayOffset`: the saved `DisplayOffset`, else the default.
fn display_offset(c: &Minecart) -> f64 {
c.custom_offset
.map_or_else(|| default_display_offset(c.kind), f64::from)
}
/// World-space envelope of the cart model and its optional displayed block,
/// based on the same root position and display offset used by the renderer.
fn cart_render_bounds(position: Vec3, offset: f64, has_display: bool) -> Aabb {
let (bottom, top) = if has_display {
let display_shift = 0.75 * (offset - 8.0) / 16.0;
(
(position.y - 0.5).min(position.y + 0.375 - 0.4 + display_shift),
(position.y + 0.9).max(position.y + 0.375 + 0.4 + display_shift + 0.9),
)
} else {
(position.y - 0.5, position.y + 0.9)
};
Aabb::new(
Vec3::new(position.x - 1.0, bottom, position.z - 1.0),
Vec3::new(position.x + 1.0, top, position.z + 1.0),
)
}
/// A display state that is air shows nothing.
fn shown_unless_air(
state: Option<BlockStateId>,
is_air: impl Fn(BlockStateId) -> bool,
) -> Option<BlockStateId> {
state.filter(|&s| !is_air(s))
}
/// `readAdditionalSaveData`'s `input.read("DisplayState", CODEC)` is empty
/// when the state does not decode: the cart forgets it (and no longer saves it).
pub(super) fn forget_unreadable_display(
c: &mut Minecart,
resolves: impl Fn(&horizon_world::nbt::Tag) -> bool,
) {
if c.custom_display.as_ref().is_some_and(|t| !resolves(t)) {
c.custom_display = None;
}
}
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
@@ -1,57 +0,0 @@
//! Where a cart draws its display block (`AbstractMinecart.getDisplayOffset`), and display
//! states that show nothing or don't decode.
use super::*;
/// `AbstractMinecart.getDefaultDisplayOffset` (6), in sixteenths of a block, as
/// `MinecartChest` (8) and `MinecartHopper` (1) override it.
pub(in super::super) fn default_display_offset(kind: CartKind) -> f64 {
match kind {
CartKind::Chest => 8.0,
CartKind::Hopper => 1.0,
CartKind::Rideable
| CartKind::Furnace
| CartKind::Tnt
| CartKind::CommandBlock
| CartKind::Spawner => 6.0,
}
}
/// `AbstractMinecart.getDisplayOffset`: the saved `DisplayOffset`, else the default.
pub(super) fn display_offset(c: &Minecart) -> f64 {
c.custom_offset
.map_or_else(|| default_display_offset(c.kind), f64::from)
}
/// World-space envelope of the cart model and its optional displayed block,
/// based on the same root position and display offset used by the renderer.
pub(super) fn cart_render_bounds(position: Vec3, offset: f64, has_display: bool) -> Aabb {
let (bottom, top) = if has_display {
let display_shift = 0.75 * (offset - 8.0) / 16.0;
(
(position.y - 0.5).min(position.y + 0.375 - 0.4 + display_shift),
(position.y + 0.9).max(position.y + 0.375 + 0.4 + display_shift + 0.9),
)
} else {
(position.y - 0.5, position.y + 0.9)
};
Aabb::new(
Vec3::new(position.x - 1.0, bottom, position.z - 1.0),
Vec3::new(position.x + 1.0, top, position.z + 1.0),
)
}
/// A display state that is air shows nothing.
pub(super) fn shown_unless_air(
state: Option<BlockStateId>,
is_air: impl Fn(BlockStateId) -> bool,
) -> Option<BlockStateId> {
state.filter(|&s| !is_air(s))
}
/// `readAdditionalSaveData`'s `input.read("DisplayState", CODEC)` is empty
/// when the state does not decode: the cart forgets it (and no longer saves it).
pub(in super::super) fn forget_unreadable_display(
c: &mut Minecart,
resolves: impl Fn(&horizon_world::nbt::Tag) -> bool,
) {
if c.custom_display.as_ref().is_some_and(|t| !resolves(t)) {
c.custom_display = None;
}
}
+4 -80
View File
@@ -94,36 +94,13 @@ 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 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)));
let here = std::env::current_dir().unwrap_or_default();
let mut paths = horizon_mods::find(&here.join(mod_list::DIR));
paths.retain(|p| !dev.contains(p));
paths.extend(dev.iter().cloned());
let mut known = None;
@@ -136,17 +113,8 @@ fn content_root_at(
.filter_map(|path| Some((path, Package::read(path).ok()?)))
.filter(|(path, package)| {
let m = &package.manifest;
// 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)
(enabled.contains(&m.id) || dev.contains(path))
&& m.capabilities.contains(&Capability::Registries)
})
.collect();
let read = read_contents(&packages);
@@ -875,50 +843,6 @@ 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
@@ -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.as_deref().cloned(),
nbt: block.nbt.clone(),
}
})
.collect();
+12 -26
View File
@@ -3,7 +3,6 @@
use super::{Game, Screen};
use horizon_client::{
Result,
hdr_calibration::WindowState,
hud::HudBatch,
lang,
options_ui::{Event, Page},
@@ -189,7 +188,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.width, batch.height));
self.show_calibration(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.
@@ -207,10 +206,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, as it was (maximized, say).
pub(super) fn show_calibration(&mut self, size: (f32, f32)) {
/// window goes fullscreen for it and back after, if it wasn't already.
pub(super) fn show_calibration(&mut self, h: f32) {
let picture = (self.screen == Screen::Options)
.then(|| self.ui.options.calibration_picture(&self.gui.atlas, size))
.then(|| self.ui.options.calibration_picture(h))
.flatten();
if let Some(r) = self.renderer.as_mut() {
r.set_hdr_calibration(picture.map(|(step, nits, band)| {
@@ -224,25 +223,20 @@ impl Game<'_> {
let Some(window) = &self.window else {
return;
};
match (picture.is_some(), self.ui.options.calibration_window) {
match (picture.is_some(), self.ui.options.calibration_fullscreen) {
(true, None) => {
// 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 was = window.fullscreen().is_some();
if !was {
let borderless = horizon_client::window::borderless_fullscreen(window);
horizon_client::window::enter_fullscreen(window, borderless);
}
self.ui.options.calibration_window = Some(was);
self.ui.options.calibration_fullscreen = Some(was);
}
(false, Some(was)) => {
let (leave_fullscreen, maximize) = was.leaving();
if leave_fullscreen {
if !was {
window.set_fullscreen(None);
}
if maximize {
window.set_maximized(true);
}
self.ui.options.calibration_window = None;
self.ui.options.calibration_fullscreen = None;
}
_ => {}
}
@@ -403,7 +397,7 @@ impl Game<'_> {
}
self.dragging = false;
// Closed from calibration (horizon-ctl, a lost window): out of it.
self.show_calibration((0.0, 0.0));
self.show_calibration(0.0);
}
Event::Language(code) => {
self.load_language(&code);
@@ -557,14 +551,6 @@ 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;
@@ -642,7 +628,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_window.is_some() {
if self.ui.options.calibration_fullscreen.is_some() {
return;
}
let Some(w) = &self.window else { return };
+4 -6
View File
@@ -306,11 +306,10 @@ impl Game<'_> {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, server, mods, disabled, dev_mods) = (
let (selected, server, mods, 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) = (
@@ -321,8 +320,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, &disabled, &dev_mods, &assets, &res, &context,
unicode, japanese, progress,
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode,
japanese, progress,
)
.map_err(|e| e.to_string())
})?);
@@ -523,7 +522,6 @@ 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,
@@ -532,7 +530,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, mods_disabled, dev_mods);
let base = super::mods::content_root(vanilla_root, mods_enabled, 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
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more