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
507 changed files with 6571 additions and 17908 deletions

No files matched your search

+11 -14
View File
@@ -80,24 +80,21 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -220,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
View File
@@ -201,9 +201,6 @@ jobs:
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: Unrelated DLLs cannot intercept launcher or setup startup
if: runner.os == 'Windows'
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
+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
+13 -45
View File
@@ -1418,7 +1418,6 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1451,7 +1450,6 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1502,6 +1500,7 @@ version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
@@ -1523,14 +1522,16 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1586,8 +1587,8 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"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,13 +1782,7 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-core",
"horizon-hooks",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
"horizon-system",
"horizon-world",
]
[[package]]
@@ -1894,6 +1856,10 @@ dependencies = [
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
]
[[package]]
name = "horizon-server"
@@ -1906,9 +1872,11 @@ dependencies = [
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-platform",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
@@ -1941,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 = [
+2 -2
View File
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review_with_revoked(&base, &head, &revoked, author);
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
-2
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The hook the hub asks (`chat_guard`), and the moderation durations.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+91 -139
View File
@@ -1,8 +1,8 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -10,11 +10,6 @@ use std::path::{Path, PathBuf};
mod normalise;
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
pub use horizon_server_files::chat_guard::{Action, Mute};
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -223,59 +218,49 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// A mute as the mutes file keeps it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct SavedMute {
uuid: String,
name: String,
until: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl From<SavedMute> for Mute {
fn from(m: SavedMute) -> Self {
let SavedMute {
uuid,
name,
until,
by,
reason,
} = m;
Mute {
uuid,
name,
until,
by,
reason,
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
impl From<Mute> for SavedMute {
fn from(m: Mute) -> Self {
let Mute {
uuid,
name,
until,
by,
reason,
} = m;
SavedMute {
uuid,
name,
until,
by,
reason,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<SavedMute>,
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
@@ -305,7 +290,7 @@ impl Guard {
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m.into());
mutes.insert(uuid, m);
}
}
}
@@ -443,7 +428,7 @@ impl Guard {
};
let now = now_millis();
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
@@ -454,94 +439,6 @@ impl Guard {
}
}
impl ChatGuard for Guard {
fn matches(&self, text: &str) -> bool {
self.filter.matches(text)
}
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
Guard::muted(self, uuid, now)
}
fn mutes(&self, now: i64) -> Vec<&Mute> {
Guard::mutes(self, now)
}
fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
Guard::mute(self, uuid, name, until, by, reason);
}
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
Guard::strike(self, uuid, name, now)
}
fn unmute(&mut self, name: &str) -> Option<String> {
Guard::unmute(self, name)
}
fn log(
&mut self,
now: i64,
name: &str,
uuid: u128,
place: Place,
count: u32,
action: Action,
text: &str,
) -> Result<(), String> {
let path = &self.config.log;
if path.is_empty() {
return Ok(());
}
let line = log_line(now, name, uuid, place.name(), count, action, text);
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
}
}
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
let mut loaded = Loaded::default();
let config = match Config::from_server_toml(server_toml) {
Ok(config) => config,
Err(e) => {
loaded.log.push((
"ERROR",
format!("server.toml: {e}; the chat filter stays off"),
));
return loaded;
}
};
if !config.enabled {
return loaded;
}
let (guard, problem) = Guard::load(config, dir);
if let Some(problem) = problem {
loaded.log.push(("WARN", problem));
}
loaded.log.push((
"INFO",
format!(
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
guard.filter.len()
),
));
loaded.guard = Some(Box::new(guard));
loaded
}
/// Gives the dedicated server this chat filter.
pub fn install() {
chat_guard::install(load);
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
@@ -577,6 +474,61 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
+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();
+12 -5
View File
@@ -18,10 +18,10 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
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).
@@ -36,6 +36,8 @@ horizon-server-files = { path = "../horizon-server-files" }
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
@@ -71,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"
@@ -109,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 -2
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
@@ -78,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");
}
-39
View File
@@ -16,45 +16,6 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
pub fn filtered_message(
content: &str,
(kind, bits): &(i32, Vec<i64>),
) -> Option<serde_json::Value> {
match kind {
1 => return None,
2 => {}
_ => return Some(serde_json::Value::from(content)),
}
let masked = |i: usize| {
bits.get(i / 64)
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
};
let units: Vec<u16> = content.encode_utf16().collect();
let mut parts = Vec::new();
let mut start = 0;
while start < units.len() {
let filtered = masked(start);
let end = (start..units.len())
.find(|&i| masked(i) != filtered)
.unwrap_or(units.len());
parts.push(if filtered {
serde_json::json!({
"text": "#".repeat(end - start),
"color": "dark_gray",
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
})
} else {
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
});
start = end;
}
Some(serde_json::json!({"text": "", "extra": parts}))
}
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
@@ -1026,43 +1026,3 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
/// into a dark grey `#` whose hover reads `chat.filtered`.
#[test]
fn a_server_filter_mask_hides_or_masks_the_message() {
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
let shown = |content: &str, mask: (i32, Vec<i64>)| {
filtered_message(content, &mask).map(|v| text(&v).plain())
};
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
assert_eq!(shown("hello", (1, vec![])), None);
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
assert_eq!(
shown("hello", (2, vec![0b1_0001])),
Some("#ell#".to_owned())
);
// An emoji is two UTF-16 units, so two bits and two `#`.
assert_eq!(
shown("a\u{1F600}b", (2, vec![0b0110])),
Some("a##b".to_owned())
);
// Bit 65 is in the second word.
let long = "x".repeat(70);
let mut expected = long.clone();
expected.replace_range(65..66, "#");
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
let hover = hash.style.hover.as_ref().unwrap();
assert_eq!(hover.spans[0].text, "chat.filtered");
assert!(
masked
.spans
.iter()
.filter(|s| s.text != "##")
.all(|s| s.style.color.is_none())
);
}
+1 -6
View File
@@ -43,11 +43,6 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
}
}
/// The native mod call this thread is currently inside, if any.
pub fn current_native_mod() -> Option<NativeModCall> {
IN_NATIVE_MOD.with(Cell::get)
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
@@ -249,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| {
@@ -305,9 +305,6 @@ pub(crate) struct Colormap {
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
@@ -326,18 +323,6 @@ enum Axis {
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
@@ -388,23 +373,6 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub(crate) fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
@@ -456,11 +424,9 @@ impl Colormap {
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
+3 -6
View File
@@ -482,16 +482,13 @@ impl Extras {
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file. The settings stores of this file follow these lines
/// (`horizon_hooks::settings`).
/// setup gains no file.
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
let mut text = self.serialize();
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
if *self == Self::default() && stores_default && !path.exists() {
if *self == Self::default() && !path.exists() {
return;
}
let _ = fs::write(path, text);
let _ = fs::write(path, self.serialize());
}
}
+152 -41
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,10 +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>,
/// Since when Auto keeps HDR on through the display's loss of its report.
auto_off_since: Option<std::time::Instant>,
/// 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
@@ -1544,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.
@@ -2993,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) =
@@ -5356,13 +5346,10 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
auto_off_since: 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,
@@ -5531,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));
@@ -5540,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);
}
@@ -5644,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,6 +7083,18 @@ impl Renderer {
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
@@ -7151,11 +7149,106 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.caps_for(settings),
output_apply::graced(self.auto_off_since, self.reading, &self.output),
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) {
@@ -7165,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.
@@ -7550,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)
@@ -7603,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(),
@@ -7626,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.
@@ -10299,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
@@ -10332,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
@@ -10364,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);
@@ -10585,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
@@ -11973,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,
@@ -15288,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);
@@ -15332,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")]
@@ -15341,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,941 +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. Auto keeps HDR on through a
//! moment's loss of the display's report ([`auto_grace`]).
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use crate::graphics::HdrMode;
use output::{EffectiveOutput, OutputCapabilities};
use std::time::{Duration, Instant};
/// 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
}
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
/// measured sleep/wake time.
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
/// and the surface still offers that encoding, a report that turns false or unknown starts
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
/// offer, and entering HDR take `raw` at once.
fn auto_grace(
now: Instant,
mode: HdrMode,
offered: Option<hdr::Output>,
raw: DisplayReading,
applied: &EffectiveOutput,
since: &mut Option<Instant>,
) -> DisplayReading {
let lost = mode == HdrMode::Auto
&& applied.encoding.is_some()
&& offered == applied.encoding
&& raw.hdr != Some(true);
if !lost {
if since.take().is_some() && raw.hdr == Some(true) {
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
}
} else if since.is_none() {
log::info!(
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
AUTO_OFF_DELAY.as_millis()
);
*since = Some(now);
} else if grace_over(*since, now) {
*since = None;
}
graced(*since, raw, applied)
}
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
}
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
/// luminance `applied` was resolved with; `raw` otherwise.
pub(super) fn graced(
since: Option<Instant>,
raw: DisplayReading,
applied: &EffectiveOutput,
) -> DisplayReading {
match since {
Some(_) => DisplayReading {
luminance: applied.reported,
hdr: Some(true),
},
None => raw,
}
}
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
/// over. Its end reads the display once more first, however long until the next poll, unless
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
fn poll_display(
watch: Option<&mut DisplayWatch>,
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
since: Option<Instant>,
now: Instant,
read: impl FnOnce() -> DisplayReading,
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
) -> bool {
let over = grace_over(since, now);
let Some(watch) = watch else {
return over;
};
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
{
*next = now;
}
let Some(read) = watch.poll(*reading, now, read) else {
return over;
};
observe(reading, caps, read, offer()) || over
}
/// What `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 offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
Some(hdr::output(&surface?.get_capabilities(adapter)))
}
/// 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,
) {
let now = Instant::now();
self.watch_hdr(settings.hdr);
// Auto's grace ends on a fresh read, also where a settings change comes before the
// frame's `display_changed`.
if grace_over(self.auto_off_since, now) {
self.read_display(now);
}
self.hold_hdr_back(settings);
let reading = auto_grace(
now,
settings.hdr,
self.output_caps.hdr,
self.reading,
&self.output,
&mut self.auto_off_since,
);
let candidate = output::resolve_output(
settings,
self.output_caps,
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);
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// 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) {
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = 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. So does the end of Auto's grace (`auto_grace`), with or without a
/// poll or an event then.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
self.read_display(Instant::now())
}
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
fn read_display(&mut self, now: Instant) -> bool {
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
poll_display(
self.display.as_mut(),
&mut self.reading,
&mut self.output_caps,
self.auto_off_since,
now,
|| DisplayReading::read(surface, adapter),
|| offer(surface, adapter),
)
}
}
#[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);
}
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
/// fake time as the game follows a renderer each frame: `display_changed`, then
/// `update_output` where it says so.
struct Auto {
settings: Graphics,
start: Instant,
watch: DisplayWatch,
reading: DisplayReading,
caps: OutputCapabilities,
applied: EffectiveOutput,
since: Option<Instant>,
swapchain: Swapchain,
/// What the system reports when it is read, and when (ms) it was.
report: DisplayReading,
reads: Vec<u64>,
}
impl Auto {
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
/// (`event`), as on Wayland.
fn new(polled: bool) -> Self {
let settings = Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
};
let (caps, reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
let start = Instant::now();
Self {
settings,
start,
watch: DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: polled.then_some(start),
},
reading,
caps,
applied,
since: None,
swapchain: Swapchain::default(),
report: reading,
reads: Vec::new(),
}
}
/// A frame at `ms`.
fn frame(&mut self, ms: u64) {
if self.read_display(ms) {
self.update(ms);
}
}
/// `Renderer::read_display` at `ms`.
fn read_display(&mut self, ms: u64) -> bool {
let now = self.start + Duration::from_millis(ms);
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
let read = || {
reads.push(ms);
report
};
let (reading, caps) = (&mut self.reading, &mut self.caps);
poll_display(
Some(&mut self.watch),
reading,
caps,
self.since,
now,
read,
|| offer,
)
}
/// Frames from `from` to `to` ms, every 10 ms.
fn frames(&mut self, from: u64, to: u64) {
for ms in (from..=to).step_by(10) {
self.frame(ms);
}
}
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
/// HDR, at 300/800 nits then.
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.report = DisplayReading {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
}),
hdr,
};
self.caps.hdr = offer;
}
/// The system's event at `ms`, with what it now `reports`.
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.reports(hdr, offer);
self.reading = self.report;
self.update(ms);
}
/// `update_output` at `ms`, in its order.
fn update(&mut self, ms: u64) {
let now = self.start + Duration::from_millis(ms);
if grace_over(self.since, now) {
self.read_display(ms);
}
let applied = &self.applied;
let reading = auto_grace(
now,
self.settings.hdr,
self.caps.hdr,
self.reading,
applied,
&mut self.since,
);
let next = candidate(&self.settings, self.caps, reading, applied);
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
// this one: no new renderer.
let reading = graced(self.since, self.reading, &self.applied);
let frame = output::resolve_frame(
&self.settings,
NO_PACK,
self.caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(
frame == self.applied.frame,
self.applied.hdr_on(),
"{ms} ms"
);
}
fn presented(&self) -> Vec<Option<hdr::Output>> {
self.swapchain.asked.iter().map(|a| a.0).collect()
}
}
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
/// is presented again, and the shader `Frame`, what the settings show as active and the
/// nits stay as they were applied, even while the report says nothing of them.
#[test]
fn a_report_that_flickers_off_and_back_swaps_nothing() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
let before = auto.applied;
auto.event(0, Some(true), Some(Pq));
auto.frames(0, 90);
let fresh = auto.applied;
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
auto.event(100, lost, Some(Pq));
auto.frames(100, 390);
assert_eq!(auto.applied, fresh, "{lost:?}");
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
auto.event(400, Some(true), Some(Pq));
auto.frames(400, 1500);
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
assert!(auto.presented().is_empty(), "{lost:?}");
assert_eq!(auto.applied.frame, before.frame);
}
}
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
/// display with no event then. The same loss reported again doesn't restart the timer.
#[test]
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
auto.event(0, lost, Some(Pq));
auto.frames(0, 190);
auto.event(200, lost, Some(Pq));
auto.frames(200, 490);
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
auto.frame(500);
assert_eq!(auto.applied.encoding, None, "{lost:?}");
auto.frames(500, 3000);
assert_eq!(auto.presented(), [None], "{lost:?}");
assert_eq!(auto.since, None);
}
}
/// Where the system is polled, a false read by the poll that starts the timer is read
/// again at the deadline, not two seconds after, before it counts: the display came back
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
/// stays otherwise.
#[test]
fn a_polled_false_is_read_again_at_the_deadline() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
assert_eq!(
(auto.applied.encoding, auto.since.is_some()),
(Some(Pq), true)
);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
/// display before the grace ends, and the frame after reads it no more: the display came
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
#[test]
fn a_settings_change_at_the_deadline_reads_the_display_first() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
auto.update(2500);
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A positive report clears the timer: a later loss has its own 500 ms.
#[test]
fn a_positive_report_clears_the_timer() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(300, Some(true), Some(Pq));
auto.event(700, Some(false), Some(Pq));
auto.frames(700, 1190);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(1200);
assert_eq!(auto.presented(), [None]);
}
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
#[test]
fn off_and_on_take_no_grace() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::Off;
auto.update(200);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::On;
auto.update(200);
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
auto.settings.hdr = HdrMode::Auto;
auto.update(300);
auto.frames(300, 790);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(800);
assert_eq!(auto.presented(), [None]);
}
/// The surface no longer offering the encoding presented ends the grace at once: SDR
/// without a report, the other encoding with one.
#[test]
fn an_encoding_no_longer_offered_is_not_kept() {
for offer in [None, Some(ScRgb)] {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(false), offer);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, None, offer);
assert_eq!(auto.presented(), [None], "{offer:?}");
}
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(true), Some(ScRgb));
assert_eq!(auto.presented(), [Some(ScRgb)]);
}
/// Two windows' timers run apart.
#[test]
fn two_renderers_keep_their_own_timer() {
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
b.start = a.start;
a.event(100, Some(false), Some(Pq));
b.event(300, Some(false), Some(Pq));
a.event(400, Some(true), Some(Pq));
for ms in (300..=900).step_by(10) {
a.frame(ms);
b.frame(ms);
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
}
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
}
}
@@ -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
@@ -55,10 +55,6 @@ mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
mod image_formats;
use image_formats::{image_format, storage_formats_of};
#[cfg(test)]
mod full_outputs_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -129,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,
}
@@ -887,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))
}
@@ -925,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),
}
}
@@ -1507,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],
@@ -1578,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)
@@ -1830,6 +1826,98 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
@@ -2195,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>,
@@ -2717,21 +2805,10 @@ impl PackPost {
.shadow
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
let mut plain = post.shadow_water_plain;
let shadow_water = post
.shadow_water
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
let shadow_water = shadow_water.or_else(|| {
unpatched
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
});
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
let geometry = PackSky::new(device);
@@ -3288,17 +3365,6 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
// Said as what it is, not as a program skipped.
if let Some(name) = water_unpatched {
let skipped = format!("{name}: skipped: ");
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
*n = format!(
"{name}: translucent terrain without the boats' water patches, as the GPU \
refused it with them ({})",
&n[skipped.len()..]
);
}
}
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
@@ -5999,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 {
@@ -1,109 +0,0 @@
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
//! the program without them, not no shadow program. The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack(outputs: u32) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let declared: String = (0..outputs)
.map(|i| format!("varying vec4 v{i};\n"))
.collect();
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
add(
"shadow.vsh",
&format!(
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
),
);
add(
"shadow.fsh",
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-full-outputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let geometry = || {
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
})
};
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
let post = gpu.build_with_terrain(pack(outputs), geometry());
assert!(post.has_shadow(), "{:?}", post.notes);
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
post.notes
.iter()
.any(|n| n.contains("without the boats' water patches")),
"{:?}",
post.notes
);
// With room for them, the patches stay and nothing is said.
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
!post.notes.iter().any(|n| n.contains("water patches")),
"{:?}",
post.notes
);
}
@@ -1,97 +0,0 @@
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
//! storage images declare.
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
pub(super) fn storage_formats_of(
module: &mut naga::Module,
entries: &[wgpu::BindGroupLayoutEntry],
) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
@@ -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),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

@@ -1,55 +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"),
),
("slot", include_bytes!("jei_recipes/slot.png")),
(
"icons/list_badge",
include_bytes!("jei_recipes/icons/list_badge.png"),
),
(
"interactive_ingredient_tooltip_background",
include_bytes!("jei_recipes/interactive_ingredient_tooltip_background.png"),
),
(
"recipe_preview_background_v2",
include_bytes!("jei_recipes/recipe_preview_background_v2.png"),
),
(
"ingredient_list_slot_background_v2",
include_bytes!("jei_recipes/ingredient_list_slot_background_v2.png"),
),
(
"scrollbar_background_v2",
include_bytes!("jei_recipes/scrollbar_background_v2.png"),
),
(
"scrollbar_marker_v2",
include_bytes!("jei_recipes/scrollbar_marker_v2.png"),
),
];
-3
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,12 +62,10 @@ 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;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
+8 -80
View File
@@ -761,49 +761,6 @@ pub struct MeshTables {
colormaps: Vec<Arc<crate::environment::Colormap>>,
water_colormap: Option<u16>,
}
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
/// spreads to every block of that function. Each with the tints that read it.
fn biome_functions(
pack_root: &std::path::Path,
modifiers: &[(bool, horizon_polytone::Targets, String)],
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
use crate::environment::Colormap;
use horizon_polytone::colors::BiomeColor;
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
(
"grass",
"minecraft:grass_block",
&[
Tint::Grass,
Tint::GrassBlock,
Tint::TallGrassUpper,
Tint::SugarCane,
],
),
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
("water", "minecraft:water", &[Tint::Water]),
];
let colors = horizon_polytone::colors::PackColors::read(pack_root);
FUNCTIONS
.into_iter()
.filter_map(|(function, block, tints)| {
let colormap = match colors.biome_color(function) {
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
None => modifiers
.iter()
.find(|(fluid, targets, _)| {
*fluid == (function == "water") && targets.matches(block, |_| false)
})
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
}?;
Some((tints, colormap))
})
.collect()
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
@@ -988,8 +945,7 @@ impl MeshTables {
}
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
/// from the colormap, and its own tint where the colormap's pixel is transparent. Vanilla's
/// four biome colour functions take the packs' `biome_colors` first (wiki "Custom Colors").
/// from the colormap, and its own tint where the colormap's pixel is transparent.
#[must_use]
pub fn with_polytone_colormaps(
mut self,
@@ -997,8 +953,7 @@ impl MeshTables {
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
let biome_functions = biome_functions(pack_root, &modifiers);
if modifiers.is_empty() && biome_functions.is_empty() {
if modifiers.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
@@ -1009,24 +964,7 @@ impl MeshTables {
Some((colormaps.len() - 1) as u16)
})
};
for (tints, colormap) in biome_functions {
self.colormaps.push(Arc::new(colormap));
let i = Some((self.colormaps.len() - 1) as u16);
if tints == [Tint::Water] {
self.water_colormap = i;
}
for info in &mut self.info {
let own = &info.tints[..usize::from(info.tint_count).min(2)];
if own.iter().any(|t| tints.contains(t)) {
info.colormap = i;
}
}
}
let water_function = self.water_colormap;
for (fluid, targets, id) in &modifiers {
if *fluid && water_function.is_some() {
continue;
}
if *fluid {
if targets.matches("minecraft:water", |_| false) {
self.water_colormap = index(id, &mut self.colormaps);
@@ -1557,9 +1495,9 @@ struct Tints<'a> {
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
water_colormap: Option<u16>,
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
/// Each colormap's own colour by block, before the blend averages them.
cells: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
cells: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1580,8 +1518,8 @@ impl<'a> Tints<'a> {
uniform: None,
colormaps: &[],
water_colormap: None,
sampled: Default::default(),
cells: Default::default(),
sampled: std::collections::HashMap::new(),
cells: std::collections::HashMap::new(),
}
}
/// With the packs' colormaps of `tables`.
@@ -1593,16 +1531,10 @@ impl<'a> Tints<'a> {
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
/// is transparent.
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
let colormap = self.colormaps.get(usize::from(index))?.clone();
// A colormap that ignores the height, over one biome: one colour per column.
let y = if colormap.column && self.uniform_biome().is_some() {
0
} else {
y
};
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
return known;
}
let colormap = self.colormaps.get(usize::from(index))?.clone();
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
// channel summed and divided. A transparent pixel in the square leaves the block's own.
let r = if colormap.biome_blend { self.radius } else { 0 };
@@ -1641,11 +1573,7 @@ impl<'a> Tints<'a> {
if let Some(&known) = self.cells.get(&(index, at)) {
return known;
}
// One biome over the section and its blend margin: no zoom per block.
let id = match self.uniform_biome() {
Some(biome) => biome,
None => Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at),
};
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at);
let [cx, cz, base_y] = self.origin;
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
@@ -251,117 +251,3 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
}
assert!(edge_differs, "the square must cross the edge somewhere");
}
#[test]
fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function() {
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
// biome_colors gives water a flat green; a block modifier colours oak leaves with a map,
// which Polytone spreads to the whole foliage function.
let root = pack(&[
(
"assets/mypack/polytone/colors.json",
br#"{"biome_colors": {"water": "0x00ff00"}}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/leaves.png",
png_row(&[[0, 0, 255, 255]]),
),
(
"assets/mypack/polytone/block_modifiers/oak.json",
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
),
]);
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
let functions = biome_functions(&root, &modifiers);
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
assert_eq!(tints.len(), 2);
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
tints.colormaps = &colormaps;
tints.water_colormap = Some(1);
assert_eq!(tints.water(3, 4, 5), 0x00_FF00);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
let root = pack(&[
(
"assets/mypack/polytone/colormaps/flat.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/flat.json",
br#"{"x_axis": "biome_id", "y_axis": "r.noise(o.x/60, o.z/60)"}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/tall.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/tall.json",
br#"{"y_axis": "y_level"}"#.to_vec(),
),
]);
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
assert!(load("mypack:flat").column);
assert!(!load("mypack:tall").column);
std::fs::remove_dir_all(root).unwrap();
}
/// Times a section's colormap tints with the freepack's kind of grass colormap (a biome id
/// column and a noise row) over the default Biome Blend: `cargo test --release ... -- --ignored
/// --nocapture colormap_tint_cost`.
#[test]
#[ignore = "timing, not a check"]
fn colormap_tint_cost() {
const RADIUS: i32 = 2;
const LAYERS: i32 = 16;
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
let pixels: Vec<[u8; 4]> = (0..16).map(|i| [i * 16, 200, 64, 255]).collect();
let root = pack(&[
("assets/mypack/polytone/colormaps/grass.png", png_row(&pixels)),
(
"assets/mypack/polytone/colormaps/grass.json",
br#"{"x_axis": "biome_id", "y_axis": "0.5+(r.noise(o.x/60,5+o.z/60)*0.55)", "biome_id_mapper": {"minecraft:plains": 1, "texture_size": 16}}"#
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
)];
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let rounds = 50;
let start = std::time::Instant::now();
let mut check = 0u64;
for _ in 0..rounds {
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), RADIUS, [0, 0, 64]);
tints.colormaps = &colormaps;
for y in 0..LAYERS {
for z in 0..16 {
for x in 0..16 {
check += u64::from(tints.colormap(0, x, y, z).unwrap_or(0));
}
}
}
}
let per = start.elapsed() / rounds;
println!("{LAYERS} layers of 256 blended colormap tints: {per:?} (checksum {check})");
std::fs::remove_dir_all(root).unwrap();
}
@@ -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);
}
}
@@ -2,8 +2,6 @@
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use crate::block_entity_render::DYES;
@@ -32,96 +30,6 @@ pub fn set(colors: &PackColors) {
}
}
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
pub fn set_item_bars(root: &Path) {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
let bar = bars
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
@@ -188,49 +96,4 @@ mod tests {
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
set_item_bars(&dir);
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
set_item_bars(&dir.join("none"));
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -362,13 +362,6 @@ impl Settings {
.and_then(|dir| fs::read_to_string(dir.join(crate::extras::FILE)).ok())
.unwrap_or_default();
let mut extras = crate::extras::Extras::parse(&extras_text);
// The settings stores of the crates above the engine: their lines of this file, or of
// their own (docs/layers.md, "Platform registries").
horizon_hooks::settings::engine::load(&mut |file| match path.parent() {
_ if file == crate::extras::FILE => Some(extras_text.clone()),
Some(dir) => fs::read_to_string(dir.join(file)).ok(),
None => None,
});
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
@@ -697,7 +690,6 @@ impl Settings {
let mut extras = self.extras.clone();
extras.emote_key = self.keys.get("key.emote").to_owned();
extras.save(dir);
horizon_hooks::settings::engine::save_own(dir, &[crate::extras::FILE]);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
+45 -10
View File
@@ -549,9 +549,6 @@ pub struct Streaming {
pub mod_forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// Finished meshes the renderer has not taken yet (the game's pending
/// uploads): they count against the meshing room as the jobs do.
pub upload_backlog: usize,
/// Dimension flags: sky light, Nether face shading.
pub sky_light: bool,
pub nether_shading: bool,
@@ -1065,9 +1062,7 @@ pub struct WorkerContext {
pub biomes: Arc<crate::biome::Biomes>,
pub light: Arc<LightTables>,
}
mod bounded;
mod relight_now;
use bounded::nearest_first;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1152,7 +1147,6 @@ impl Streaming {
forced: HashSet::new(),
mod_forced: HashSet::new(),
paused: false,
upload_backlog: 0,
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
@@ -2089,10 +2083,7 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
// Meshes waiting for upload count too: when uploads fall behind (a
// low frame rate at a wide distance), meshing on held gigabytes of
// them until the system swapped.
let room = (self.workers * 32).saturating_sub(self.meshing.len() + self.upload_backlog);
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates.drain(..) {
let Some(region) = Self::region(world, x, z) else {
@@ -3030,6 +3021,19 @@ fn light_region_ready(
}
true
}
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
/// What `keep_unchanged_sections` found changed.
#[derive(Debug, Default, PartialEq)]
struct Resent {
@@ -3582,6 +3586,37 @@ mod tests {
assert_eq!(pops(queue), [None]);
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
/// What the scheduler counts as ready to light: every neighbour a
/// finished column, or a decorated proto chunk past the area — the
/// walk `light_region` makes without its snapshot.
@@ -1,52 +0,0 @@
//! Bounded submission: the light and mesh stages hand the workers only the
//! nearest of what is ready.
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
pub(super) fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
#[cfg(test)]
mod tests {
use super::*;
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
}
+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 -105
View File
@@ -138,10 +138,6 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
| "horizon/jei/button_disabled_v2"
| "horizon/jei/button_highlight_v2" => [6; 4],
"horizon/jei/search_background_v2" => [6; 4],
"horizon/jei/interactive_ingredient_tooltip_background" => [16; 4],
"horizon/jei/recipe_preview_background_v2" => [16; 4],
"horizon/jei/scrollbar_background_v2" => [6; 4],
"horizon/jei/scrollbar_marker_v2" => [2, 2, 2, 1],
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
@@ -1231,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,
@@ -1529,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,
}
@@ -1575,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
@@ -1592,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;
@@ -1700,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 {
@@ -1751,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();
@@ -1817,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();
@@ -2575,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");
}
}
+7 -7
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).
@@ -33,8 +31,7 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md);
# `entry` installs it into the engine's hook (`horizon_server_files::chat_guard`).
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
@@ -70,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"
@@ -120,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.",
@@ -1058,8 +1057,5 @@
"horizon.studio.topbar.add_marker": "Add Marker",
"horizon.studio.topbar.clear_keyframes": "🗑 Clear Keyframes",
"horizon.studio.topbar.timeline_hud": "⏱ Timeline",
"horizon.studio.topbar.split_scene": "Split Scene",
"horizon.recipes.fuel.options": "Hold SHIFT to pin this tooltip, then click an ingredient to view recipes.",
"horizon.recipes.fuel.any": "Any Fuel",
"horizon.recipes.fuel.minecraft": "Minecraft"
"horizon.studio.topbar.split_scene": "Split Scene"
}
+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() {
@@ -1,25 +0,0 @@
//! A saved `speed_multiplier` is read by `ValueInput.getFloatOr`, which takes
//! any numeric tag, not only a float.
use horizon_client_lib::{containers::BlockEntity, save::decode_block_entity};
use horizon_world::nbt::Tag;
fn brewing(speed: Tag) -> f32 {
let tag = Tag::compound([
("id", Tag::String("minecraft:brewing_stand".into())),
("x", Tag::Int(0)),
("y", Tag::Int(64)),
("z", Tag::Int(0)),
("speed_multiplier", speed),
]);
let (_, model) = decode_block_entity(&tag).unwrap();
let BlockEntity::Brewing(stand) = model else {
panic!("a brewing stand");
};
stand.speed
}
#[test]
fn an_integer_speed_multiplier_loads() {
assert_eq!(brewing(Tag::Int(2)), 2.0);
assert_eq!(brewing(Tag::Double(1.5f64.to_bits())), 1.5);
}
+9 -7
View File
@@ -20,13 +20,13 @@ pub use horizon_client_render::{
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -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;
+4 -152
View File
@@ -1,11 +1,9 @@
//! The input handling every survival and container screen shares
//! The mouse handling every survival and container screen shares
//! (`AbstractContainerScreen.mouseClicked` / `mouseReleased`): which slot id
//! a click targets (-999 outside the window, `hasClickedOutside` with the
//! recipe book's own rule) and which `ContainerInput` it sends. Shift is
//! the event's own modifiers (`InputWithModifiers.hasShiftDown`: the
//! physical Shift keys, never the remapped or toggled sneak key). The
//! keyboard's hotbar and off-hand keys are the same screen's
//! (`checkHotbarKeyPressed`).
//! physical Shift keys, never the remapped or toggled sneak key).
use crate::menu::Input;
@@ -95,44 +93,14 @@ 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)]
#[derive(Clone, Debug, Default)]
pub struct Clicks {
/// `isQuickCrafting` with `quickCraftingButton`.
quick_crafting: Option<Button>,
skip_next_release: bool,
}
impl Default for Clicks {
fn default() -> Self {
Self {
quick_crafting: None,
// AbstractContainerScreen's constructor: `this.skipNextRelease =
// true`. The release of the click that opened the screen (a chest
// right-click opens it on the server's answer) is not a click on
// the slot now under the pointer.
skip_next_release: true,
}
}
}
/// What a release does.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Release {
@@ -165,9 +133,7 @@ impl Clicks {
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
// A fresh `AbstractContainerScreen` starts with `skipNextRelease`
// true, so the next release without a press is swallowed.
self.skip_next_release = true;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
@@ -240,32 +206,6 @@ impl Clicks {
}
}
/// A key `AbstractContainerScreen.checkHotbarKeyPressed` acts on: one of the
/// nine hotbar slots (`keyHotbarSlots`) or the off hand (`keySwapOffhand`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HotbarKey {
/// `key.hotbar.N`, in `0..9`.
Slot(usize),
/// `key.swapOffhand`, the menu's button 40.
Offhand,
}
/// What `AbstractContainerScreen.checkHotbarKeyPressed` answers: the menu
/// button of the SWAP click, or `None` when the screen refuses it. The whole
/// method is gated on `this.menu.getCarried().isEmpty() && this.hoveredSlot
/// != null`, so a carried stack sends nothing; without that guard the
/// server's SWAP would move the hotbar slot into the slot under the cursor
/// while the stack stays on the cursor.
pub fn hotbar_swap(hovered: Option<HotbarKey>, carried_empty: bool) -> Option<i32> {
if !carried_empty {
return None;
}
Some(match hovered? {
HotbarKey::Slot(slot) => i32::try_from(slot).ok()?,
HotbarKey::Offhand => 40,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -441,92 +381,4 @@ mod tests {
Some(Input::Pickup)
);
}
/// `AbstractContainerScreen`'s constructor sets `skipNextRelease = true`:
/// a release with no press before it on this screen clicks nothing. This
/// is the release of the click that opened the screen (the server's
/// answer to a chest's right-click comes after the button went up), over
/// the slot now under the pointer. A carried stack is what makes the
/// release click, so that is the hand it is tested with.
#[test]
fn a_release_before_any_press_clicks_nothing() {
let slot = Target::<usize>::Slot(0);
let mut clicks = Clicks::default();
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Nothing
);
// A press and release are a normal click again.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Click(Input::Pickup)
);
}
/// A screen that takes the pointer over starts as a fresh
/// `AbstractContainerScreen` does: `cancel_drag` drops the old drag and
/// leaves `skipNextRelease` true, so the opening click's release (a
/// carried stack is what would click) does nothing.
#[test]
fn a_screen_that_takes_the_pointer_over_swallows_its_first_release() {
let slot = Target::<usize>::Slot(3);
let mut clicks = Clicks::default();
// The old screen's press with a stack carried left a drag behind.
clicks.press(&slot, Button::Left, false, false, false);
clicks.cancel_drag();
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Nothing
);
}
/// `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)));
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`:
/// `this.menu.getCarried().isEmpty() && this.hoveredSlot != null` gates
/// the off hand and the nine hotbar slots alike. A stack on the cursor
/// refuses the key (the menu's SWAP does not look at the carried stack
/// and would move the hotbar slot into the hovered one); so does no
/// hovered slot.
#[test]
fn a_carried_stack_refuses_the_container_hotbar_keys() {
for carried_empty in [false, true] {
for slot in 0..9 {
let key = HotbarKey::Slot(slot);
assert_eq!(
hotbar_swap(Some(key), carried_empty),
carried_empty.then_some(slot as i32)
);
}
assert_eq!(
hotbar_swap(Some(HotbarKey::Offhand), carried_empty),
carried_empty.then_some(40)
);
}
assert_eq!(hotbar_swap(None, true), None);
assert_eq!(hotbar_swap(None, false), None);
}
}
+2 -73
View File
@@ -26,8 +26,6 @@ pub struct Tab {
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
/// pages them.
pub page: u32,
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
pub name: Option<(String, Option<u32>)>,
}
impl Tab {
/// The tab without its stacks.
@@ -43,7 +41,6 @@ impl Tab {
inventory: self.inventory,
hotbar: self.hotbar,
page: self.page,
name: self.name.clone(),
}
}
}
@@ -100,29 +97,6 @@ pub struct Creative {
/// of every search item, built on the first search and kept until the
/// translations, item list or enchantment registry change.
lines_cache: Mutex<Option<(u64, Arc<[Vec<String>]>)>>,
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
}
/// A text component's plain text and its colour (a name, or `#rrggbb`).
fn component_text(json: &Value) -> (String, Option<u32>) {
match json {
Value::String(s) => (s.clone(), None),
Value::Object(o) => {
let text = o
.get("text")
.or_else(|| o.get("translate"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
c.strip_prefix('#')
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| crate::text::named_color(c))
});
(text, color)
}
_ => (String::new(), None),
}
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
@@ -326,7 +300,6 @@ impl Creative {
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
page: t["page"].as_u64().unwrap_or(0) as u32,
name: None,
},
category: kind == "CATEGORY",
operator,
@@ -367,7 +340,6 @@ impl Creative {
inventory: kind == "INVENTORY",
hotbar: false,
page: 0,
name: None,
},
category: false,
});
@@ -408,7 +380,6 @@ impl Creative {
permissions: false,
shown: Mutex::new(None),
lines_cache: Mutex::new(None),
pack: Vec::new(),
};
creative.rebuild();
creative
@@ -428,12 +399,10 @@ impl Creative {
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
let mut tab = Tab {
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
};
self.apply_pack(&mut tab);
(!e.category || !tab.items.is_empty()).then_some(tab)
})
})
.collect();
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
@@ -445,44 +414,6 @@ impl Creative {
.unzip();
self.forget_items();
}
/// The packs' Polytone creative tab modifiers (wiki "Creative Tab Modifiers"): the tabs are
/// built again with them.
pub fn set_pack_modifiers(&mut self, pack: Vec<crate::polytone_creative_tabs::TabModifier>) {
self.pack = pack;
self.rebuild();
self.selected = self.selected.min(self.tabs.len().saturating_sub(1));
}
/// A tab after the modifiers aimed at it: its items, icon and name.
fn apply_pack(&self, tab: &mut Tab) {
let aimed = self
.pack
.iter()
.filter(|m| m.targets.matches(&tab.id, |_| false));
for m in aimed {
let in_tag = |item: &str, tag: &str| {
let (namespace, path) = split_id(tag);
self.exported_search.iter().any(|(s, e, _)| {
*s.item == *item && e.tags.iter().any(|t| t.0 == namespace && t.1 == path)
})
};
let mut ids: Vec<String> = tab.items.iter().map(|s| s.item.to_string()).collect();
m.apply(&mut ids, &in_tag);
let mut old = std::mem::take(&mut tab.items);
tab.items = ids
.into_iter()
.map(|id| match old.iter().position(|s| *s.item == *id) {
Some(i) => old.remove(i),
None => ItemStack::new(&id, 1),
})
.collect();
if let Some(icon) = &m.icon {
tab.icon = icon.clone();
}
if let Some(name) = &m.name {
tab.name = Some(component_text(name));
}
}
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
@@ -552,7 +483,6 @@ impl Creative {
inventory: false,
hotbar: false,
page: 0,
name: None,
});
}
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -1748,7 +1678,6 @@ mod tests {
inventory: false,
hotbar: false,
page: 0,
name: None,
};
// Top row, second column: the button spans x 27..53, y -28..4.
let t = tab(true, 1);
+8 -11
View File
@@ -4,8 +4,7 @@
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
//! or with `HORIZON_NO_DISCORD=1`.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
@@ -15,7 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_command::args::GameMode;
use crate::command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -52,11 +51,9 @@ pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
/// This world is open to LAN or friends; `players` counts the host.
Hosting {
players: usize,
max: usize,
},
}
@@ -256,7 +253,7 @@ fn activity(status: Status, since: Option<u64>) -> Option<Value> {
let (details, party) = match play {
Play::Singleplayer => ("Singleplayer", None),
Play::Multiplayer => ("Multiplayer", None),
Play::Hosting { players, max } => ("Hosting a world", Some((players, max))),
Play::Hosting { players } => ("Hosting a world", Some(players)),
};
(details, Some(place), party)
}
@@ -268,7 +265,8 @@ fn activity(status: Status, since: Option<u64>) -> Option<Value> {
if let Some(start) = since {
a["timestamps"] = json!({"start": start});
}
if let Some((players, max)) = party {
if let Some(players) = party {
let max = crate::lan::MAX_PLAYERS;
a["party"] = json!({
"id": format!("horizon-{}", std::process::id()),
"size": [players.clamp(1, max), max],
@@ -533,7 +531,7 @@ mod tests {
assert_eq!(multi["details"], "Multiplayer");
assert_eq!(multi["state"], "In the End");
let host = world(Play::Hosting { players: 3, max: 8 }, Dimension::Overworld);
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
let host = activity(host, Some(5)).unwrap();
assert_eq!(host["details"], "Hosting a world");
assert_eq!(host["state"], "In the Overworld");
@@ -551,8 +549,7 @@ mod tests {
assert_eq!(w.since, Some(20));
// Another dimension, opening to LAN: the same visit.
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
let hosting = world(Play::Hosting { players: 1, max: 8 }, Dimension::Nether);
assert!(w.update(hosting, 40));
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
assert_eq!(w.since, Some(20));
assert!(w.update(Status::Menus, 50));
assert_eq!(w.since, None);
@@ -115,7 +115,6 @@ pub fn update_entity_fluids(
0.0023333333333333335
},
);
let _ = interaction.apply_mod_currents(&mut scene);
(water_height, lava_height, eye_in_water)
}
impl ItemEntity {
+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))),
-6
View File
@@ -13,8 +13,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
// The platform's features the dedicated server loads from server.toml, through the engine's hooks.
horizon_chat_filter::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
if !cfg!(feature = "horizon-extras") {
horizon_client::options_ui::HORIZON_EXTRAS
@@ -23,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();
@@ -308,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);
}
}
}
Loaded 100 of 507 files, more files were not shown because too many files have changed in this diff. Show more