mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2211bd56bb | ||
|
|
d8edfbcd0f | ||
|
|
c9d734aabc | ||
|
|
f40d4df781 | ||
|
|
97f6d296b6 | ||
|
|
baee70be80 | ||
|
|
1191e6b721 | ||
|
|
db7134ac66 | ||
|
|
a8dd3a4106 | ||
|
|
79e09456c4 | ||
|
|
3f6f3a6dc1 | ||
|
|
2e1e541e02 | ||
|
|
0ffc3ec0bb | ||
|
|
b31a68ea8d | ||
|
|
d2e82c7339 | ||
|
|
cefa8659ec | ||
|
|
3fef07f080 | ||
|
|
e5953f9e97 | ||
|
|
48430a7647 | ||
|
|
8a442b616b | ||
|
|
9048266788 | ||
|
|
eba1f44312 | ||
|
|
847d64ca9f | ||
|
|
02423ba72e | ||
|
|
a47ed5699e | ||
|
|
e124a775b1 | ||
|
|
bc29f62f9e | ||
|
|
c351350bad | ||
|
|
79fc2426f3 | ||
|
|
775bdc3c3e | ||
|
|
47a372677c | ||
|
|
eaa7da4fb1 | ||
|
|
a7e434b1ed | ||
|
|
b750f4d7e0 | ||
|
|
297d91b07e | ||
|
|
6d3ed570fb | ||
|
|
16ca83f6f3 | ||
|
|
17b23db474 | ||
|
|
3023ce1dc9 | ||
|
|
4916b004bf | ||
|
|
02d36e14c4 | ||
|
|
757ce1bd14 | ||
|
|
14b5c7632e | ||
|
|
9abb7dc50f | ||
|
|
cbb81c5db4 | ||
|
|
55dcb11813 | ||
|
|
c8f7583036 | ||
|
|
4f35e0d6f8 | ||
|
|
9b05e6d523 | ||
|
|
14cb95e5dd | ||
|
|
3f98dd1ca6 | ||
|
|
9870e9faac | ||
|
|
d179172286 | ||
|
|
62e5ef76fe | ||
|
|
b49b1f8f15 | ||
|
|
7edc16e18d | ||
|
|
d874278161 | ||
|
|
134096b5cf | ||
|
|
485c2f56ea | ||
|
|
33f9fbfda0 | ||
|
|
3a801dcc57 | ||
|
|
f437985e02 | ||
|
|
ef242dfe34 | ||
|
|
2ff9047fff | ||
|
|
23a2db75b0 | ||
|
|
cc66b6f8b6 | ||
|
|
cb7c5c8967 | ||
|
|
0a499bde42 |
No files matched your search
@@ -15,6 +15,9 @@ on:
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check:
|
||||
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
|
||||
|
||||
@@ -15,6 +15,9 @@ on:
|
||||
- '.github/workflows/telemetry-server.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check:
|
||||
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
|
||||
|
||||
+34
-33
@@ -5,7 +5,8 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
|
||||
welcome on [Discord](https://discord.gg/playhorizon).
|
||||
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything
|
||||
else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
@@ -14,23 +15,25 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
|
||||
[Build from source](README.md#build-from-source).
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
(`libasound2-dev libudev-dev`).
|
||||
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
|
||||
described in [Build from source](README.md#build-from-source).
|
||||
- **Commands:**
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
## Vanilla first
|
||||
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
|
||||
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
|
||||
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
|
||||
probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
|
||||
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
|
||||
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
|
||||
and compares their outputs. There are about 290 of them.
|
||||
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
@@ -41,11 +44,10 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
|
||||
for player-facing code.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
|
||||
`tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
@@ -54,24 +56,23 @@ another project's source may also be read and used, **only when that project's l
|
||||
|
||||
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
|
||||
public-domain dedication. That code may be read, copied or adapted.
|
||||
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
|
||||
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
|
||||
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
|
||||
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
|
||||
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
|
||||
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
|
||||
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
|
||||
at your commit counts. In doubt, don't: ask in #contributors first.
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
|
||||
pull request that used another project's source without saying so is closed.
|
||||
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
|
||||
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
|
||||
was changed.
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
|
||||
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
|
||||
another project's source; they come back only as a clean-room rewrite from a written description of the pack
|
||||
format.
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -87,8 +88,8 @@ your change can affect. Without the vanilla data on your machine it lists those
|
||||
`--quick` skips the harnesses. It exits non-zero when something fails.
|
||||
|
||||
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
|
||||
including examples. The dependency scope includes optional, platform, build and development dependencies.
|
||||
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
|
||||
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
|
||||
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
|
||||
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
|
||||
|
||||
## Claim before you start
|
||||
@@ -123,8 +124,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
`label:parity` is the list of real progress toward vanilla.
|
||||
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
|
||||
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
|
||||
fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
|
||||
@@ -64,6 +64,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
|
||||
@@ -222,6 +222,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.graphics_api(),
|
||||
settings.translucent_batching,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
let state = &scene.renderer;
|
||||
let [width, height] = o.size.unwrap_or(state.size);
|
||||
|
||||
@@ -121,6 +121,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
|
||||
@@ -379,14 +379,17 @@ pub fn generate(
|
||||
let (_, _, key) = load(palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = load(palette, "textures/palettes")?;
|
||||
let mut mapped = argb(&pixels);
|
||||
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
|
||||
let out = mapped
|
||||
let key = argb(&key);
|
||||
let target = argb(&target);
|
||||
apply_palette(&mut mapped, &key, &target)?;
|
||||
let out: Vec<u8> = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
drop(mapped);
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ struct Mixer {
|
||||
world_names: Vec<String>,
|
||||
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -544,9 +546,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
(forward, up, right)
|
||||
};
|
||||
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
|
||||
let mut cameras = std::mem::take(&mut mixer.cameras);
|
||||
cameras.clear();
|
||||
cameras.extend(
|
||||
mixer.cameras.clear();
|
||||
mixer.cameras.extend(
|
||||
std::iter::once((
|
||||
0,
|
||||
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
|
||||
@@ -565,10 +566,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
)
|
||||
}),
|
||||
);
|
||||
let shared = cameras.len() > 1;
|
||||
let shared = mixer.cameras.len() > 1;
|
||||
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
continue;
|
||||
@@ -586,7 +588,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = cameras
|
||||
let camera = mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
@@ -617,8 +620,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
mixer.cameras = cameras;
|
||||
for (id, [left, right, gain], step) in voices {
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -648,6 +650,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
mixer.voices = voices;
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
|
||||
@@ -101,6 +101,22 @@ impl AuthSession {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
|
||||
}
|
||||
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
|
||||
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
|
||||
/// says.
|
||||
pub fn expired(&self) -> bool {
|
||||
let exp = self
|
||||
.access_token
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
|
||||
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
|
||||
.and_then(|v| v.get("exp")?.as_u64());
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
exp.is_some_and(|exp| exp <= now + 60)
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
|
||||
@@ -436,11 +452,8 @@ struct ChatKeys {
|
||||
fetching: bool,
|
||||
next_try: Option<std::time::Instant>,
|
||||
}
|
||||
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
|
||||
current: None,
|
||||
fetching: false,
|
||||
next_try: None,
|
||||
});
|
||||
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
|
||||
/// then prepareKeyPair: start fetching when there is no key pair or it is
|
||||
@@ -449,7 +462,14 @@ pub fn prepare_chat_keys(now: bool) {
|
||||
let Some(session) = current() else {
|
||||
return;
|
||||
};
|
||||
let mut keys = CHAT_KEYS.lock().unwrap();
|
||||
prepare_chat_keys_for(session, now);
|
||||
}
|
||||
|
||||
/// Prepare keys for this player, independently of the other local accounts.
|
||||
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
|
||||
let uuid = session.uuid;
|
||||
let mut accounts = CHAT_KEYS.lock().unwrap();
|
||||
let keys = accounts.entry(uuid).or_default();
|
||||
let instant = std::time::Instant::now();
|
||||
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
|
||||
return;
|
||||
@@ -463,12 +483,13 @@ pub fn prepare_chat_keys(now: bool) {
|
||||
return;
|
||||
}
|
||||
keys.fetching = true;
|
||||
drop(keys);
|
||||
drop(accounts);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Profile key fetch".into())
|
||||
.spawn(move || {
|
||||
let result = fetch_chat_keys(&session);
|
||||
let mut keys = CHAT_KEYS.lock().unwrap();
|
||||
let mut accounts = CHAT_KEYS.lock().unwrap();
|
||||
let keys = accounts.entry(uuid).or_default();
|
||||
keys.fetching = false;
|
||||
match result {
|
||||
Ok(pair) => keys.current = Some(Arc::new(pair)),
|
||||
@@ -476,19 +497,24 @@ pub fn prepare_chat_keys(now: bool) {
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
CHAT_KEYS.lock().unwrap().fetching = false;
|
||||
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat key pair fetched so far, unless it has expired.
|
||||
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
|
||||
let now = now_millis();
|
||||
chat_keys_for(current()?.uuid)
|
||||
}
|
||||
|
||||
/// This account's key pair; another local player's keys can never be returned.
|
||||
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
|
||||
CHAT_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&uuid)?
|
||||
.current
|
||||
.clone()
|
||||
.filter(|k| !k.public.has_expired(now))
|
||||
.filter(|k| !k.public.has_expired(now_millis()))
|
||||
}
|
||||
|
||||
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
@@ -632,6 +658,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// base64url without padding, as a JWT's parts are.
|
||||
fn base64url(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::new();
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n =
|
||||
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
|
||||
for k in 0..=chunk.len() {
|
||||
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_past_its_exp_is_expired() {
|
||||
// header.payload.signature, the payload base64url without padding.
|
||||
let token = |exp: u64| {
|
||||
let json = format!(r#"{{"exp":{exp}}}"#);
|
||||
let payload = base64url(json.as_bytes());
|
||||
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
assert!(token(now - 10).expired());
|
||||
assert!(
|
||||
token(now + 30).expired(),
|
||||
"a minute from its end: as good as gone"
|
||||
);
|
||||
assert!(!token(now + 3600).expired());
|
||||
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
|
||||
}
|
||||
|
||||
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
|
||||
let path = dir.join("session.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
@@ -795,6 +857,37 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_accounts_use_their_own_chat_keys() {
|
||||
let pair = |signature: &[u8]| {
|
||||
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
|
||||
keys.public.expires_at = i64::MAX;
|
||||
keys.refreshed_after = i64::MAX;
|
||||
keys.public.signature = signature.to_vec();
|
||||
Arc::new(keys)
|
||||
};
|
||||
let ann = pair(b"ann");
|
||||
let bea = pair(b"bea");
|
||||
{
|
||||
let mut accounts = CHAT_KEYS.lock().unwrap();
|
||||
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
|
||||
accounts.insert(
|
||||
uuid,
|
||||
ChatKeys {
|
||||
current: Some(keys),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
|
||||
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
|
||||
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
|
||||
assert!(chat_keys_for(103).is_none());
|
||||
CHAT_KEYS.lock().unwrap().remove(&101);
|
||||
CHAT_KEYS.lock().unwrap().remove(&102);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificates_response() {
|
||||
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
|
||||
|
||||
@@ -1174,6 +1174,11 @@ pub struct Renderer {
|
||||
emission_stats: hdr_stats::EmissionStats,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
|
||||
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
|
||||
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
|
||||
/// swapchain through `readback`.
|
||||
frame_format: wgpu::TextureFormat,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
@@ -1732,6 +1737,45 @@ fn surface_format(
|
||||
.or_else(|| formats.iter().copied().find(|f| !f.is_srgb()))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
/// The 16-bit frame HDR output draws into where it can (`Renderer::frame_format`). Unorm
|
||||
/// rather than float: it clips at white after every pass as the 8-bit frame does, so light
|
||||
/// added past white (the sun on the sky) and then blended over (glass) looks the same.
|
||||
const WIDE_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Unorm;
|
||||
|
||||
/// Whether a device with `features` can draw, blend and filter `WIDE_FRAME`, whose
|
||||
/// format features (`format`) the adapter reports. Drawing into it is an adapter-specific
|
||||
/// format feature, beyond the format itself.
|
||||
fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeatures) -> bool {
|
||||
features.contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) && format.allowed_usages.contains(
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
) && format.flags.contains(
|
||||
wgpu::TextureFormatFeatureFlags::BLENDABLE | wgpu::TextureFormatFeatureFlags::FILTERABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
|
||||
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
|
||||
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
|
||||
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
fn frame_format(
|
||||
wide_frame: bool,
|
||||
hdr_offered: bool,
|
||||
device_draws_it: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
) -> wgpu::TextureFormat {
|
||||
if wide_frame && hdr_offered && device_draws_it {
|
||||
WIDE_FRAME
|
||||
} else {
|
||||
swapchain.remove_srgb_suffix()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a device on the first of `backend_order`'s backends that gives one.
|
||||
/// Offscreen rendering needs no surface, so it can use a GPU the window's
|
||||
/// display cannot present from (a headless compositor).
|
||||
@@ -2163,6 +2207,7 @@ impl Renderer {
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
) -> Result<Self> {
|
||||
let Opened {
|
||||
@@ -2280,6 +2325,12 @@ impl Renderer {
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
.then(|| Self::offscreen_target(&device, &config));
|
||||
let wide_ok = draws_wide_frame(
|
||||
device.features(),
|
||||
adapter.get_texture_format_features(WIDE_FRAME),
|
||||
);
|
||||
crate::graphics::offer_smooth_gradients(wide_ok);
|
||||
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
@@ -2443,7 +2494,7 @@ impl Renderer {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -2635,7 +2686,7 @@ impl Renderer {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
@@ -2995,7 +3046,7 @@ impl Renderer {
|
||||
let glint_armor_view = glint_armor_texture.create_view(&Default::default());
|
||||
let mut hud = HudRenderer::new(
|
||||
&device,
|
||||
config.format.remove_srgb_suffix(),
|
||||
frame_format,
|
||||
&atlas_view,
|
||||
&glint_view,
|
||||
&glint_sampler,
|
||||
@@ -3088,12 +3139,8 @@ impl Renderer {
|
||||
},
|
||||
)],
|
||||
});
|
||||
let weather = weather::Weather::new(
|
||||
&device,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&entity_bindings,
|
||||
&entity_shader,
|
||||
);
|
||||
let weather =
|
||||
weather::Weather::new(&device, frame_format, &entity_bindings, &entity_shader);
|
||||
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Atlas"),
|
||||
..Default::default()
|
||||
@@ -3297,7 +3344,7 @@ impl Renderer {
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: Some(blend),
|
||||
write_mask,
|
||||
})],
|
||||
@@ -3477,7 +3524,7 @@ impl Renderer {
|
||||
entry_point: Some("fs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
// fs_entity's discard handles binary cutout alpha. Any
|
||||
// sprite with fractional alpha stays on the blended
|
||||
// dynamic entity path.
|
||||
@@ -3569,7 +3616,7 @@ impl Renderer {
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
@@ -3606,7 +3653,7 @@ impl Renderer {
|
||||
// HDR output's mark on lightning bolts: fs_hdr_mark over the bolts' own vertex layout,
|
||||
// as hdr_mark_pipeline marks entities and particles (only alpha written).
|
||||
let mark_targets = [Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})];
|
||||
@@ -3661,7 +3708,7 @@ impl Renderer {
|
||||
entry_point: Some("fs_sky_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
|
||||
// the colour premultiplied in fs_sky_celestial. Its alpha is
|
||||
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
|
||||
@@ -3835,7 +3882,7 @@ impl Renderer {
|
||||
entry_point: Some("fs_border"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
@@ -3913,7 +3960,7 @@ impl Renderer {
|
||||
entry_point: Some(entry),
|
||||
compilation_options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
format: frame_format,
|
||||
blend: Some(blend),
|
||||
// 8 and up: colour only, for a tint of the world in HDR output.
|
||||
write_mask: if i & 8 == 0 {
|
||||
@@ -4064,13 +4111,7 @@ impl Renderer {
|
||||
log::info!(
|
||||
"Horizon: solid terrain through a visibility buffer (HORIZON_VISBUFFER)"
|
||||
);
|
||||
visbuffer::Visbuffer::new(
|
||||
&device,
|
||||
&bindings,
|
||||
f,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&sprite_constants,
|
||||
)
|
||||
visbuffer::Visbuffer::new(&device, &bindings, f, frame_format, &sprite_constants)
|
||||
});
|
||||
let multi_draw = device
|
||||
.features()
|
||||
@@ -4132,8 +4173,7 @@ impl Renderer {
|
||||
_ => depth_test,
|
||||
})
|
||||
.map(|depth_test| {
|
||||
let mut cull =
|
||||
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
|
||||
let mut cull = cull::Cull::new(&device, frame_format, depth_test);
|
||||
cull.always = cull_env.as_deref() == Ok("all");
|
||||
if !cull.always && adapter_info.backend == wgpu::Backend::Metal {
|
||||
cull.min_sections = cull::TILED_MIN_SECTIONS;
|
||||
@@ -4141,17 +4181,16 @@ impl Renderer {
|
||||
cull
|
||||
});
|
||||
let depth = Self::depth(&device, &config);
|
||||
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
|
||||
let world_post = world_post::Post::new(&device, frame_format);
|
||||
let uploads = upload::Uploads::new(&device);
|
||||
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
|
||||
let stars = stars::Stars::new(&device, config.format.remove_srgb_suffix());
|
||||
let stars = stars::Stars::new(&device, frame_format);
|
||||
let entity_outline = entity_outline::EntityOutline::new(
|
||||
&device,
|
||||
(&entity_shader, &entity_layout, &entity_vertex),
|
||||
config.format.remove_srgb_suffix(),
|
||||
frame_format,
|
||||
);
|
||||
let panorama =
|
||||
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
|
||||
let panorama = panorama::Panorama::new(&device, frame_format, panorama_faces);
|
||||
drop(pipelines_step);
|
||||
reuse.finish(generation);
|
||||
if previous.is_some() {
|
||||
@@ -4372,6 +4411,7 @@ impl Renderer {
|
||||
hdr: None,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
@@ -4471,7 +4511,7 @@ impl Renderer {
|
||||
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
|
||||
/// outline, or the built-in one.
|
||||
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let format = self.frame_format;
|
||||
self.core_lines =
|
||||
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
|
||||
}
|
||||
@@ -5826,7 +5866,7 @@ impl Renderer {
|
||||
} else {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let format = self.frame_format;
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
self.configure_surface();
|
||||
@@ -5982,7 +6022,7 @@ impl Renderer {
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
post,
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
self.frame_format,
|
||||
world,
|
||||
)
|
||||
});
|
||||
@@ -6033,7 +6073,7 @@ impl Renderer {
|
||||
globals: &self.terrain_layout,
|
||||
flat: self.flat.as_ref().map(|f| &f.draw_layout),
|
||||
sprites: self.terrain_sprites,
|
||||
format: self.config.format.remove_srgb_suffix(),
|
||||
format: self.frame_format,
|
||||
sprite_tags: &sprite_tags,
|
||||
sprite_materials: &sprite_materials,
|
||||
entities: &self.entity_bindings,
|
||||
@@ -6422,6 +6462,12 @@ impl Renderer {
|
||||
let mut config = self.config.clone();
|
||||
[config.width, config.height] = size;
|
||||
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
|
||||
if self.frame_format != config.format.remove_srgb_suffix() {
|
||||
// A 16-bit frame, read back through an 8-bit copy (`take_readback`).
|
||||
config.format = self.frame_format;
|
||||
config.view_formats = vec![self.frame_format];
|
||||
config.usage |= wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
}
|
||||
// Out of video memory (an invalid texture's view is a validation error):
|
||||
// tried again at half the size next frame; the window keeps its last one.
|
||||
let checks = GpuChecks::push(&self.device);
|
||||
@@ -6523,10 +6569,11 @@ impl Renderer {
|
||||
laps.lap("r_prepare");
|
||||
// Frame generation: its present thread acquires and presents, this frame draws
|
||||
// into frames of its own. Not over menus that blur the frame, a shader pack's
|
||||
// own post passes, HDR or the title screen.
|
||||
// own post passes, HDR (or the 16-bit frame it was started with) or the title screen.
|
||||
let fg = self.frame_gen.on
|
||||
&& !self.frame_gen.paused
|
||||
&& self.hdr.is_none()
|
||||
&& self.frame_format == self.config.format.remove_srgb_suffix()
|
||||
&& !frame.title
|
||||
&& frame.menu_blur.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -6602,11 +6649,16 @@ impl Renderer {
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
_ if let Some((real, _)) = &fg_frames => real.clone(),
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL), and a 16-bit frame
|
||||
// with HDR off, draw into a texture of their own, then onto the swapchain.
|
||||
(Some(f), None) => self
|
||||
.readback
|
||||
.target(&self.device, &f.texture, self.screenshot.is_some())
|
||||
.target(
|
||||
&self.device,
|
||||
&f.texture,
|
||||
self.frame_format,
|
||||
self.screenshot.is_some(),
|
||||
)
|
||||
.unwrap_or_else(|| f.texture.clone()),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
@@ -8168,7 +8220,7 @@ impl Renderer {
|
||||
self.drawn_sections += gpu.records.live();
|
||||
}
|
||||
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.config.format.remove_srgb_suffix()),
|
||||
format: Some(self.frame_format),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
@@ -8274,7 +8326,7 @@ impl Renderer {
|
||||
let (device, layout, sprites) =
|
||||
(&self.device, &self.terrain_layout, self.terrain_sprites);
|
||||
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let format = self.frame_format;
|
||||
let flat = self
|
||||
.flat
|
||||
.as_ref()
|
||||
@@ -8451,7 +8503,7 @@ impl Renderer {
|
||||
&& let Some(sky) = &native.sky
|
||||
{
|
||||
if let Some(t) = &mut native.timing {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let format = self.frame_format;
|
||||
t.mark(&self.device, &mut encoder, (world_view, format));
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
@@ -8514,7 +8566,7 @@ impl Renderer {
|
||||
size,
|
||||
color: target(
|
||||
"Water mirror",
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
self.frame_format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
),
|
||||
@@ -9737,7 +9789,7 @@ impl Renderer {
|
||||
encoder: &mut encoder,
|
||||
color: &target_texture,
|
||||
color_view: &view_target,
|
||||
color_format: self.config.format.remove_srgb_suffix(),
|
||||
color_format: self.frame_format,
|
||||
size,
|
||||
depth,
|
||||
depth_size,
|
||||
@@ -9777,7 +9829,7 @@ impl Renderer {
|
||||
timer.end(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
(&view_target, self.frame_format),
|
||||
);
|
||||
}
|
||||
laps.lap("r_passes");
|
||||
@@ -10075,7 +10127,7 @@ impl Renderer {
|
||||
encoder: &mut *encoder,
|
||||
color: target_texture,
|
||||
color_view: view_target,
|
||||
color_format: self.config.format.remove_srgb_suffix(),
|
||||
color_format: self.frame_format,
|
||||
size: [self.config.width, self.config.height],
|
||||
hand: if overlay.hand_drawn { frame.hand } else { &[] },
|
||||
view_proj: overlay.hand_view_proj,
|
||||
@@ -10245,7 +10297,7 @@ impl Renderer {
|
||||
timing.as_mut(),
|
||||
);
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let format = self.frame_format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
@@ -10396,6 +10448,15 @@ impl Renderer {
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
// The 16-bit frame is read through an 8-bit copy in the swapchain's format.
|
||||
let copy = (texture.format() != self.config.format.remove_srgb_suffix()
|
||||
&& texture.format() == self.frame_format)
|
||||
.then(|| {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.readback
|
||||
.unorm_copy(&self.device, encoder, texture, format)
|
||||
});
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
@@ -11026,6 +11087,72 @@ mod tests {
|
||||
];
|
||||
assert_eq!(surface_format(&without_bgra, Rgba8UnormSrgb), Rgba8Unorm);
|
||||
}
|
||||
/// The 16-bit frame only with HDR on, Smooth Gradients on, an HDR surface and a device
|
||||
/// that draws it; in every other case the swapchain's 8-bit format, as before it existed.
|
||||
#[test]
|
||||
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
|
||||
use super::frame_format;
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
|
||||
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
|
||||
for (wide_frame, hdr_offered, draws) in [
|
||||
(false, true, true),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
(false, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
|
||||
Bgra8Unorm
|
||||
);
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
|
||||
Rgba8Unorm
|
||||
);
|
||||
}
|
||||
}
|
||||
/// What `draws_wide_frame` needs of a device: both features, and the usages and flags
|
||||
/// every pass takes of the frame. Missing any one of them keeps the 8-bit frame.
|
||||
#[test]
|
||||
fn the_16_bit_frame_needs_every_usage_and_flag_the_passes_take() {
|
||||
use super::draws_wide_frame;
|
||||
use wgpu::{Features as F, TextureFormatFeatureFlags as Fl, TextureUsages as U};
|
||||
let features = F::TEXTURE_FORMAT_16BIT_NORM | F::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
|
||||
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING | U::COPY_SRC | U::COPY_DST;
|
||||
let flags = Fl::BLENDABLE | Fl::FILTERABLE;
|
||||
let format = |allowed_usages, flags| wgpu::TextureFormatFeatures {
|
||||
allowed_usages,
|
||||
flags,
|
||||
};
|
||||
// A desktop GPU: all of it, and more.
|
||||
assert!(draws_wide_frame(
|
||||
features | F::FLOAT32_FILTERABLE,
|
||||
format(usages | U::STORAGE_BINDING, flags | Fl::MULTISAMPLE_X4),
|
||||
));
|
||||
assert!(!draws_wide_frame(
|
||||
F::TEXTURE_FORMAT_16BIT_NORM,
|
||||
format(usages, flags)
|
||||
));
|
||||
assert!(!draws_wide_frame(
|
||||
F::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
format(usages, flags)
|
||||
));
|
||||
// A downlevel device reports no usages at all for a format it lacks.
|
||||
assert!(!draws_wide_frame(
|
||||
F::empty(),
|
||||
format(U::empty(), Fl::empty())
|
||||
));
|
||||
for usage in [
|
||||
U::RENDER_ATTACHMENT,
|
||||
U::TEXTURE_BINDING,
|
||||
U::COPY_SRC,
|
||||
U::COPY_DST,
|
||||
] {
|
||||
assert!(!draws_wide_frame(features, format(usages - usage, flags)));
|
||||
}
|
||||
for flag in [Fl::BLENDABLE, Fl::FILTERABLE] {
|
||||
assert!(!draws_wide_frame(features, format(usages, flags - flag)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn special_material_batches_preserve_quad_order_and_offsets() {
|
||||
let quad = |layer, id| {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
|
||||
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
|
||||
//! white, expanding the world's highlights towards their peak brightness. The
|
||||
//! world is copied aside before the hand and HUD so only it is expanded.
|
||||
//! without it, in vanilla's gamma space, into a 16-bit target when HDR output
|
||||
//! was on when the renderer was made, else an 8-bit one
|
||||
//! (`Renderer::frame_format`), and one pass (`hdr.wgsl`) writes it to an HDR10
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -301,7 +303,7 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8-bit frame every pass draws into this frame, sized like the window.
|
||||
/// 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) {
|
||||
let texture = |label, usage| {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle. A 16-bit frame
|
||||
//! (`Renderer::frame_format`) with HDR output off goes the same way every frame.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
@@ -15,18 +16,21 @@ pub struct Readback {
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
/// `surface`, in the frame's `format`: `None` unless that differs from the
|
||||
/// swapchain's, or the frame is read back (`screenshot`) and the image
|
||||
/// cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
format: wgpu::TextureFormat,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
if format == surface.format().remove_srgb_suffix()
|
||||
&& (!screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
@@ -60,22 +64,57 @@ impl Readback {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
self.blit(device, encoder, &texture, &target, format);
|
||||
}
|
||||
|
||||
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
|
||||
/// as an 8-bit frame holds it, for a screenshot to read back.
|
||||
pub fn unorm_copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &wgpu::Texture,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::Texture {
|
||||
let copy = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot copy"),
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let target = copy.create_view(&Default::default());
|
||||
self.blit(device, encoder, frame, &target, format);
|
||||
copy
|
||||
}
|
||||
|
||||
fn blit(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::TextureView,
|
||||
format: wgpu::TextureFormat,
|
||||
) {
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let source = source.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
blitter.copy(device, encoder, &source, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +123,8 @@ impl Readback {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const F: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
@@ -101,7 +142,7 @@ mod tests {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
format: F,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
@@ -156,7 +197,7 @@ mod tests {
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, F);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
@@ -173,7 +214,7 @@ mod tests {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.target(&device, &surface, F, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
@@ -205,9 +246,70 @@ mod tests {
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
assert!(readback.target(&device, &readable, F, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.target(&device, &surface, F, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
|
||||
/// A 16-bit frame with HDR output off draws into a 16-bit texture of its own, and both
|
||||
/// the swapchain and a screenshot's 8-bit copy get it clamped as an 8-bit frame holds it.
|
||||
fn reaches_an_8_bit_swapchain(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
color: wgpu::Color,
|
||||
expected: [u8; 4],
|
||||
) {
|
||||
let surface = texture(
|
||||
device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let mut readback = Readback::default();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback.target(device, &surface, format, false).unwrap();
|
||||
assert_eq!(frame.format(), format);
|
||||
clear(&mut encoder, &frame, color);
|
||||
let copy = readback.unorm_copy(device, &mut encoder, &frame, F);
|
||||
let screenshot = crate::gfx::copy_frame(device, &mut encoder, ©, [70, 3]);
|
||||
readback.present(device, &mut encoder, &surface);
|
||||
let presented = crate::gfx::copy_frame(device, &mut encoder, &surface, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(device, &screenshot), expected);
|
||||
assert_eq!(last_pixel(device, &presented), expected);
|
||||
}
|
||||
|
||||
/// `Rgba16Unorm`, the frame HDR output draws into where the device can
|
||||
/// (`Renderer::frame_format`).
|
||||
#[test]
|
||||
fn a_16_bit_unorm_frame_reaches_an_8_bit_swapchain() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let wide = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
|
||||
if !adapter.features().contains(wide) {
|
||||
return;
|
||||
}
|
||||
let descriptor = wgpu::DeviceDescriptor {
|
||||
required_features: wide,
|
||||
..Default::default()
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&descriptor)) else {
|
||||
return;
|
||||
};
|
||||
let color = wgpu::Color {
|
||||
r: 1.0,
|
||||
g: 0.2,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
};
|
||||
let unorm = wgpu::TextureFormat::Rgba16Unorm;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
|
||||
}
|
||||
}
|
||||
@@ -521,6 +521,17 @@ pub fn translucent_batching_offered() -> bool {
|
||||
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static SMOOTH_GRADIENTS_OFFERED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the device can draw, blend and filter the 16-bit frame (`Graphics::hdr_smooth_gradients`):
|
||||
/// set when the renderer starts. Where it can't, the settings grey its switch out.
|
||||
pub fn offer_smooth_gradients(on: bool) {
|
||||
SMOOTH_GRADIENTS_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn smooth_gradients_offered() -> bool {
|
||||
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -684,6 +695,11 @@ pub struct Graphics {
|
||||
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
|
||||
/// off by default and had no switch, stays ignored.
|
||||
pub translucent_batching: bool,
|
||||
/// Smooth Gradients (HDR page, on by default): with HDR output on, the frame is drawn in
|
||||
/// 16 bits, so dark gradients don't band (`Renderer::frame_format`). Read when a renderer
|
||||
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
|
||||
/// (`smooth_gradients_offered`).
|
||||
pub hdr_smooth_gradients: bool,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
/// ignored.
|
||||
@@ -736,6 +752,7 @@ impl Default for Graphics {
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
translucent_batching: true,
|
||||
hdr_smooth_gradients: true,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -867,6 +884,7 @@ impl Graphics {
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
|
||||
"hdrSmoothGradients" => g.hdr_smooth_gradients = flag(g.hdr_smooth_gradients),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
@@ -883,7 +901,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -934,6 +952,7 @@ impl Graphics {
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
bool_text(self.hdr_smooth_gradients),
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
@@ -949,6 +968,11 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
|
||||
/// HDR on, with Smooth Gradients.
|
||||
pub fn wide_frame(&self) -> bool {
|
||||
self.hdr && self.hdr_smooth_gradients
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
@@ -1105,6 +1129,7 @@ mod tests {
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
translucent_batching: false,
|
||||
hdr_smooth_gradients: false,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
@@ -1146,6 +1171,10 @@ mod tests {
|
||||
// off by default, not a choice.
|
||||
assert!(Graphics::default().translucent_batching);
|
||||
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
|
||||
// Smooth Gradients is on, and draws a 16-bit frame only with HDR on.
|
||||
assert!(Graphics::default().hdr_smooth_gradients && !Graphics::default().wide_frame());
|
||||
assert!(Graphics::parse("hdr:true\n").wide_frame());
|
||||
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
|
||||
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
|
||||
@@ -104,6 +104,7 @@ pub enum H {
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
TranslucentBatching,
|
||||
HdrSmoothGradients,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -496,6 +497,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrSmoothGradients,
|
||||
Low,
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
|
||||
),
|
||||
h(
|
||||
H::HdrCalibrate,
|
||||
None,
|
||||
@@ -824,12 +830,16 @@ impl H {
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
|
||||
// while the saved value waits for a device that can.
|
||||
// These two are greyed out where the device can't (`Item::blocked`): Off, as
|
||||
// they are there, while the saved value waits for a device that can.
|
||||
H::TranslucentBatching => labelled(
|
||||
"Translucent Batching",
|
||||
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
|
||||
),
|
||||
H::HdrSmoothGradients => labelled(
|
||||
"Smooth Gradients",
|
||||
on_off(g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()),
|
||||
),
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
@@ -942,6 +952,7 @@ impl H {
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
|
||||
H::HdrSmoothGradients => g.hdr_smooth_gradients = !g.hdr_smooth_gradients,
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
Pacing::Efficient => Pacing::Precise,
|
||||
@@ -973,7 +984,8 @@ impl Item {
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on; Translucent Batching switches only where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -985,16 +997,23 @@ impl Item {
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrSmoothGradients,
|
||||
) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(H::HdrSmoothGradients)
|
||||
if !crate::graphics::smooth_gradients_offered() =>
|
||||
{
|
||||
Some("Your GPU or graphics API doesn't support it.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrSmoothGradients,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
@@ -1032,6 +1051,9 @@ impl Item {
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
|
||||
Item::Horizon(H::HdrSmoothGradients) => {
|
||||
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
|
||||
}
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
|
||||
@@ -1098,6 +1120,7 @@ impl Item {
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
| H::TranslucentBatching
|
||||
| H::HdrSmoothGradients
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
| Opt::Fullscreen
|
||||
@@ -2613,6 +2636,31 @@ mod tests {
|
||||
assert!(!ui.release());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = true;
|
||||
let item = Item::Horizon(H::HdrSmoothGradients);
|
||||
// Where the device can't, the saved On reads Off.
|
||||
crate::graphics::offer_smooth_gradients(false);
|
||||
assert_eq!(
|
||||
item.blocked(&s),
|
||||
Some("Your GPU or graphics API doesn't support it.")
|
||||
);
|
||||
assert_eq!(item.label(&s).plain(), "Smooth Gradients: Off");
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert!(s.horizon.hdr_smooth_gradients);
|
||||
// Where it can, it waits only for HDR, like the rest of the page.
|
||||
crate::graphics::offer_smooth_gradients(true);
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
s.horizon.hdr = false;
|
||||
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -58,6 +58,14 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
|
||||
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
|
||||
// would read differently (an alpha of 0.001 would count as the hand); its colour
|
||||
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
|
||||
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
const BT709_TO_BT2020 = mat3x3<f32>(
|
||||
vec3(0.6274, 0.0691, 0.0164),
|
||||
@@ -152,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
@@ -162,7 +170,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
|
||||
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _span = guard(name);
|
||||
f()
|
||||
}
|
||||
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
|
||||
/// returns). Nothing is read while spans are off.
|
||||
#[must_use = "the span ends when the guard is dropped"]
|
||||
pub struct Guard {
|
||||
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
}
|
||||
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
|
||||
#[inline]
|
||||
pub fn guard(name: &'static str) -> Guard {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
if !recording_here() {
|
||||
return f();
|
||||
return Guard { open: None, _heap };
|
||||
}
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
Guard {
|
||||
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
|
||||
_heap,
|
||||
}
|
||||
}
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let Some((name, started, cpu, depth)) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
@@ -551,6 +572,42 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A guarded span ends on every way out of its step, an early return's too, and holds the
|
||||
/// spans that ran inside it until then; the next span outside it is back at its depth.
|
||||
#[test]
|
||||
fn a_guarded_span_ends_where_its_step_returns() {
|
||||
fn step(early: bool) -> u32 {
|
||||
let _span = super::guard("use_block");
|
||||
super::time("open_loot", || ());
|
||||
if early {
|
||||
return 1;
|
||||
}
|
||||
super::time("open_maps", || ());
|
||||
2
|
||||
}
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
assert_eq!(step(true), 1);
|
||||
assert_eq!(step(false), 2);
|
||||
super::time("use_item", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("open_loot", 1),
|
||||
("use_block", 0),
|
||||
("open_loot", 1),
|
||||
("open_maps", 1),
|
||||
("use_block", 0),
|
||||
("use_item", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Splitscreen's profile page (Horizon's Splitscreen setting): a joining player picks a profile,
|
||||
//! or makes one, before entering P1's world, and a player in it switches to another from its
|
||||
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
|
||||
//! (kept in the game folder), with its own options. The
|
||||
//! (instance-local offline names and the launcher's Microsoft accounts), with its own options. The
|
||||
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
|
||||
//! can't play (`refused`). Built from vanilla's widgets.
|
||||
use crate::{
|
||||
@@ -56,10 +56,12 @@ pub fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
|
||||
pub struct SplitProfiles {
|
||||
/// The game filled the page since it opened.
|
||||
pub filled: bool,
|
||||
/// The profiles; those playing now (none can be picked twice); and the one this player plays
|
||||
/// as (switching; none while joining).
|
||||
/// The profiles; those playing now (none can be picked twice); the signed-in Microsoft
|
||||
/// accounts among them; and the one this player plays as (switching; none while joining).
|
||||
pub names: Vec<String>,
|
||||
pub taken: Vec<String>,
|
||||
pub accounts: Vec<String>,
|
||||
pub offline_accounts: Vec<String>,
|
||||
pub current: Option<String>,
|
||||
/// Why the last pick can't play (an online server, an expired sign-in), until the next.
|
||||
pub refused: Option<String>,
|
||||
@@ -127,7 +129,7 @@ impl SplitProfiles {
|
||||
}
|
||||
|
||||
fn editable(&self, name: &str) -> bool {
|
||||
!self.taken.contains(&name.to_owned())
|
||||
!self.accounts.contains(&name.to_owned()) && !self.taken.contains(&name.to_owned())
|
||||
}
|
||||
|
||||
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
|
||||
@@ -179,24 +181,34 @@ impl SplitProfiles {
|
||||
.skip(self.page() * SHOWN)
|
||||
.take(SHOWN)
|
||||
{
|
||||
let microsoft = self.accounts.contains(name);
|
||||
let mut label = name.clone();
|
||||
if microsoft {
|
||||
label.push_str(&if self.offline_accounts.contains(name) {
|
||||
tr("horizon.splitscreen.profiles.offline", " (Offline)")
|
||||
} else {
|
||||
tr("horizon.splitscreen.profiles.microsoft", " (Microsoft)")
|
||||
});
|
||||
}
|
||||
if self.current.as_ref() == Some(name) {
|
||||
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
|
||||
}
|
||||
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
|
||||
buttons.push((
|
||||
[x, y, 176.0, 20.0],
|
||||
[x, y, if microsoft { 200.0 } else { 176.0 }, 20.0],
|
||||
label,
|
||||
!self.taken.contains(name),
|
||||
Target::Profile(i),
|
||||
));
|
||||
buttons.push((
|
||||
[x + 180.0, y, 20.0, 20.0],
|
||||
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
|
||||
.replacen("%s", name, 1),
|
||||
self.editable(name),
|
||||
Target::Remove(i),
|
||||
));
|
||||
if !microsoft {
|
||||
buttons.push((
|
||||
[x + 180.0, y, 20.0, 20.0],
|
||||
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
|
||||
.replacen("%s", name, 1),
|
||||
self.editable(name),
|
||||
Target::Remove(i),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut y = 52.0 + self.names.len().min(SHOWN) as f32 * 24.0;
|
||||
if self.names.len() > SHOWN {
|
||||
@@ -322,6 +334,19 @@ impl SplitProfiles {
|
||||
for ([x, y, bw, bh], label, enabled, target) in &buttons {
|
||||
let hover = ui::hovered(cursor, *x, *y, *bw, *bh);
|
||||
let remove = matches!(target, Target::Remove(_));
|
||||
if hover && let Target::Profile(i) = target {
|
||||
let name = &self.names[*i];
|
||||
if self.accounts.contains(name) {
|
||||
tip = Some(if self.offline_accounts.contains(name) {
|
||||
tr(
|
||||
"horizon.splitscreen.profiles.sign_in",
|
||||
"Sign in again in the launcher",
|
||||
)
|
||||
} else {
|
||||
tr("horizon.splitscreen.profiles.online", "Microsoft online")
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut text = Text::literal(if remove { "" } else { label });
|
||||
if matches!(target, Target::Forget) {
|
||||
text = text.with_color(0xFF5555);
|
||||
@@ -584,14 +609,31 @@ mod tests {
|
||||
names: (0..11).map(|i| format!("Player{i}")).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
p.accounts = vec!["Player10".into()];
|
||||
press(&mut p, &atlas, "Next");
|
||||
press(&mut p, &atlas, "Next");
|
||||
press(&mut p, &atlas, "Player10");
|
||||
press(&mut p, &atlas, "Player10 (Microsoft)");
|
||||
assert_eq!(p.take_choice(), Some(Some("Player10".into())));
|
||||
press(&mut p, &atlas, "Previous");
|
||||
assert!(p.buttons(427.0, 240.0).iter().any(|b| b.1 == "Player4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_accounts_are_marked_and_cannot_be_forgotten() {
|
||||
let mut p = SplitProfiles {
|
||||
names: vec!["Cal".into()],
|
||||
accounts: vec!["Cal".into()],
|
||||
offline_accounts: vec!["Cal".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!p.editable("Cal"));
|
||||
let buttons = p.buttons(427.0, 240.0);
|
||||
assert!(buttons.iter().any(|b| b.1 == "Cal (Offline)" && b.2));
|
||||
assert!(!buttons.iter().any(|b| b.1 == "Forget Cal"));
|
||||
press(&mut p, &atlas(), "Cal (Offline)");
|
||||
assert_eq!(p.take_choice(), Some(Some("Cal".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_names_are_player_names() {
|
||||
assert!(valid_name("Steve_2"));
|
||||
|
||||
@@ -62,6 +62,15 @@ impl SpriteAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct StillKey {
|
||||
frame: u64,
|
||||
pbr: u64,
|
||||
side: u32,
|
||||
settings: [u32; 2],
|
||||
flags: [bool; 4],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Sprites {
|
||||
/// Edge length of every array layer and the layers' current frames, set
|
||||
@@ -90,7 +99,7 @@ pub struct Sprites {
|
||||
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
|
||||
/// textures under their own names, and one layer serves them all (the GPU's array may
|
||||
/// hold as few as 2048).
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
still: std::collections::HashMap<StillKey, u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
@@ -647,18 +656,20 @@ impl Sprites {
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut frame = std::collections::hash_map::DefaultHasher::new();
|
||||
frames[0].hash(&mut frame);
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
(
|
||||
frames[0].clone(),
|
||||
maps.finish(),
|
||||
StillKey {
|
||||
frame: frame.finish(),
|
||||
pbr: maps.finish(),
|
||||
side,
|
||||
[
|
||||
settings: [
|
||||
settings.strategy as u32,
|
||||
settings.alpha_cutoff_bias.to_bits(),
|
||||
],
|
||||
[settings.has_transparent, settings.item, opaque, translucent],
|
||||
)
|
||||
flags: [settings.has_transparent, settings.item, opaque, translucent],
|
||||
}
|
||||
});
|
||||
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
|
||||
self.layers.insert(name.to_owned(), same);
|
||||
|
||||
@@ -114,6 +114,8 @@
|
||||
"Peak Brightness": "Peak Brightness",
|
||||
"Black Level": "Black Level",
|
||||
"UI Brightness": "UI Brightness",
|
||||
"Smooth Gradients": "Smooth Gradients",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
|
||||
"Battery Mode": "Battery Mode",
|
||||
"Graphics API": "Graphics API",
|
||||
"Translucent Batching": "Translucent Batching",
|
||||
@@ -483,6 +485,12 @@
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Offline)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Sign in again in the launcher",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft online",
|
||||
"horizon.splitscreen.account_unavailable": "%s is offline. Sign in again in the launcher, then restart the game.",
|
||||
"horizon.splitscreen.account_expired": "%s’s Microsoft sign-in has expired. Sign in again in the launcher, then restart the game.",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
|
||||
@@ -370,6 +370,12 @@
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"horizon.splitscreen.profiles.offline": " (Hors ligne)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Reconnectez-vous dans le lanceur",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft en ligne",
|
||||
"horizon.splitscreen.account_unavailable": "%s est hors ligne. Reconnectez-vous dans le lanceur, puis redémarrez le jeu.",
|
||||
"horizon.splitscreen.account_expired": "La connexion Microsoft de %s a expiré. Reconnectez-vous dans le lanceur, puis redémarrez le jeu.",
|
||||
"Particle Culling": "Occlusion des particules",
|
||||
"Draws every particle.": "Dessine toutes les particules.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignore les particules hors écran, trop petites ou masquées par le terrain.",
|
||||
|
||||
@@ -1182,6 +1182,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
|
||||
game_time: world.game_time,
|
||||
|
||||
@@ -148,6 +148,7 @@ mod input_trace;
|
||||
mod integrated;
|
||||
mod interaction_shapes;
|
||||
mod interactions;
|
||||
mod item_areas;
|
||||
mod item_draw;
|
||||
mod item_particles;
|
||||
mod item_use;
|
||||
@@ -572,6 +573,7 @@ impl Options {
|
||||
Ok(None) => {}
|
||||
Err(e) => eprintln!("Horizon: not signed in: {e}"),
|
||||
}
|
||||
split::launcher::read_now();
|
||||
if let Some(dir) = o.game_dir.take() {
|
||||
// Paths given on the command line stay relative to where the game was started.
|
||||
o.resources = std::path::absolute(&o.resources)?;
|
||||
@@ -2447,6 +2449,9 @@ struct Game<'a> {
|
||||
view_at: [u32; 2],
|
||||
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
|
||||
split_hud: split::Hud,
|
||||
/// Splitscreen: this player's own Microsoft account session (one of the launcher's other
|
||||
/// accounts), in place of the process's (P1's) for its connections and skin.
|
||||
split_auth: Option<std::sync::Arc<horizon_client::auth::AuthSession>>,
|
||||
focused: bool,
|
||||
/// Whether the window currently accepts input method events.
|
||||
ime_allowed: bool,
|
||||
@@ -5034,7 +5039,7 @@ impl Game<'_> {
|
||||
// The last answer, asked from Y `from` at a world revision:
|
||||
// the next ticking section and its mask. It stands for a
|
||||
// later Y up to that section's until a block changes.
|
||||
let mut last: Option<(u64, i32, Option<(i32, Arc<_>)>)> = None;
|
||||
let mut last: Option<(u64, i32, Option<(i32, std::rc::Rc<_>)>)> = None;
|
||||
let pos = horizon_core::position::ChunkPos::new(x, z);
|
||||
b.tick_chunk_random_masked(level, ticks, pos, |level, from| {
|
||||
let revision = level.world.revision;
|
||||
@@ -5715,13 +5720,15 @@ impl Game<'_> {
|
||||
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
|
||||
} else if let Some(entity) = BlockEntity::for_block(&name) {
|
||||
self.world.block_entities.get_or_insert_with(key, || entity);
|
||||
self.unpack_opened_loot(key, &name);
|
||||
horizon_client::spans::time("open_loot", || self.unpack_opened_loot(key, &name));
|
||||
let centre = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
self.resolve_container_maps(key, centre);
|
||||
horizon_client::spans::time("open_maps", || {
|
||||
self.resolve_container_maps(key, centre);
|
||||
});
|
||||
self.container = Some(key);
|
||||
let first = self.container_opener_event(key, true);
|
||||
self.post_block_game_event(
|
||||
@@ -6369,9 +6376,13 @@ impl Game<'_> {
|
||||
let mut anchor_passed = false;
|
||||
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
|
||||
// canceled click does nothing (a guest's block is sent back).
|
||||
// A slow tick's line names the phase of a use that took it (#8669): the mods' hook,
|
||||
// the block's own use, then the item's.
|
||||
if let Some(hit) = block_hit {
|
||||
let mut canceled = false;
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
horizon_client::spans::time("use_mods", || {
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
});
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
self.use_failed = true;
|
||||
@@ -6379,6 +6390,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// MultiPlayerGameMode.useItemOn: blocks react first.
|
||||
let block_phase = horizon_client::spans::guard("use_block");
|
||||
if !(sneaking && hands_full)
|
||||
&& let Some(hit) = block_hit
|
||||
{
|
||||
@@ -6561,6 +6573,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(block_phase);
|
||||
let _item_phase = horizon_client::spans::guard("use_item");
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -14217,6 +14231,7 @@ impl Game<'_> {
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)?;
|
||||
renderer.screenshot = screenshot;
|
||||
@@ -14253,6 +14268,7 @@ impl Game<'_> {
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
}
|
||||
@@ -18820,6 +18836,7 @@ impl<'a> Game<'a> {
|
||||
split_window: false,
|
||||
view_at: [0, 0],
|
||||
split_hud: Default::default(),
|
||||
split_auth: None,
|
||||
focused: true,
|
||||
ime_allowed: false,
|
||||
ime_area: None,
|
||||
@@ -20461,6 +20478,9 @@ fn build_renderer(
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
@@ -20498,6 +20518,7 @@ fn build_renderer(
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
@@ -20565,6 +20586,8 @@ fn loading_renderer(
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
@@ -21146,6 +21169,7 @@ impl Loading {
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
})?;
|
||||
@@ -21408,7 +21432,11 @@ pub fn run_with() -> Result<()> {
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
if options.execution_role.is_client() {
|
||||
// A dedicated server too: vanilla's "Server Shutdown Thread" (`DedicatedServer.initServer`'s
|
||||
// shutdown hook) halts the server and saves its players and levels on SIGTERM, SIGINT or SIGHUP.
|
||||
if options.execution_role.is_client()
|
||||
|| options.execution_role == execution_role::ExecutionRole::DedicatedServer
|
||||
{
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -821,6 +821,7 @@ impl Game<'_> {
|
||||
leash_boats: &self.leash_boats,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
// day.json: false from tick 12542, true from 23460 (a step track).
|
||||
monsters_burn: self.dim == Dim::Overworld
|
||||
@@ -1023,14 +1024,19 @@ impl Game<'_> {
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
|
||||
let mut villager_slots = Vec::new();
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == horizon_client::mobs::Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
for i in 0..mobs.len() {
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
..base.clone()
|
||||
};
|
||||
let m = &mut mobs[i];
|
||||
@@ -7777,6 +7783,7 @@ mod pearl_tests {
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
|
||||
@@ -1185,7 +1185,9 @@ impl Game<'_> {
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
let mut braked = Instant::now();
|
||||
let mut overload_warned: Option<Instant> = None;
|
||||
while !state.lists.lock().unwrap().stop {
|
||||
// `/stop`, or a stop signal (the shutdown hook's `halt`): either way the players and levels are
|
||||
// saved below.
|
||||
while !state.lists.lock().unwrap().stop && horizon_threads::stop_requests() == 0 {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
|
||||
@@ -17,14 +17,18 @@ impl Game<'_> {
|
||||
/// `Fox.ALLOWED_ITEMS` and the looting block of `Mob.aiStep` for foxes, once a tick.
|
||||
pub(super) fn tick_foxes(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Fox || !m.alive() || m.removed {
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
|
||||
if griefing && m.can_pick_up_loot() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
if griefing
|
||||
&& m.can_pick_up_loot()
|
||||
&& near.may_meet(&self.dropped, self.next_entity, reach)
|
||||
{
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
@@ -49,11 +53,14 @@ impl Game<'_> {
|
||||
// `FoxSearchForItemsGoal`: the first allowed item within 8 blocks.
|
||||
let m = &self.mobs[i];
|
||||
let area = m.bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
|
||||
let at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| e.pickup_delay == 0 && e.health > 0 && area.intersects(e.bounds()))
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
let at = if near.may_meet(&self.dropped, self.next_entity, area) {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|e| e.pickup_delay == 0 && e.health > 0 && area.intersects(e.bounds()))
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fox::set_item_at(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +79,7 @@ impl Game<'_> {
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Dolphin || !m.alive() || m.removed {
|
||||
@@ -79,8 +87,11 @@ impl Game<'_> {
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
|
||||
// takes the whole stack when the mouth is empty (`Mob.canHoldItem` is true).
|
||||
if griefing && m.can_pick_up_loot() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
if griefing
|
||||
&& m.can_pick_up_loot()
|
||||
&& near.may_meet(&self.dropped, self.next_entity, reach)
|
||||
{
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
@@ -103,16 +114,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let area = self.mobs[i].bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
|
||||
let at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.pickup_delay == 0
|
||||
&& e.health > 0
|
||||
&& area.intersects(e.bounds())
|
||||
&& in_water(self, e)
|
||||
})
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
let at = if near.may_meet(&self.dropped, self.next_entity, area) {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.pickup_delay == 0
|
||||
&& e.health > 0
|
||||
&& area.intersects(e.bounds())
|
||||
&& in_water(self, e)
|
||||
})
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
dolphin::set_item_at(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Where the level's dropped items lie, in 8-block cells, for the passes that
|
||||
//! test each mob's reach against every item (`Mob.aiStep`'s looting, the
|
||||
//! foxes' and dolphins' item searches). A reach that meets no cell holding an
|
||||
//! item meets no item, so its scan, which would find nothing, is skipped; a
|
||||
//! reach near an item scans as before, in the same order, with the same tests.
|
||||
use horizon_client::{entities::ItemEntity, fast_hash::FxHashSet, join_seq::JoinSeq};
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
/// Cells of 8 blocks on each axis.
|
||||
const SHIFT: i32 = 3;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ItemAreas {
|
||||
cells: FxHashSet<[i32; 3]>,
|
||||
/// What the cells were built from: the list's length, the last entity id
|
||||
/// the game handed out and the next join number. An item spawned or
|
||||
/// removed since changes one of them, and the cells are built again.
|
||||
built: Option<(usize, u32, u64)>,
|
||||
}
|
||||
|
||||
impl ItemAreas {
|
||||
/// Whether an item of `items` may meet `area`: false only when none can.
|
||||
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
|
||||
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
|
||||
if self.built != Some(key) {
|
||||
self.cells.clear();
|
||||
self.cells.extend(items.iter().map(|e| {
|
||||
let p = e.position;
|
||||
[cell(p.x), cell(p.y), cell(p.z)]
|
||||
}));
|
||||
self.built = Some(key);
|
||||
}
|
||||
if self.cells.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (area.min_position(), area.max_position());
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
return true;
|
||||
}
|
||||
// An item's box spans its width sideways and upwards from its
|
||||
// position; a margin of a whole width keeps rounding on the safe side.
|
||||
let m = ItemEntity::WIDTH;
|
||||
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
|
||||
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
|
||||
let span = (0..3)
|
||||
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
|
||||
.product::<i64>();
|
||||
if span > self.cells.len() as i64 {
|
||||
return self
|
||||
.cells
|
||||
.iter()
|
||||
.any(|c| (0..3).all(|a| (from[a]..=to[a]).contains(&c[a])));
|
||||
}
|
||||
(from[2]..=to[2]).any(|z| {
|
||||
(from[1]..=to[1]).any(|y| (from[0]..=to[0]).any(|x| self.cells.contains(&[x, y, z])))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(v: f64) -> i32 {
|
||||
(v.floor() as i32) >> SHIFT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let p = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
join: Default::default(),
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
id: 1,
|
||||
stack: horizon_client::items::ItemStack::new("minecraft:egg", 1),
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: p,
|
||||
previous: p,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
no_gravity: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some item's own box meets `area`, as the scans test it.
|
||||
fn scan_meets(items: &[ItemEntity], area: Aabb) -> bool {
|
||||
items.iter().any(|e| area.intersects(e.bounds()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reach_that_meets_an_item_is_never_skipped() {
|
||||
// Items just inside and just outside reach of a box, across cell
|
||||
// edges and on both sides of zero.
|
||||
let mut items = Vec::new();
|
||||
for &x in &[-16.13, -8.0, -7.9, -0.125, 0.0, 7.87, 8.0, 15.99] {
|
||||
for &y in &[-64.0, -1.0, 0.0, 7.75, 64.3] {
|
||||
items.push(item_at(x, y, x * 0.5));
|
||||
}
|
||||
}
|
||||
let mut cells = ItemAreas::default();
|
||||
for &x in &[-17.0, -9.0, -8.3, -1.0, -0.4, 0.2, 7.0, 7.9, 8.2, 15.0] {
|
||||
for &y in &[-65.0, -2.0, -0.3, 6.9, 7.6, 63.0] {
|
||||
for &w in &[0.3, 1.3, 8.3] {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x - w, y, x * 0.5 - w),
|
||||
Vec3::new(x + w, y + 1.95, x * 0.5 + w),
|
||||
);
|
||||
if scan_meets(&items, area) {
|
||||
assert!(cells.may_meet(&items, 1, area), "{area:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reach_far_from_every_item_is_skipped() {
|
||||
let items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
let mut cells = ItemAreas::default();
|
||||
let near = Aabb::new(Vec3::new(98.0, 63.0, 98.0), Vec3::new(101.0, 65.0, 101.0));
|
||||
let far = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(2.6, 65.0, 2.6));
|
||||
assert!(cells.may_meet(&items, 1, near));
|
||||
assert!(!cells.may_meet(&items, 1, far));
|
||||
assert!(!cells.may_meet(&[], 1, near));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_spawned_after_the_cells_were_built_is_seen() {
|
||||
let mut items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
let mut cells = ItemAreas::default();
|
||||
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(2.6, 65.0, 2.6));
|
||||
assert!(!cells.may_meet(&items, 1, area));
|
||||
// Spawned where the first one was taken: the same length, a new id.
|
||||
items[0] = item_at(1.0, 64.0, 1.0);
|
||||
assert!(cells.may_meet(&items, 2, area));
|
||||
}
|
||||
}
|
||||
@@ -1840,7 +1840,8 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
if timed {
|
||||
note_event(&host, previous.take());
|
||||
previous = Some((Instant::now(), event_kind(&event)));
|
||||
let detail = self.event_detail(&host, &event);
|
||||
previous = Some((Instant::now(), event_kind(&event), detail));
|
||||
}
|
||||
match event {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
@@ -3060,22 +3061,73 @@ fn event_kind(event: &HostEvent) -> EventKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes how long the event that started at `started` took (`spans::note`).
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind)>) {
|
||||
let Some((started, (kind, conn, name))) = started else {
|
||||
/// Notes how long the event that started at `started` took (`spans::note`), with what it
|
||||
/// was about when [`Game::event_detail`] said.
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
|
||||
let Some((started, (kind, conn, name), detail)) = started else {
|
||||
return;
|
||||
};
|
||||
horizon_client::spans::note(started.elapsed().as_secs_f32() * 1000.0, || {
|
||||
let who = name
|
||||
.or_else(|| conn.and_then(|c| host.hub.guest(c)).map(|g| g.name.clone()))
|
||||
.or_else(|| conn.map(|c| format!("connection {c}")));
|
||||
match who {
|
||||
let line = match who {
|
||||
Some(who) => format!("{kind} of {who}"),
|
||||
None => kind.to_owned(),
|
||||
};
|
||||
match detail {
|
||||
Some(d) => format!("{line} ({d})"),
|
||||
None => line,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a use packet was about, taken before it ran (the use may consume the item or change
|
||||
/// the block): the item in the hand it names and, on a block, that block, where, and the face.
|
||||
struct UseDetail {
|
||||
item: Option<Arc<str>>,
|
||||
block: Option<(Arc<str>, [i32; 3], i32)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UseDetail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.item.as_deref().unwrap_or("empty hand"))?;
|
||||
if let Some((block, [x, y, z], face)) = &self.block {
|
||||
let face = ["down", "up", "north", "south", "west", "east"]
|
||||
.get(*face as usize)
|
||||
.copied()
|
||||
.unwrap_or("?");
|
||||
write!(f, " on {block} at {x} {y} {z} {face}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// For a slow tick's note (#8669): the item a `UseItemOn` or `UseItem` uses and the block
|
||||
/// it aims at. None for other events.
|
||||
fn event_detail(&self, host: &LanHost, event: &HostEvent) -> Option<UseDetail> {
|
||||
let HostEvent::Packet(conn, packet) = event else {
|
||||
return None;
|
||||
};
|
||||
let (hand, block) = match &**packet {
|
||||
play::Serverbound::UseItemOn(p) => (p.hand, Some((p.pos, p.face))),
|
||||
play::Serverbound::UseItem(p) => (p.hand, None),
|
||||
_ => return None,
|
||||
};
|
||||
let guest = host.hub.guest(*conn)?;
|
||||
let held = if hand == 1 {
|
||||
guest.offhand.as_ref()
|
||||
} else {
|
||||
guest.inventory.get(guest.selected).and_then(Option::as_ref)
|
||||
};
|
||||
Some(UseDetail {
|
||||
item: held.map(|s| Arc::clone(&s.item)),
|
||||
block: block.map(|(pos, face)| (self.block_name(pos), pos, face)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags, mob_freeze_meta};
|
||||
|
||||
@@ -2096,7 +2096,9 @@ impl Game<'_> {
|
||||
let stack = g
|
||||
.with_hand(hand, |g| g.held().cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
horizon_client::spans::time("use_advancements", || {
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
});
|
||||
}
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
|
||||
@@ -535,28 +535,40 @@ impl Game<'_> {
|
||||
/// collision, which sets a TNT minecart off.
|
||||
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
|
||||
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
// Every cart asks about every entity of the level each tick, and the box
|
||||
// turns nearly all of them away: it is tested first, before a mob's
|
||||
// pushable state, and only the boxes kept are collected.
|
||||
let close = |b: &Aabb| b.intersects(near);
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
.filter(|o| o.id != c.id)
|
||||
.map(Minecart::bounds)
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.filter(close)
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.map(super::boats::Boat::bounds)
|
||||
.filter(close),
|
||||
)
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
.filter(|m| !m.removed && m.extra.mount != Some(c.id))
|
||||
.map(|m| (m, m.bounds()))
|
||||
.filter(|(m, b)| {
|
||||
close(b)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
})
|
||||
.map(|m| m.bounds()),
|
||||
.map(|(_, b)| b),
|
||||
)
|
||||
.collect();
|
||||
if self.riding != Some(c.id)
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
&& close(&p.bounds())
|
||||
{
|
||||
boxes.push(p.bounds());
|
||||
}
|
||||
@@ -572,10 +584,10 @@ impl Game<'_> {
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(c.id)
|
||||
})
|
||||
.map(|g| g.bounds()),
|
||||
.map(|g| g.bounds())
|
||||
.filter(close),
|
||||
);
|
||||
}
|
||||
boxes.retain(|b| b.intersects(near));
|
||||
boxes
|
||||
}
|
||||
/// Entity.move for a cart: collisions zero the blocked components, the world border's walls
|
||||
@@ -586,7 +598,14 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let entities = self.cart_entity_collisions(c);
|
||||
// A zero move stays zero whatever surrounds it (`Entity.collide`, and the
|
||||
// step-up needs a blocked horizontal move): a cart standing on its rails,
|
||||
// as most do, gathers no entity boxes.
|
||||
let entities = if delta.length_squared() == 0.0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.cart_entity_collisions(c)
|
||||
};
|
||||
let mut scene = horizon_client::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
@@ -1608,13 +1627,11 @@ impl Game<'_> {
|
||||
let candidates: Vec<u32> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
if m.removed || !m.pushable() || m.extra.mount == Some(id) {
|
||||
// The box first: it turns away nearly every mob of the level.
|
||||
if m.removed || m.extra.mount == Some(id) || !hitbox.intersects(m.bounds()) {
|
||||
return false;
|
||||
}
|
||||
if !hitbox.intersects(m.bounds()) {
|
||||
return false;
|
||||
}
|
||||
pushable_by(None, teams.of(Who::Mob(m.id)), true)
|
||||
m.pushable() && pushable_by(None, teams.of(Who::Mob(m.id)), true)
|
||||
})
|
||||
.map(|j| self.mobs[j].id)
|
||||
.collect();
|
||||
@@ -1729,7 +1746,13 @@ impl Game<'_> {
|
||||
let Some(i) = cart_at(&self.carts, id, i) else {
|
||||
continue;
|
||||
};
|
||||
// The ticked copy takes the loot roll instead of copying it: only
|
||||
// opening the cart, a command or a save reads it, never a tick, and
|
||||
// a mineshaft's chest cart carries its whole roll as NBT. The
|
||||
// stored cart keeps its slots for whatever reads them meanwhile.
|
||||
let loot = self.carts[i].loot.take();
|
||||
let mut c = self.carts[i].clone();
|
||||
c.loot = loot;
|
||||
let alive = self.tick_cart(&mut c, [0.0; 2]);
|
||||
if c.kind == CartKind::Hopper && c.enabled {
|
||||
self.hopper_cart_suck(&mut c);
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Game<'_> {
|
||||
if self.dropped.is_empty() || !self.ui.rules.bool("mob_griefing") {
|
||||
return;
|
||||
}
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.can_pick_up_loot() || !m.alive() || m.removed {
|
||||
@@ -32,6 +33,9 @@ impl Game<'_> {
|
||||
}
|
||||
// `Mob.ITEM_PICKUP_REACH`.
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
if !near.may_meet(&self.dropped, self.next_entity, reach) {
|
||||
continue;
|
||||
}
|
||||
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
|
||||
@@ -311,6 +311,10 @@ pub(super) struct GameLevel<'g, 'a> {
|
||||
/// holds the game); the first ones scan, as the build costs a few scans.
|
||||
pub(super) boxes: std::cell::OnceCell<MobBoxes>,
|
||||
pub(super) box_queries: std::cell::Cell<u32>,
|
||||
/// The entity types some biome of the level gives a spawn cost (the
|
||||
/// nether's few): for any other the cost is none wherever it stands, and
|
||||
/// spawning asks for one for every loaded mob each tick.
|
||||
pub(super) costed: Vec<&'g str>,
|
||||
}
|
||||
const SCANNED_QUERIES: u32 = 8;
|
||||
/// A mob not removed: its index in `Game::mobs`, box, alive, and whether
|
||||
@@ -490,6 +494,9 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
self.zoomed_biome(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
if !self.costed.contains(&entity) {
|
||||
return None;
|
||||
}
|
||||
self.zoomed_biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
@@ -613,6 +620,16 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
|
||||
mobs + pending
|
||||
}
|
||||
}
|
||||
/// Every entity type with a spawn cost in some biome, each once.
|
||||
fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
|
||||
let mut types: Vec<&str> = Vec::new();
|
||||
for t in biomes.list.iter().flat_map(|b| b.spawn_costs.keys()) {
|
||||
if !types.contains(&t.as_str()) {
|
||||
types.push(t);
|
||||
}
|
||||
}
|
||||
types
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
@@ -630,6 +647,7 @@ impl<'a> Game<'a> {
|
||||
pending_ids: Default::default(),
|
||||
boxes: Default::default(),
|
||||
box_queries: Default::default(),
|
||||
costed: spawn_cost_types(world_biomes!(self)),
|
||||
}
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
|
||||
@@ -355,6 +355,7 @@ impl Game<'_> {
|
||||
Some(textures),
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
super::shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -769,15 +769,6 @@ impl Game<'_> {
|
||||
None => horizon_client::health::hurt_sound("hurt"),
|
||||
}
|
||||
}
|
||||
/// Offline local profiles never borrow the account that started P1's game.
|
||||
pub(super) fn auth(&self) -> Option<std::sync::Arc<horizon_client::auth::AuthSession>> {
|
||||
if self.options.split_guest {
|
||||
None
|
||||
} else {
|
||||
horizon_client::auth::current()
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
|
||||
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
|
||||
self.connect_server_with_cookies(address, name, None)
|
||||
@@ -869,7 +860,8 @@ impl Game<'_> {
|
||||
horizon_client::net::shown_address(address),
|
||||
);
|
||||
}
|
||||
// A signed-in account always plays under its own name.
|
||||
// A signed-in account always plays under its own name; a splitscreen player signs in
|
||||
// with its own account when it picked one.
|
||||
let auth = self.auth();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let user = auth.as_ref().map_or_else(
|
||||
@@ -1697,6 +1689,13 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// This player's account: a splitscreen player's own, else the game's (P1's).
|
||||
pub(super) fn auth(&self) -> Option<std::sync::Arc<horizon_client::auth::AuthSession>> {
|
||||
match self.options.split_guest {
|
||||
true => self.split_auth.clone(),
|
||||
false => horizon_client::auth::current(),
|
||||
}
|
||||
}
|
||||
/// Connected to a server in online mode (its Login): each player needs its own account.
|
||||
pub(super) fn online_server(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.online_mode)
|
||||
@@ -5076,15 +5075,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn poll_own_profile(&mut self) {
|
||||
let auth = self.auth();
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match &mut r.own_profile {
|
||||
OwnProfile::Idle => {
|
||||
let Some(auth) = (!self.options.split_guest)
|
||||
.then(horizon_client::auth::current)
|
||||
.flatten()
|
||||
else {
|
||||
let Some(auth) = auth else {
|
||||
r.own_profile = OwnProfile::Done(None);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -154,7 +154,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Only when the server says it is in online mode (handleLogin).
|
||||
if self.remote.as_ref().is_some_and(|r| r.online_mode) {
|
||||
auth::prepare_chat_keys(true);
|
||||
if let Some(account) = self.auth() {
|
||||
auth::prepare_chat_keys_for(account, true);
|
||||
}
|
||||
self.update_chat_session();
|
||||
}
|
||||
}
|
||||
@@ -169,8 +171,10 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.logged_in && r.online_mode)
|
||||
{
|
||||
if self.remote.as_ref().is_some_and(|r| r.secure_chat.signed()) {
|
||||
auth::prepare_chat_keys(false);
|
||||
if self.remote.as_ref().is_some_and(|r| r.secure_chat.signed())
|
||||
&& let Some(account) = self.auth()
|
||||
{
|
||||
auth::prepare_chat_keys_for(account, false);
|
||||
}
|
||||
self.update_chat_session();
|
||||
}
|
||||
@@ -189,7 +193,7 @@ impl Game<'_> {
|
||||
if !r.logged_in || r.profile() != account.uuid {
|
||||
return;
|
||||
}
|
||||
let Some(keys) = auth::chat_keys() else {
|
||||
let Some(keys) = auth::chat_keys_for(account.uuid) else {
|
||||
return;
|
||||
};
|
||||
if r.secure_chat
|
||||
|
||||
@@ -51,6 +51,7 @@ use winit::{
|
||||
|
||||
mod frames;
|
||||
mod hud;
|
||||
pub(super) mod launcher;
|
||||
mod pads;
|
||||
mod players;
|
||||
mod setup;
|
||||
@@ -145,6 +146,9 @@ pub(super) struct Split<'a> {
|
||||
/// Whose game runs now (0 = P1), and P1's buttons while another's does.
|
||||
turn: usize,
|
||||
host_widgets: widgets::Saved,
|
||||
/// The launcher's other Microsoft accounts' sessions, for splitscreen players to sign in
|
||||
/// with (`launcher::accounts`).
|
||||
accounts: Vec<Arc<horizon_client::auth::AuthSession>>,
|
||||
/// `HORIZON_SPLIT_STATS`: when the frame times were last printed.
|
||||
stats: Option<Instant>,
|
||||
/// For the readout: where a frame's wall time goes (the turns with their renders, presents,
|
||||
@@ -200,6 +204,7 @@ impl<'a> Split<'a> {
|
||||
res: None,
|
||||
turn: 0,
|
||||
host_widgets: Default::default(),
|
||||
accounts: launcher::accounts(),
|
||||
stats: std::env::var_os("HORIZON_SPLIT_STATS").map(|_| Instant::now()),
|
||||
spent: Default::default(),
|
||||
spent_frames: 0,
|
||||
@@ -398,6 +403,7 @@ impl<'a> Split<'a> {
|
||||
None,
|
||||
api,
|
||||
batching,
|
||||
self.host.settings.horizon.wide_frame(),
|
||||
shader_pack,
|
||||
);
|
||||
let renderer = match built {
|
||||
@@ -475,6 +481,7 @@ impl<'a> Split<'a> {
|
||||
None,
|
||||
api,
|
||||
batching,
|
||||
self.host.settings.horizon.wide_frame(),
|
||||
shader_pack,
|
||||
);
|
||||
let renderer = match built {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Microsoft sessions handed over by the launcher; offline split profiles belong to the instance.
|
||||
use horizon_client::auth::AuthSession;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The launcher's other signed-in accounts (`HORIZON_ACCOUNT_SESSIONS`: name, uuid and a session
|
||||
/// file each), for splitscreen players to sign in with: read at start beside `--session-file`
|
||||
/// (`read_now`), as the files go when read and are refused once a couple of minutes old.
|
||||
pub(super) fn accounts() -> Vec<Arc<AuthSession>> {
|
||||
READ.get_or_init(read).0.clone()
|
||||
}
|
||||
|
||||
static READ: std::sync::OnceLock<(Vec<Arc<AuthSession>>, Vec<String>)> = std::sync::OnceLock::new();
|
||||
|
||||
pub(super) fn unavailable() -> Vec<String> {
|
||||
READ.get_or_init(read).1.clone()
|
||||
}
|
||||
|
||||
/// Read (and so delete) the session files now, before the game loads.
|
||||
pub(in crate::game) fn read_now() {
|
||||
READ.get_or_init(read);
|
||||
}
|
||||
|
||||
fn read() -> (Vec<Arc<AuthSession>>, Vec<String>) {
|
||||
let Ok(json) = std::env::var("HORIZON_ACCOUNT_SESSIONS") else {
|
||||
return Default::default();
|
||||
};
|
||||
parse(&json)
|
||||
}
|
||||
|
||||
fn parse(json: &str) -> (Vec<Arc<AuthSession>>, Vec<String>) {
|
||||
let list: Vec<serde_json::Value> = serde_json::from_str(json).unwrap_or_default();
|
||||
let mut sessions = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
for a in list {
|
||||
// Every file listed is read (and so deleted) first, whatever else the entry says:
|
||||
// nothing with a token stays on disk.
|
||||
let session = a
|
||||
.get("file")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|file| AuthSession::load(std::path::Path::new(file)).ok());
|
||||
let Some(name) = a.get("name").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !horizon_client::split_profiles::valid_name(name) {
|
||||
continue;
|
||||
}
|
||||
match session {
|
||||
Some(session)
|
||||
if session.name.eq_ignore_ascii_case(name)
|
||||
&& a.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(horizon_client::auth::parse_uuid)
|
||||
== Some(session.uuid) =>
|
||||
{
|
||||
sessions.push(Arc::new(session))
|
||||
}
|
||||
_ => {
|
||||
// Reasons are deliberately fixed text: never log session contents or tokens.
|
||||
log::warn!(
|
||||
"Horizon: splitscreen: {name} is offline; sign in again in the launcher."
|
||||
);
|
||||
unavailable.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
(sessions, unavailable)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mismatched_account_sessions_are_consumed_and_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-split-account-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("session.json");
|
||||
std::fs::write(&file, r#"{"name":"Ann","uuid":"00000000000000000000000000000001","access_token":"unused","user_type":"msa"}"#).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let json = serde_json::json!([{"name":"Ann", "uuid":"00000000000000000000000000000002", "file":file}]).to_string();
|
||||
let (sessions, unavailable) = parse(&json);
|
||||
assert!(sessions.is_empty());
|
||||
assert_eq!(unavailable, ["Ann"]);
|
||||
assert!(
|
||||
!file.exists(),
|
||||
"session files are read once even when mismatched"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_session_file_is_consumed() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-split-consumed-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = |n: &str| {
|
||||
let file = dir.join(format!("session-{n}.json"));
|
||||
std::fs::write(&file, r#"{"name":"Bea","uuid":"00000000000000000000000000000002","access_token":"not-a-real-token","user_type":"msa"}"#).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
file
|
||||
};
|
||||
let (bad_name, no_name, good) = (file("a"), file("b"), file("c"));
|
||||
let uuid = "00000000000000000000000000000002";
|
||||
let json = serde_json::json!([
|
||||
{"name": "not a name!", "uuid": uuid, "file": bad_name},
|
||||
{"uuid": uuid, "file": no_name},
|
||||
{"name": "Bea", "uuid": uuid, "file": good},
|
||||
])
|
||||
.to_string();
|
||||
let (sessions, unavailable) = parse(&json);
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!((sessions[0].name.as_str(), sessions[0].uuid), ("Bea", 2));
|
||||
assert!(unavailable.is_empty());
|
||||
for f in [bad_name, no_name, good] {
|
||||
assert!(!f.exists(), "{} left on disk", f.display());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,28 @@ fn boxed(start: impl FnOnce() -> Result<Option<Game<'static>>>) -> Result<Box<Ga
|
||||
}
|
||||
}
|
||||
|
||||
/// P1's server lets a splitscreen player in (#6399: everyone who plays elsewhere owns the
|
||||
/// game): `account`, its launcher Microsoft session (whether that has expired), else an offline
|
||||
/// profile, `unavailable` when that name is a launcher account that couldn't be handed over.
|
||||
/// An offline profile joins only a server on this computer or its local network
|
||||
/// (`local`, resolved only for it) in offline mode; a Microsoft account any server while its
|
||||
/// session holds. The refusal is its translation key.
|
||||
fn server_gate(
|
||||
account: Option<bool>,
|
||||
unavailable: bool,
|
||||
local: impl FnOnce() -> bool,
|
||||
online: bool,
|
||||
) -> std::result::Result<(), &'static str> {
|
||||
match account {
|
||||
Some(false) => Ok(()),
|
||||
Some(true) => Err("horizon.splitscreen.account_expired"),
|
||||
None if !local() => Err(LOCAL_ONLY),
|
||||
None if !online => Ok(()),
|
||||
None if unavailable => Err("horizon.splitscreen.account_unavailable"),
|
||||
None => Err(ONLINE_ONLY),
|
||||
}
|
||||
}
|
||||
|
||||
impl Split<'_> {
|
||||
/// The first seat free (2 to `MAX_PLAYERS`).
|
||||
pub(super) fn free_seat(&self) -> Option<usize> {
|
||||
@@ -138,21 +160,29 @@ impl Split<'_> {
|
||||
}
|
||||
|
||||
/// Whether `name` can play in P1's world, said on the profile page before it tries: P1's
|
||||
/// own world takes anyone; a server only on this computer or its local network, and an
|
||||
/// online-mode one no offline profile.
|
||||
/// own world takes anyone; offline profiles require a local, offline-mode server, while
|
||||
/// Microsoft accounts require a valid session.
|
||||
fn can_join(&self, name: &str) -> std::result::Result<(), String> {
|
||||
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(name)) {
|
||||
return Err(format!("{name} is already playing."));
|
||||
}
|
||||
if let Some(address) = self.p1_server() {
|
||||
if !local_server(&address) {
|
||||
return Err(horizon_client::lang::tr(LOCAL_ONLY, &[]));
|
||||
}
|
||||
if self.host.online_server() {
|
||||
return Err(horizon_client::lang::tr(ONLINE_ONLY, &[]));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let Some(address) = self.p1_server() else {
|
||||
return Ok(());
|
||||
};
|
||||
let account = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(name));
|
||||
let unavailable = launcher::unavailable()
|
||||
.iter()
|
||||
.any(|a| a.eq_ignore_ascii_case(name));
|
||||
server_gate(
|
||||
account.map(|a| a.expired()),
|
||||
unavailable,
|
||||
|| local_server(&address),
|
||||
self.host.online_server(),
|
||||
)
|
||||
.map_err(|key| horizon_client::lang::tr(key, &[&name]))
|
||||
}
|
||||
|
||||
/// A server's resource packs: the others answer as P1 did (asked once, by P1). A player
|
||||
@@ -186,20 +216,10 @@ impl Split<'_> {
|
||||
if !self.guests.iter().any(open) {
|
||||
return;
|
||||
}
|
||||
let names = Self::folder()
|
||||
.and_then(|f| {
|
||||
Ok(horizon_client::split_profiles::store::names(
|
||||
&f.join("profiles"),
|
||||
)?)
|
||||
})
|
||||
.map(|mut names| {
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: splitscreen: the profiles: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
let (names, accounts) = self.profile_names().unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: splitscreen: the profiles: {e}");
|
||||
Default::default()
|
||||
});
|
||||
for g in self.guests.iter_mut().filter(|g| open(g)) {
|
||||
let page = &mut g.game.ui.options.split_profiles;
|
||||
page.filled = true;
|
||||
@@ -208,10 +228,31 @@ impl Split<'_> {
|
||||
page.names.insert(0, me.clone());
|
||||
}
|
||||
page.taken = taken.clone();
|
||||
page.accounts = accounts.clone();
|
||||
page.offline_accounts = launcher::unavailable();
|
||||
page.offline_accounts.extend(
|
||||
self.accounts
|
||||
.iter()
|
||||
.filter(|a| a.expired())
|
||||
.map(|a| a.name.clone()),
|
||||
);
|
||||
page.current = g.name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's other Microsoft accounts, then this instance's offline profiles.
|
||||
fn profile_names(&self) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let profiles = Self::folder()?.join("profiles");
|
||||
let mut offline = horizon_client::split_profiles::store::names(&profiles)?;
|
||||
let mut accounts: Vec<String> = self.accounts.iter().map(|a| a.name.clone()).collect();
|
||||
accounts.extend(launcher::unavailable());
|
||||
offline.retain(|n| !accounts.iter().any(|a| a.eq_ignore_ascii_case(n)));
|
||||
offline.sort_by_key(|n| n.to_lowercase());
|
||||
let mut names = accounts.clone();
|
||||
names.extend(offline);
|
||||
Ok((names, accounts))
|
||||
}
|
||||
|
||||
/// Guest `i`, playing, switches to profile `name` where it plays: out of the world as its
|
||||
/// old profile, in as the new one.
|
||||
fn switch(&mut self, i: usize, name: String) -> Result<()> {
|
||||
@@ -235,7 +276,14 @@ impl Split<'_> {
|
||||
return Err(format!("{name:?} can't be a profile.").into());
|
||||
}
|
||||
let profiles = Self::folder()?.join("profiles");
|
||||
if horizon_client::split_profiles::valid_name(&name) {
|
||||
// A Microsoft account signs in as itself (its own connections and skin).
|
||||
let account = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(&name))
|
||||
.cloned();
|
||||
self.guests[i].game.split_auth = account.clone();
|
||||
if account.is_none() && horizon_client::split_profiles::valid_name(&name) {
|
||||
horizon_client::split_profiles::store::restore(&profiles, &name)?;
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
@@ -295,8 +343,18 @@ impl Split<'_> {
|
||||
.split_profiles
|
||||
.take_forgotten()
|
||||
{
|
||||
let hidden = if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
Err("This profile cannot be forgotten while it is playing.".into())
|
||||
let signed_in = self
|
||||
.accounts
|
||||
.iter()
|
||||
.any(|a| a.name.eq_ignore_ascii_case(&name));
|
||||
let signed_in = signed_in
|
||||
|| launcher::unavailable()
|
||||
.iter()
|
||||
.any(|a| a.eq_ignore_ascii_case(&name));
|
||||
let hidden = if signed_in
|
||||
|| self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name))
|
||||
{
|
||||
Err("This profile cannot be forgotten while it is playing or signed in.".into())
|
||||
} else {
|
||||
Self::folder().and_then(|f| {
|
||||
Ok(horizon_client::split_profiles::store::hide(
|
||||
@@ -415,6 +473,7 @@ impl Split<'_> {
|
||||
None,
|
||||
host.settings.horizon.api,
|
||||
host.settings.horizon.translucent_batching,
|
||||
host.settings.horizon.wide_frame(),
|
||||
// The pack its own game loads, from its profile.
|
||||
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
|
||||
)?);
|
||||
@@ -505,7 +564,7 @@ fn folder_name(name: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{folder_name, local_server};
|
||||
use super::{LOCAL_ONLY, ONLINE_ONLY, folder_name, local_server, server_gate};
|
||||
|
||||
#[test]
|
||||
fn a_profile_name_never_leaves_the_profiles_folder() {
|
||||
@@ -541,4 +600,39 @@ mod tests {
|
||||
assert!(!local_server(elsewhere), "{elsewhere} is not local");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_microsoft_session_lifts_the_local_server_rule() {
|
||||
const EXPIRED: &str = "horizon.splitscreen.account_expired";
|
||||
const UNAVAILABLE: &str = "horizon.splitscreen.account_unavailable";
|
||||
// (account: None offline, Some(expired)), unavailable, local, online => the answer.
|
||||
let cases: [(Option<bool>, bool, bool, bool, Result<(), &str>); 11] = [
|
||||
// An offline profile: home-network offline-mode servers only.
|
||||
(None, false, true, false, Ok(())),
|
||||
(None, false, false, false, Err(LOCAL_ONLY)),
|
||||
(None, false, false, true, Err(LOCAL_ONLY)),
|
||||
(None, false, true, true, Err(ONLINE_ONLY)),
|
||||
// A launcher account that couldn't be handed over plays as an offline profile.
|
||||
(None, true, false, false, Err(LOCAL_ONLY)),
|
||||
(None, true, true, true, Err(UNAVAILABLE)),
|
||||
(None, true, true, false, Ok(())),
|
||||
// Its own Microsoft session: any server, while it holds.
|
||||
(Some(false), false, false, false, Ok(())),
|
||||
(Some(false), false, false, true, Ok(())),
|
||||
(Some(true), false, false, true, Err(EXPIRED)),
|
||||
(Some(true), false, true, false, Err(EXPIRED)),
|
||||
];
|
||||
for (account, unavailable, local, online, want) in cases {
|
||||
assert_eq!(
|
||||
server_gate(account, unavailable, || local, online),
|
||||
want,
|
||||
"{account:?} unavailable={unavailable} local={local} online={online}"
|
||||
);
|
||||
}
|
||||
// A Microsoft session never needs the address resolved.
|
||||
assert_eq!(
|
||||
server_gate(Some(false), false, || unreachable!(), true),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2636,6 +2636,10 @@ pub struct MobWorld<'a> {
|
||||
pub others_grid: Option<&'a seen_grid::SeenGrid>,
|
||||
/// Where each id stands in `others`, when the caller keeps one.
|
||||
pub others_index: Option<&'a crate::fast_hash::FxHashMap<u32, u32>>,
|
||||
/// The slots of `others` holding a villager, in order, when the caller
|
||||
/// keeps them (the mob ticks: a slot keeps its mob, and its species, all
|
||||
/// tick). Each zombie looking for a village read every slot.
|
||||
pub others_villagers: Option<&'a [u32]>,
|
||||
pub daylight: bool,
|
||||
/// The `gameplay/monsters_burn` attribute: the overworld day timeline's
|
||||
/// step track, false in every other dimension.
|
||||
@@ -2832,12 +2836,15 @@ impl Mob {
|
||||
}
|
||||
/// `Entity.makeBoundingBox(position)`.
|
||||
pub fn bounds_at(&self, position: Vec3) -> Aabb {
|
||||
let w = f64::from(self.width()) / 2.0;
|
||||
// `width()` and `height()` in one `dims()`: every box asked for (each
|
||||
// collision, push and cart test) read the whole size twice.
|
||||
let (width, height, _) = self.dims();
|
||||
let w = f64::from(width as f32) / 2.0;
|
||||
Aabb::new(
|
||||
Vec3::new(position.x - w, position.y, position.z - w),
|
||||
Vec3::new(
|
||||
position.x + w,
|
||||
position.y + f64::from(self.height()),
|
||||
position.y + f64::from(height as f32),
|
||||
position.z + w,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2724,6 +2724,14 @@ impl Goal for TryFindWater {
|
||||
m.nav_move_to(cx.w, self.wanted, 1.0);
|
||||
}
|
||||
}
|
||||
/// The block a mob stands in (`blockPosition`).
|
||||
fn block_of(m: &Mob) -> [i32; 3] {
|
||||
[
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
]
|
||||
}
|
||||
/// `MoveToBlockGoal`'s shared state: search, walk to and stay at a block.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MoveToBlock {
|
||||
@@ -2768,11 +2776,16 @@ impl MoveToBlock {
|
||||
w: &MobWorld,
|
||||
valid: impl Fn(&MobWorld, [i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
let base = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
match self.walk(block_of(m), |p| valid(w, p)) {
|
||||
Some(p) => {
|
||||
self.block = p;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `findNearestBlock`'s walk out from `base`: the first cell `valid` takes.
|
||||
fn walk(&self, base: [i32; 3], mut valid: impl FnMut([i32; 3]) -> bool) -> Option<[i32; 3]> {
|
||||
let next = |v: i32| if v > 0 { -v } else { 1 - v };
|
||||
let mut y = self.vertical_start;
|
||||
while y <= self.vertical_range {
|
||||
@@ -2782,9 +2795,8 @@ impl MoveToBlock {
|
||||
let mut z = if x < r && x > -r { r } else { 0 };
|
||||
while z <= r {
|
||||
let p = [base[0] + x, base[1] + y - 1, base[2] + z];
|
||||
if valid(w, p) {
|
||||
self.block = p;
|
||||
return true;
|
||||
if valid(p) {
|
||||
return Some(p);
|
||||
}
|
||||
z = next(z);
|
||||
}
|
||||
@@ -2793,7 +2805,46 @@ impl MoveToBlock {
|
||||
}
|
||||
y = next(y);
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
/// The cells [`Self::walk`] can reach from `base`, as a box (inclusive), or
|
||||
/// `None` when it walks none.
|
||||
fn walk_bounds(&self, base: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
|
||||
if self.range <= 0 || self.vertical_start > self.vertical_range {
|
||||
return None;
|
||||
}
|
||||
// The heights go out from `vertical_start` both ways, past neither it
|
||||
// nor `vertical_range` by more than one, a block below.
|
||||
let reach = self.range - 1;
|
||||
let height = self
|
||||
.vertical_start
|
||||
.saturating_abs()
|
||||
.max(self.vertical_range.saturating_abs())
|
||||
.saturating_add(1);
|
||||
Some((
|
||||
[
|
||||
base[0].saturating_sub(reach),
|
||||
base[1].saturating_sub(height).saturating_sub(1),
|
||||
base[2].saturating_sub(reach),
|
||||
],
|
||||
[
|
||||
base[0].saturating_add(reach),
|
||||
base[1].saturating_add(height),
|
||||
base[2].saturating_add(reach),
|
||||
],
|
||||
))
|
||||
}
|
||||
/// Whether [`Self::find_nearest`] may find a block whose state `matches`
|
||||
/// (its `valid` needs one): false when no section of the cells it walks
|
||||
/// holds such a state in its palette, so the walk would find nothing.
|
||||
pub fn may_find(
|
||||
&self,
|
||||
m: &Mob,
|
||||
w: &MobWorld,
|
||||
matches: impl FnMut(horizon_world::registry::BlockStateId) -> bool,
|
||||
) -> bool {
|
||||
self.walk_bounds(block_of(m))
|
||||
.is_some_and(|(lo, hi)| w.world.may_hold(lo, hi, matches))
|
||||
}
|
||||
pub fn can_continue(&self, w: &MobWorld, valid: impl Fn(&MobWorld, [i32; 3]) -> bool) -> bool {
|
||||
self.try_ticks >= -self.max_stay && self.try_ticks <= 1200 && valid(w, self.block)
|
||||
@@ -3020,3 +3071,44 @@ mod peaceful_targets_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod move_to_block_tests {
|
||||
use super::MoveToBlock;
|
||||
|
||||
/// Every cell the walk tries lies in the box `may_find` asks the
|
||||
/// palettes about, for the ranges the goals use and odd ones; and the
|
||||
/// walk stops at the first cell it is given.
|
||||
#[test]
|
||||
fn the_walk_stays_in_its_bounds() {
|
||||
for range in [-1, 0, 1, 2, 8, 24] {
|
||||
for vertical_range in [-2, 0, 1, 2, 3, 6] {
|
||||
for vertical_start in [-3, -1, 0, 1, 2] {
|
||||
let goal = MoveToBlock {
|
||||
range,
|
||||
vertical_range,
|
||||
vertical_start,
|
||||
..Default::default()
|
||||
};
|
||||
for base in [[0, 64, 0], [-17, -60, 31], [100, 319, -100]] {
|
||||
let bounds = goal.walk_bounds(base);
|
||||
let mut walked = 0;
|
||||
let found = goal.walk(base, |p| {
|
||||
walked += 1;
|
||||
let (lo, hi) = bounds.expect("a walk with no bounds");
|
||||
assert!((0..3).all(|a| lo[a] <= p[a] && p[a] <= hi[a]), "{p:?}");
|
||||
false
|
||||
});
|
||||
assert_eq!(found, None);
|
||||
assert_eq!(
|
||||
walked > 0,
|
||||
bounds.is_some(),
|
||||
"{range} {vertical_range} {vertical_start}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let goal = MoveToBlock::new(1.0, 24, 2);
|
||||
assert_eq!(goal.walk([5, 64, 5], |_| true), Some([5, 63, 5]));
|
||||
}
|
||||
}
|
||||
@@ -1921,6 +1921,7 @@ mod tests {
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
@@ -2034,6 +2035,7 @@ mod tests {
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
|
||||
@@ -1025,28 +1025,29 @@ impl<'a, 'w> Finder<'a, 'w> {
|
||||
let malus =
|
||||
|s: &Self, n: Option<u32>| n.is_some_and(|n| s.nodes[n as usize].cost_malus >= 0.0);
|
||||
// Direction.values(): down, up, north, south, west, east.
|
||||
let mut by_dir: FxHashMap<(i32, i32, i32), Option<u32>> = FxHashMap::default();
|
||||
for (dx, dy, dz) in [
|
||||
let dirs = [
|
||||
(0, -1, 0),
|
||||
(0, 1, 0),
|
||||
(0, 0, -1),
|
||||
(0, 0, 1),
|
||||
(-1, 0, 0),
|
||||
(1, 0, 0),
|
||||
] {
|
||||
];
|
||||
let mut by_dir: [Option<u32>; 6] = [None; 6];
|
||||
for (slot, (dx, dy, dz)) in by_dir.iter_mut().zip(dirs) {
|
||||
let n = self.swim_accepted(px + dx, py + dy, pz + dz);
|
||||
by_dir.insert((dx, dy, dz), n);
|
||||
*slot = n;
|
||||
if open(self, n) {
|
||||
out.push(n.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
for ((dx, dz), (cx, cz)) in [
|
||||
((0, -1), (1, 0)),
|
||||
((1, 0), (0, 1)),
|
||||
((0, 1), (-1, 0)),
|
||||
((-1, 0), (0, -1)),
|
||||
for (a, b, dx, dz, cx, cz) in [
|
||||
(2usize, 5usize, 0i32, -1i32, 1i32, 0i32),
|
||||
(5usize, 3usize, 1i32, 0i32, 0i32, 1i32),
|
||||
(3usize, 4usize, 0i32, 1i32, -1i32, 0i32),
|
||||
(4usize, 2usize, -1i32, 0i32, 0i32, -1i32),
|
||||
] {
|
||||
if malus(self, by_dir[&(dx, 0, dz)]) && malus(self, by_dir[&(cx, 0, cz)]) {
|
||||
if malus(self, by_dir[a]) && malus(self, by_dir[b]) {
|
||||
let d = self.swim_accepted(px + dx + cx, py, pz + dz + cz);
|
||||
if open(self, d) {
|
||||
out.push(d.unwrap_or_default());
|
||||
|
||||
@@ -1162,7 +1162,10 @@ impl Goal for AttackTurtleEgg {
|
||||
self.base.next_start -= 1;
|
||||
return false;
|
||||
}
|
||||
if self.base.find_nearest(m, cx.w, turtle_egg_target) {
|
||||
// Most of the level holds no turtle egg: its sections' palettes say
|
||||
// so without the walk of every cell in reach.
|
||||
let egg = |s| &*cx.w.facts.get(s).name == "minecraft:turtle_egg";
|
||||
if self.base.may_find(m, cx.w, egg) && self.base.find_nearest(m, cx.w, turtle_egg_target) {
|
||||
self.base.next_start = super::goals::reduced(20);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1046,7 +1046,7 @@ pub(super) fn village_sections(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in w.others {
|
||||
let mut villager = |o: &super::Seen| {
|
||||
if o.alive && o.species == Species::Villager {
|
||||
for p in [o.job, o.bed, o.potential_job, o.meeting]
|
||||
.into_iter()
|
||||
@@ -1057,6 +1057,10 @@ pub(super) fn village_sections(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
match w.others_villagers {
|
||||
Some(slots) => slots.iter().for_each(|&i| villager(&w.others[i as usize])),
|
||||
None => w.others.iter().for_each(villager),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -472,6 +472,7 @@ pub(crate) fn with_resource_test_world(
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
|
||||
@@ -209,6 +209,7 @@ mod tests {
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct SavedOldWorld {
|
||||
data: Arc<GeneratorData>,
|
||||
/// The old chunks of each region file (`SaveDir::old_chunk_bits`).
|
||||
regions: Mutex<HashMap<(i32, i32), Arc<OnceLock<[u64; 16]>>>>,
|
||||
/// Chunks asked about one at a time in regions not scanned ([`OldWorld::is_old_chunk`]):
|
||||
/// per region, the chunks read and the old ones among them.
|
||||
single: Mutex<HashMap<(i32, i32), ([u64; 16], [u64; 16])>>,
|
||||
/// Each old chunk's blending data, its border columns calculated.
|
||||
blending: Mutex<HashMap<(i32, i32), Option<Arc<BlendingData>>>>,
|
||||
kept_biomes: Mutex<HashMap<(i32, i32), Option<Arc<BiomeChunk<BiomeId>>>>>,
|
||||
@@ -43,6 +46,7 @@ impl SavedOldWorld {
|
||||
biomes,
|
||||
data,
|
||||
regions: Default::default(),
|
||||
single: Default::default(),
|
||||
blending: Default::default(),
|
||||
kept_biomes: Default::default(),
|
||||
}
|
||||
@@ -152,6 +156,28 @@ impl OldWorld for SavedOldWorld {
|
||||
}
|
||||
false
|
||||
}
|
||||
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let index = ((x & 31) + (z & 31) * 32) as usize;
|
||||
let (word, bit) = (index / 64, 1u64 << (index % 64));
|
||||
let scanned = (self.regions.lock().unwrap().get(&key)).and_then(|cell| cell.get().copied());
|
||||
if let Some(bits) = scanned {
|
||||
return bits[word] & bit != 0;
|
||||
}
|
||||
if let Some((read, old)) = self.single.lock().unwrap().get(&key)
|
||||
&& read[word] & bit != 0
|
||||
{
|
||||
return old[word] & bit != 0;
|
||||
}
|
||||
let old = self.save.is_old_chunk(x, z);
|
||||
let mut single = self.single.lock().unwrap();
|
||||
let (read, olds) = single.entry(key).or_default();
|
||||
read[word] |= bit;
|
||||
if old {
|
||||
olds[word] |= bit;
|
||||
}
|
||||
old
|
||||
}
|
||||
fn saved_starts(&self, x: i32, z: i32) -> Vec<horizon_world::worldgen::blending::SavedStart> {
|
||||
self.save
|
||||
.raw_chunk(x, z)
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_world::{level::RandomTickMask, registry::BlockStateId};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
/// A value snapshot, in ascending section order, with no per-chunk allocation.
|
||||
@@ -55,8 +56,10 @@ struct Cached {
|
||||
read: Vec<Weak<Section>>,
|
||||
sections: Sections,
|
||||
/// Each ticking section's [`RandomTickMask`], built at its first draw
|
||||
/// and dropped when the section is read again.
|
||||
masks: Vec<Option<Arc<RandomTickMask>>>,
|
||||
/// and dropped when the section is read again. `Rc`, not `Arc`: the
|
||||
/// tick thread hands one out per ticking section per tick, and an atomic
|
||||
/// count on a mask out of cache stalled the whole random tick loop.
|
||||
masks: Vec<Option<Rc<RandomTickMask>>>,
|
||||
}
|
||||
/// Vanilla keeps a count of randomly ticking blocks and fluids per section,
|
||||
/// updated by every `setBlockState`, and ticks the sections where it is above
|
||||
@@ -103,7 +106,7 @@ impl RandomTickSections {
|
||||
facts: &BlockFacts,
|
||||
candidate: impl FnMut(u32) -> bool,
|
||||
from_y: i32,
|
||||
) -> Option<(i32, Arc<RandomTickMask>)> {
|
||||
) -> Option<(i32, Rc<RandomTickMask>)> {
|
||||
let cached = Self::refresh(&mut self.chunks, key, chunk, &mut |id| {
|
||||
facts.get(BlockStateId(id)).random_ticks
|
||||
});
|
||||
@@ -112,7 +115,7 @@ impl RandomTickSections {
|
||||
let mask = cached.masks[index].get_or_insert_with(|| {
|
||||
let mut blocks = [0u32; 4096];
|
||||
chunk.sections[index].blocks.unpack(&mut blocks);
|
||||
Arc::new(RandomTickMask::of(&blocks, candidate))
|
||||
Rc::new(RandomTickMask::of(&blocks, candidate))
|
||||
});
|
||||
Some((y, mask.clone()))
|
||||
}
|
||||
|
||||
@@ -2562,17 +2562,19 @@ impl SaveDir {
|
||||
let Some(root) = self.read_record_raw("region", pos.x, pos.z) else {
|
||||
continue;
|
||||
};
|
||||
let old = match root.tag.get("DataVersion") {
|
||||
Some(Tag::Int(version)) => *version < 4997,
|
||||
_ => true,
|
||||
} || root.tag.get("blending_data").is_some();
|
||||
if old {
|
||||
if record_is_old(&root) {
|
||||
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
|
||||
bits[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
/// Whether the saved chunk at `x, z` is old ([`Self::old_chunk_bits`]'s test), from its
|
||||
/// record alone: false for a chunk not saved.
|
||||
pub fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
self.read_record_raw("region", x, z)
|
||||
.is_some_and(|root| record_is_old(&root))
|
||||
}
|
||||
/// A saved chunk brought to the current version, for the readers of its
|
||||
/// fields that `load` does not decode (`blending_data`).
|
||||
pub fn raw_chunk(&self, x: i32, z: i32) -> Option<NamedTag> {
|
||||
@@ -5064,6 +5066,15 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `IOWorker.createOldDataForRegion`'s test of one record: saved before DataVersion 4997, or
|
||||
/// with `blending_data`.
|
||||
fn record_is_old(root: &NamedTag) -> bool {
|
||||
let before_blending = match root.tag.get("DataVersion") {
|
||||
Some(Tag::Int(version)) => *version < 4997,
|
||||
_ => true,
|
||||
};
|
||||
before_blending || root.tag.get("blending_data").is_some()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::mobs::spawner::SpawnDataExt;
|
||||
@@ -7440,6 +7451,30 @@ mod tests {
|
||||
let upgraded = nether.raw_chunk(0, 0).expect("the chunk reads");
|
||||
assert!(upgraded.tag.get("blending_data").is_none());
|
||||
assert_ne!(nether.old_chunk_bits(0, 0)[0] & 1, 0);
|
||||
// One chunk's record tells the same, without the region's other chunks; a chunk this
|
||||
// version saved, and one not saved, are not old.
|
||||
let current = Record::Tag(Arc::new(NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("xPos", Tag::Int(1)),
|
||||
("zPos", Tag::Int(0)),
|
||||
("yPos", Tag::Int(0)),
|
||||
("Status", string("minecraft:full")),
|
||||
("sections", Tag::List(Vec::new())),
|
||||
]),
|
||||
}));
|
||||
super::write_region_records(
|
||||
&nether.root,
|
||||
&nether.regions,
|
||||
"region",
|
||||
&[((1, 0), ¤t)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(nether.is_old_chunk(0, 0));
|
||||
assert!(!nether.is_old_chunk(1, 0));
|
||||
assert!(!nether.is_old_chunk(2, 0));
|
||||
assert_eq!(nether.old_chunk_bits(0, 0)[0] & 0b110, 0);
|
||||
// A POI record of before 1.14.4 (no DataVersion, read as 1945): its
|
||||
// sections at the root, which 1946 moved under `Sections`.
|
||||
let overworld = SaveDir::new(
|
||||
|
||||
@@ -32,17 +32,77 @@ impl std::error::Error for ShapeError {}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VoxelShape {
|
||||
identity: Arc<ShapeIdentity>,
|
||||
coords: Arc<[Vec<f64>; 3]>,
|
||||
coords: Coords,
|
||||
sizes: [usize; 3],
|
||||
occupancy: Arc<[u64]>,
|
||||
cube: bool,
|
||||
coordinate_cubes: [bool; 3],
|
||||
boxes: Arc<Vec<Aabb>>,
|
||||
boxes: Arc<[Aabb]>,
|
||||
bounds: Option<Aabb>,
|
||||
discrete_min: [i32; 3],
|
||||
discrete_max: [i32; 3],
|
||||
}
|
||||
|
||||
/// The three axes' boundaries in one allocation: axis `i` is
|
||||
/// `values[starts[i]..starts[i + 1]]`. A moved shape (one per block an
|
||||
/// entity's movement meets) then allocates its coordinates once, not four
|
||||
/// times.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Coords {
|
||||
values: Arc<[f64]>,
|
||||
starts: [usize; 4],
|
||||
}
|
||||
impl Coords {
|
||||
fn new(axes: [Vec<f64>; 3]) -> Self {
|
||||
let mut starts = [0; 4];
|
||||
for i in 0..3 {
|
||||
starts[i + 1] = starts[i] + axes[i].len();
|
||||
}
|
||||
let mut values = Vec::with_capacity(starts[3]);
|
||||
for axis in axes {
|
||||
values.extend(axis);
|
||||
}
|
||||
Self {
|
||||
values: values.into(),
|
||||
starts,
|
||||
}
|
||||
}
|
||||
fn to_vecs(&self) -> [Vec<f64>; 3] {
|
||||
std::array::from_fn(|i| self[i].to_vec())
|
||||
}
|
||||
/// Every boundary plus its axis's offset, the same addition as moving
|
||||
/// each axis on its own.
|
||||
fn moved(&self, offset: Vec3) -> Self {
|
||||
let [_, y, z, _] = self.starts;
|
||||
let [dx, dy, dz] = offset.components();
|
||||
let values = self
|
||||
.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
*value
|
||||
+ if i < y {
|
||||
dx
|
||||
} else if i < z {
|
||||
dy
|
||||
} else {
|
||||
dz
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
values,
|
||||
starts: self.starts,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::ops::Index<usize> for Coords {
|
||||
type Output = [f64];
|
||||
fn index(&self, axis: usize) -> &[f64] {
|
||||
&self.values[self.starts[axis]..self.starts[axis + 1]]
|
||||
}
|
||||
}
|
||||
|
||||
type FaceCache = [OnceLock<Result<VoxelShape, ShapeError>>; 6];
|
||||
#[derive(Debug, Default)]
|
||||
struct ShapeIdentity {
|
||||
@@ -102,17 +162,17 @@ impl VoxelShape {
|
||||
}
|
||||
let mut shape = Self {
|
||||
identity: Arc::new(ShapeIdentity::default()),
|
||||
coords: Arc::new(coords),
|
||||
coords: Coords::new(coords),
|
||||
sizes,
|
||||
occupancy: occupancy.into(),
|
||||
cube,
|
||||
coordinate_cubes,
|
||||
boxes: Arc::new(Vec::new()),
|
||||
boxes: Vec::new().into(),
|
||||
bounds: None,
|
||||
discrete_min,
|
||||
discrete_max,
|
||||
};
|
||||
shape.boxes = Arc::new(shape.decompose());
|
||||
shape.boxes = shape.decompose().into();
|
||||
shape.bounds = shape.boxes.iter().copied().reduce(Aabb::union_bounds);
|
||||
Ok(shape)
|
||||
}
|
||||
@@ -289,16 +349,10 @@ impl VoxelShape {
|
||||
result.identity = Arc::new(ShapeIdentity::default());
|
||||
result.cube = false;
|
||||
result.coordinate_cubes = [false; 3];
|
||||
let mut coords = (*self.coords).clone();
|
||||
for axis in Axis::ALL {
|
||||
for value in &mut coords[axis as usize] {
|
||||
*value += offset.component(axis);
|
||||
}
|
||||
}
|
||||
result.coords = Arc::new(coords);
|
||||
result.coords = self.coords.moved(offset);
|
||||
// The same occupancy decomposes into the same boxes, over coordinates
|
||||
// moved by the same additions.
|
||||
result.boxes = Arc::new(self.boxes.iter().map(|b| b.move_by(offset)).collect());
|
||||
result.boxes = self.boxes.iter().map(|b| b.move_by(offset)).collect();
|
||||
result.bounds = self.bounds.map(|bounds| bounds.move_by(offset));
|
||||
result
|
||||
}
|
||||
@@ -604,4 +658,34 @@ mod tests {
|
||||
assert!(!VoxelShape::block().moved(Vec3::ZERO).is_block());
|
||||
assert!(!VoxelShape::block().fresh_identity().is_block());
|
||||
}
|
||||
#[test]
|
||||
fn each_moved_axis_takes_only_its_own_offset() {
|
||||
// Three axes of different lengths, so a boundary given another
|
||||
// axis's offset would show.
|
||||
let coords = [
|
||||
vec![0.0, 0.25, 1.0],
|
||||
vec![0.0, 0.5],
|
||||
vec![0.0, 0.125, 0.75, 1.0],
|
||||
];
|
||||
let shape = VoxelShape::from_grid(coords.clone(), &[true; 6]).unwrap();
|
||||
let offset = Vec3::new(3.0, -64.0, 0.1);
|
||||
let moved = shape.moved(offset);
|
||||
for (axis, (values, by)) in Axis::ALL
|
||||
.into_iter()
|
||||
.zip(coords.iter().zip([3.0, -64.0, 0.1]))
|
||||
{
|
||||
let expected: Vec<u64> = values.iter().map(|v| (v + by).to_bits()).collect();
|
||||
let got: Vec<u64> = moved
|
||||
.coordinates(axis)
|
||||
.iter()
|
||||
.map(|v| v.to_bits())
|
||||
.collect();
|
||||
assert_eq!(got, expected, "{axis:?}");
|
||||
assert_eq!(
|
||||
shape.coordinates(axis),
|
||||
&values[..],
|
||||
"{axis:?} of the original"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ impl VoxelShape {
|
||||
if (0..3).any(|i| min[i] >= max[i]) {
|
||||
return Ok(Self::empty());
|
||||
}
|
||||
let mut coords = (*self.coords).clone();
|
||||
let mut coords = self.coords.to_vecs();
|
||||
coords[a] = vec![0.0, 1.0];
|
||||
if (0..3).all(|i| {
|
||||
coords[i].len() == 2
|
||||
|
||||
@@ -283,7 +283,11 @@ pub fn catch_stop_signals() {
|
||||
for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
|
||||
// SAFETY: the handler only touches an atomic and calls _exit, both async-signal-safe.
|
||||
unsafe {
|
||||
libc::signal(signal, handler as libc::sighandler_t);
|
||||
// A signal ignored from the start stays ignored, as the JVM leaves it (`nohup`'s SIGHUP:
|
||||
// a server started that way outlives its terminal).
|
||||
if libc::signal(signal, handler as libc::sighandler_t) == libc::SIG_IGN {
|
||||
libc::signal(signal, libc::SIG_IGN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
ticks::TickTypeId,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
@@ -1864,7 +1865,7 @@ impl Behaviours {
|
||||
level: &mut L,
|
||||
ticks: &mut Ticks,
|
||||
chunk: ChunkPos,
|
||||
mut next_ticking: impl FnMut(&mut L, i32) -> Option<(i32, Option<Arc<RandomTickMask>>)>,
|
||||
mut next_ticking: impl FnMut(&mut L, i32) -> Option<(i32, Option<Rc<RandomTickMask>>)>,
|
||||
) {
|
||||
let speed = level.game_rules().random_tick_speed;
|
||||
if speed <= 0 {
|
||||
|
||||
@@ -404,6 +404,13 @@ pub trait OldWorld: Send + Sync {
|
||||
/// `WorldGenRegion.isOldChunkAround`: a chunk within `range` of `center`
|
||||
/// (a square) is old, as far as the storage can tell without reading it.
|
||||
fn is_old_chunk_around(&self, center: ChunkPos, range: i32) -> bool;
|
||||
/// Whether the chunk at `x, z` is old, as `StructureCheck.tryLoadFromStorage` finds out:
|
||||
/// from that chunk's record alone. A structure search asks this of one chunk per spacing
|
||||
/// cell across thousands of cells; scanning each region it touched, as the blender does,
|
||||
/// read every chunk an explored world saved there (#8669).
|
||||
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
self.is_old_chunk_around(ChunkPos::new(x, z), 0)
|
||||
}
|
||||
/// The blending data of the old chunk at `x, z`, its border columns
|
||||
/// calculated, or None for a chunk that is not old.
|
||||
fn blending(&self, x: i32, z: i32) -> Option<Arc<BlendingData>>;
|
||||
|
||||
@@ -2014,7 +2014,7 @@ where
|
||||
self.pipeline
|
||||
.old_world
|
||||
.get()
|
||||
.is_some_and(|world| world.is_old_chunk_around(chunk, 0))
|
||||
.is_some_and(|world| world.is_old_chunk(chunk.x, chunk.z))
|
||||
}
|
||||
fn saved_starts(&self, chunk: ChunkPos) -> Vec<super::blending::SavedStart> {
|
||||
self.pipeline
|
||||
@@ -2455,6 +2455,16 @@ trait SeededPipeline: Send + Sync {
|
||||
) -> Result<(), String>;
|
||||
fn forget_structure_starts(&self);
|
||||
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>>;
|
||||
/// The structures whose random-spread set may start in `chunk`, each with whether a
|
||||
/// search's start check lets it start there.
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(
|
||||
&self,
|
||||
_worker: &mut WorkerState,
|
||||
_chunk: ChunkPos,
|
||||
) -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError>;
|
||||
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom>;
|
||||
fn locate(
|
||||
@@ -2624,6 +2634,20 @@ where
|
||||
structures.forget_starts();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(&self, worker: &mut WorkerState, chunk: ChunkPos) -> Vec<(String, bool)> {
|
||||
match &self.structures {
|
||||
Some(structures) => structures.may_start_answers(
|
||||
&mut StructureQueries {
|
||||
pipeline: self,
|
||||
worker,
|
||||
valid: BTreeSet::new(),
|
||||
},
|
||||
chunk,
|
||||
),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>> {
|
||||
self.structures.as_ref()?.ring_positions(
|
||||
&mut StructureQueries {
|
||||
@@ -3729,6 +3753,10 @@ impl GeneratorWorker {
|
||||
pub fn forget_structure_starts(&mut self) {
|
||||
self.pipeline.forget_structure_starts();
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(&mut self, chunk: ChunkPos) -> Vec<(String, bool)> {
|
||||
self.pipeline.may_start_answers(&mut self.state, chunk)
|
||||
}
|
||||
pub fn stage_timings(&self) -> StageTimings {
|
||||
self.state.timings
|
||||
}
|
||||
@@ -4282,6 +4310,44 @@ mod tests {
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
/// A map's structure search skips a chunk's starts when no wanted structure's start
|
||||
/// piece and biome fit there (#8669). Over the candidate chunks of every random-spread
|
||||
/// set around the camp TiesB2007's map went looking from, a skipped structure is never
|
||||
/// one the chunk's full starts hold, and some are skipped.
|
||||
#[test]
|
||||
fn may_start_never_skips_a_start_the_chunk_holds() {
|
||||
let Some(data) = prepared_data() else {
|
||||
return;
|
||||
};
|
||||
let generator = OverworldGenerator::new(data, -3292718948513116954).expect("generator");
|
||||
let mut worker = generator.worker();
|
||||
let (mut held, mut skipped) = (0, 0);
|
||||
for x in 212..332 {
|
||||
for z in -481..-361 {
|
||||
let chunk = ChunkPos::new(x, z);
|
||||
let checks = worker.may_start_answers(chunk);
|
||||
if checks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let starts: Vec<String> = (worker.structure_starts(chunk).iter())
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
for (name, may_start) in checks {
|
||||
if starts.contains(&name) {
|
||||
assert!(
|
||||
may_start,
|
||||
"{name} starts in {chunk:?}, but its check skips it"
|
||||
);
|
||||
held += 1;
|
||||
} else if !may_start {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(held > 0 && skipped > 0, "held {held}, skipped {skipped}");
|
||||
}
|
||||
|
||||
/// Generators of one seed and dimension share their ring positions;
|
||||
/// another seed or generator gets its own, and old entries go.
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
jigsaw::PoolPiece,
|
||||
start::{
|
||||
Piece, Rings, StartContext, StructureRegistry, StructureStart, beardifier,
|
||||
create_starts, try_generate,
|
||||
create_starts, may_start, try_generate,
|
||||
},
|
||||
stronghold::RingSearch,
|
||||
},
|
||||
@@ -379,6 +379,39 @@ impl StructureState {
|
||||
.any(|(_, search)| search.may_land_near(chunk.x, chunk.z, self.zone_reach));
|
||||
if near { self.rings(context) } else { &NONE }
|
||||
}
|
||||
/// The structures of the random-spread sets whose start chunk is `chunk`, each with
|
||||
/// [`may_start`]'s answer, for tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn may_start_answers(
|
||||
&self,
|
||||
context: &mut impl super::structure::start::StartContext,
|
||||
chunk: ChunkPos,
|
||||
) -> Vec<(String, bool)> {
|
||||
let seed = context.seed();
|
||||
let rings = self.rings_for(context, chunk).clone();
|
||||
let mut checks = Vec::new();
|
||||
for &si in &self.possible_sets {
|
||||
let set = &self.registry.sets[si];
|
||||
if set.spacing().is_none()
|
||||
|| StructureRegistry::potential_chunk(set, seed, chunk.x, chunk.z) != Some(chunk)
|
||||
|| !(self.registry).is_structure_chunk(set, seed, chunk.x, chunk.z, &rings)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &(s, _) in &set.structures {
|
||||
let may = may_start(
|
||||
&self.registry,
|
||||
&self.resources,
|
||||
&self.bindings,
|
||||
context,
|
||||
s,
|
||||
chunk,
|
||||
);
|
||||
checks.push((self.registry.structures[s].name.clone(), may));
|
||||
}
|
||||
}
|
||||
checks
|
||||
}
|
||||
/// `StructureRegistry::needs_dimension_origin`: whether any set is placed
|
||||
/// by the dimension's origin chunk alone.
|
||||
pub(super) fn needs_dimension_origin(&self) -> bool {
|
||||
@@ -602,9 +635,9 @@ impl StructureState {
|
||||
/// its locate position (the start chunk's corner at y 0). `skip` says a
|
||||
/// start (structure name, its chunk) can't be referenced again
|
||||
/// (`StructureStart.canBeReferenced`): the search goes on past it.
|
||||
pub(super) fn locate(
|
||||
pub(super) fn locate<C: super::structure::start::StartContext>(
|
||||
&self,
|
||||
context: &mut impl super::structure::start::StartContext,
|
||||
context: &mut C,
|
||||
names: &[String],
|
||||
from: [i32; 3],
|
||||
radius: i32,
|
||||
@@ -618,7 +651,30 @@ impl StructureState {
|
||||
dx * dx + dz * dz
|
||||
};
|
||||
let mut best: Option<(f64, String, [i32; 3])> = None;
|
||||
let found_at = |this: &Self, context: &mut _, chunk: ChunkPos| -> Option<String> {
|
||||
let wanted_structures: Vec<usize> = (0..self.registry.structures.len())
|
||||
.filter(|&s| wanted(s))
|
||||
.collect();
|
||||
let found_at = |this: &Self, context: &mut C, chunk: ChunkPos| -> Option<String> {
|
||||
// StructureCheck.checkStart: a chunk with no starts built or saved yet is first
|
||||
// asked whether a wanted structure could start there (canCreateStructure, a
|
||||
// jigsaw's start piece and its biome), and only then are all its starts built,
|
||||
// layouts included. A map's search for a camp of a rarer biome built thousands of
|
||||
// chunks' starts on the server thread, seconds of it (#8669).
|
||||
if this.starts.get((chunk.x, chunk.z)).is_none()
|
||||
&& !context.is_old_chunk(chunk)
|
||||
&& !wanted_structures.iter().any(|&s| {
|
||||
may_start(
|
||||
&this.registry,
|
||||
&this.resources,
|
||||
&this.bindings,
|
||||
context,
|
||||
s,
|
||||
chunk,
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
this.starts_at(context, chunk)
|
||||
.iter()
|
||||
.find(|s| wanted(s.structure) && !skip(&s.name, chunk))
|
||||
|
||||
@@ -1075,6 +1075,37 @@ pub fn assemble_drawing(
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<([i32; 3], Vec<PoolPiece>)> {
|
||||
let start = place_start(
|
||||
draws, config, context, pools, resources, bindings, random, position,
|
||||
)?;
|
||||
Some(lay_out_children(
|
||||
draws, start, config, context, pools, resources, bindings, random,
|
||||
))
|
||||
}
|
||||
|
||||
/// A jigsaw's start piece in place ([`place_start`]), its children not laid out yet: vanilla's
|
||||
/// `Structure.GenerationStub`, whose position the structure's biome is checked at before
|
||||
/// `JigsawPlacement.addPieces` lays the rest out.
|
||||
pub struct PlacedStart {
|
||||
/// The start position (`[centre x, start y, centre z]`).
|
||||
pub position: [i32; 3],
|
||||
center: PoolPiece,
|
||||
}
|
||||
|
||||
/// The first half of [`assemble_drawing`]: the start piece drawn, rotated, projected to the
|
||||
/// heightmap and checked against the height limits. None when the start is invalid. The
|
||||
/// draws it makes come first in `random`, as in `assemble_drawing`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn place_start(
|
||||
draws: Draws,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<PlacedStart> {
|
||||
let alias = |name: &str| {
|
||||
config
|
||||
.aliases
|
||||
@@ -1144,6 +1175,29 @@ pub fn assemble_drawing(
|
||||
return None;
|
||||
}
|
||||
let cy = bottom + local[1];
|
||||
Some(PlacedStart {
|
||||
position: [cx, cy, cz],
|
||||
center,
|
||||
})
|
||||
}
|
||||
|
||||
/// The second half of [`assemble_drawing`]: the children of a [`place_start`]ed start laid out
|
||||
/// to `config.max_depth`, with the same `random`. Returns the start position and every piece.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn lay_out_children(
|
||||
draws: Draws,
|
||||
start: PlacedStart,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> ([i32; 3], Vec<PoolPiece>) {
|
||||
let PlacedStart {
|
||||
position: [cx, cy, cz],
|
||||
center,
|
||||
} = start;
|
||||
let mut pieces = vec![center];
|
||||
if config.max_depth > 0 {
|
||||
let [h, v] = config.max_distance;
|
||||
@@ -1164,7 +1218,7 @@ pub fn assemble_drawing(
|
||||
container,
|
||||
);
|
||||
}
|
||||
Some(([cx, cy, cz], pieces))
|
||||
([cx, cy, cz], pieces)
|
||||
}
|
||||
|
||||
/// The inner JigsawPlacement.addPieces: lays out the children of
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use super::{
|
||||
BoundingBox, StructureResources,
|
||||
jigsaw::{
|
||||
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble_drawing,
|
||||
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, lay_out_children,
|
||||
place_start,
|
||||
},
|
||||
legacy::{LegacyPiece, PieceContext},
|
||||
};
|
||||
@@ -1018,7 +1019,53 @@ pub(crate) fn try_generate(
|
||||
chunk: ChunkPos,
|
||||
any_biome: bool,
|
||||
) -> Option<StructureStart> {
|
||||
match generate(
|
||||
registry, resources, bindings, context, structure, chunk, any_biome, false,
|
||||
)? {
|
||||
Generated::Start(start) => Some(start),
|
||||
Generated::Stub => None,
|
||||
}
|
||||
}
|
||||
/// `StructureCheck.canCreateStructure`: whether `structure` may start in `chunk`, by
|
||||
/// `Structure.findValidGenerationPoint` alone. A jigsaw's start piece is placed and its biome
|
||||
/// checked; its children are not laid out. Other structures answer true: their starts are
|
||||
/// cheap to build. False only where [`try_generate`] can't start it, so a search may skip
|
||||
/// the chunk's full starts then (#8669).
|
||||
pub(crate) fn may_start(
|
||||
registry: &StructureRegistry,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
context: &mut impl StartContext,
|
||||
structure: usize,
|
||||
chunk: ChunkPos,
|
||||
) -> bool {
|
||||
generate(
|
||||
registry, resources, bindings, context, structure, chunk, false, true,
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
/// What [`generate`] made: the start, or with `stub_only` only that it may start.
|
||||
enum Generated {
|
||||
Start(StructureStart),
|
||||
Stub,
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn generate(
|
||||
registry: &StructureRegistry,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
context: &mut impl StartContext,
|
||||
structure: usize,
|
||||
chunk: ChunkPos,
|
||||
any_biome: bool,
|
||||
stub_only: bool,
|
||||
) -> Option<Generated> {
|
||||
let def = ®istry.structures[structure];
|
||||
let vanilla_jigsaw =
|
||||
matches!(&def.kind, StructureKind::Jigsaw(jigsaw) if jigsaw.piece_limited.is_none());
|
||||
if stub_only && !vanilla_jigsaw {
|
||||
return Some(Generated::Stub);
|
||||
}
|
||||
context.set_valid_biomes((!any_biome).then_some(&def.biomes));
|
||||
let seed = context.seed();
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
@@ -1061,16 +1108,38 @@ pub(crate) fn try_generate(
|
||||
chunk,
|
||||
start,
|
||||
)?,
|
||||
None => assemble_drawing(
|
||||
jigsaw.draws,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
start,
|
||||
)?,
|
||||
None => {
|
||||
let placed = place_start(
|
||||
jigsaw.draws,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
start,
|
||||
)?;
|
||||
// Structure.findValidGenerationPoint checks the stub's biome before its
|
||||
// GenerationStub lays the pieces out: a start in another biome costs its
|
||||
// start piece, not its whole layout (an ancient city's, a camp's).
|
||||
let quart = placed.position.map(|v| v >> 2);
|
||||
if !any_biome && !def.biomes.contains(&context.noise_biome(quart)) {
|
||||
return None;
|
||||
}
|
||||
if stub_only {
|
||||
return Some(Generated::Stub);
|
||||
}
|
||||
lay_out_children(
|
||||
jigsaw.draws,
|
||||
placed,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
)
|
||||
}
|
||||
};
|
||||
// Vanilla's JigsawPlacement keeps no piece at size 0; the
|
||||
// piece-limited assembler keeps the start piece.
|
||||
@@ -1331,7 +1400,9 @@ pub(crate) fn try_generate(
|
||||
if (!any_biome && !def.biomes.contains(&context.noise_biome(quart))) || pieces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(StructureStart::new(structure, def, chunk, pieces))
|
||||
Some(Generated::Start(StructureStart::new(
|
||||
structure, def, chunk, pieces,
|
||||
)))
|
||||
}
|
||||
impl StructureStart {
|
||||
/// A start of `def` (registry index `structure`) from its pieces, at
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ section has one page per surface, plus the rules every surface follows:
|
||||
| Surface | For | API today | Stability | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Mods](../mods.md) | mod authors | content mods: manifest `api` 0.2; native mods: a horizon-mixins bundle ([native-mods.md](../native-mods.md)) | preview (content), experimental (native) | [mods.md](../mods.md) |
|
||||
| [Shaders](shaders.md) | shader authors | none yet: packs were removed on 2026-10-24 | planned | none |
|
||||
| [Shaders](shaders.md) | shader authors | API 0.1 (sky, vertex, material, weather and post stages, coloured block light, options, hot reload) | experimental | [shaders-reference.md](shaders-reference.md) |
|
||||
| [Agent control](agent-control.md) | agents, scripts, tests | `horizon-ctl`, its MCP server, the game's control socket | preview | [horizon-ctl-commands.md](../horizon-ctl-commands.md) (generated) |
|
||||
| [Servers](server.md) | hosts, panels, bots | `horizon-server` commands, `server.toml`, control API `/api/v1` | stable | [server-reference.md](server-reference.md) (generated) |
|
||||
| [Launcher](launcher.md) | players' agents, pack makers | `horizon-launcher` commands and its MCP server | preview | [launcher-commands.md](../launcher-commands.md) (generated) |
|
||||
|
||||
+7
-7
@@ -1,8 +1,9 @@
|
||||
# Shader API
|
||||
|
||||
Horizon has no shader packs today: the earlier implementation was removed on 2026-10-24. Two things will replace it,
|
||||
designed separately: a **Horizon shader API**, described here, and a clean-room loader for the existing pack format
|
||||
(its own spec; it is not part of this API). Both sit on the same renderer hooks, agreed between the two.
|
||||
Horizon has its own shader packs: WGSL written against stages of Horizon's renderer, checked before it runs. This
|
||||
page is the **Horizon shader API**. A separate clean-room loader reads the existing `shaders/` pack format (its own
|
||||
spec, not part of this API); both sit on the same renderer hooks. The earlier implementation was removed on
|
||||
2026-10-24; this API was written from scratch after it.
|
||||
|
||||
## Overview: what a shader author will be able to do
|
||||
|
||||
@@ -157,13 +158,12 @@ the same way every time (files in path order, fixed dates), so a pack's SHA-256
|
||||
|
||||
## Stability
|
||||
|
||||
Not started. The first stages will be experimental (opt-in), then preview once a reference, an example and a test
|
||||
pack exist ([stability.md](stability.md)).
|
||||
Experimental (API 0.1). The stages ship in the game behind pack selection; see [stability.md](stability.md).
|
||||
|
||||
## Not there yet
|
||||
|
||||
Option values other than the defaults (the three stages run, with hot reload), then the overlay and lighting
|
||||
stages.
|
||||
An overlay showing what each pass draws (F3 already shows what each costs), and stages for entities, block entities
|
||||
and particles (the material stage covers terrain only).
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Content mods (`registries`: data-driven blocks, items, mob effects, thrown entity types and menu types) | preview | manifest `api = "0.2"` |
|
||||
| Native mods (horizon-mixins bundles, [native-mods.md](../native-mods.md)) | experimental | the pinned revisions |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`, `check`) | preview | none yet (unversioned) |
|
||||
| Shader API | not started | |
|
||||
| Shader API | experimental | 0.1 |
|
||||
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
|
||||
| The game's own control-socket commands outside `horizon-ctl`'s table (test hooks) | experimental | |
|
||||
| `horizon-server` commands, `server.toml` | stable | `horizon-server --version` |
|
||||
|
||||
@@ -198,6 +198,12 @@ Cancel keeps the current one. The switch is disabled while another player is joi
|
||||
P1 is alone, turning it off restores the normal controller reader. Leaving it on while
|
||||
alone keeps watching for joins without a compositor.
|
||||
|
||||
When Splitscreen is enabled, Play in the launcher also hands over its other signed-in
|
||||
Microsoft accounts. They appear first in the profile picker, marked **(Microsoft)**, and
|
||||
connect with their own sessions, skins and secure-chat keys. An account whose session is
|
||||
unavailable appears as **(Offline)**: sign in again in the launcher and restart the game to
|
||||
use it on an online-mode server. Launcher accounts cannot be forgotten from this page.
|
||||
|
||||
`HORIZON_SPLIT=N` remains a testing shortcut to enable it and add N-1 players.
|
||||
The Setup map previews the monitors, windows and player views. Click a player (its name turns
|
||||
yellow) and choose a window or monitor, or drag its tile onto a window, a place among another
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
|
||||
|
||||
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
|
||||
|
||||
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Conduits, the warden's glowing layers and light sources in distant LOD terrain shine too. Portals don't: they're the look of another place, the same in any light. The screen shows these as plain numbers: they are nits onlLine truncated
|
||||
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (referencLine truncated
|
||||
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
|
||||
|
||||
|
||||
+15
-6
@@ -180,6 +180,9 @@ low_memory = false # true: less memory, slightly slower ticks (see bel
|
||||
every = "6h" # 30m, 6h, 1d...; "0" turns automatic backups off
|
||||
keep = 12 # automatic backups kept; backups made by hand are never deleted
|
||||
|
||||
[updates]
|
||||
keep = 1 # other releases kept by updates, besides the one in use and the one rollback goes to
|
||||
|
||||
[panel]
|
||||
bind = "127.0.0.1:8130" # this computer only, or a tailnet address (100.x); "" turns it off
|
||||
allow_public = false
|
||||
@@ -282,14 +285,19 @@ horizon-server ban-ip ADDRESS|NAME [REASON]
|
||||
horizon-server pardon-ip ADDRESS
|
||||
horizon-server backup [NAME] back up now
|
||||
horizon-server backups [--json] list the backups
|
||||
horizon-server restore BACKUP [--yes]
|
||||
horizon-server restore BACKUP [--dimension ID] [--yes]
|
||||
horizon-server service install|uninstall|print [--system]
|
||||
horizon-server panel a one-time login link for the web panel
|
||||
horizon-server import FILE server.toml from a vanilla server.properties
|
||||
horizon-server import-resources DIR copy prepared game files into place (step 2)
|
||||
horizon-server config [--json] the settings in server.toml
|
||||
horizon-server config set KEY=VALUE... change settings, checked first (below)
|
||||
horizon-server update [--check] install the newest release and restart on it (below)
|
||||
horizon-server rollback back to the release before
|
||||
horizon-server config set KEY=VALUE... [--json]
|
||||
change settings, checked first (below)
|
||||
horizon-server update [--check [--json]] [--version TAG] [--timeout SECONDS]
|
||||
install the newest release and restart on it (below);
|
||||
--archive FILE --version NAME installs an archive you have
|
||||
horizon-server rollback [--timeout SECONDS]
|
||||
back to the release used before
|
||||
```
|
||||
|
||||
While the server runs, `op`, `whitelist` and `cmd` go to the game's console, so they act exactly as vanilla's
|
||||
@@ -352,7 +360,7 @@ minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, a
|
||||
|
||||
**Updates** (a server installed with the install script). The server checks for a newer release a minute
|
||||
after it starts and every 6 hours after, and says so in its log, in `status` and in the panel; it never
|
||||
updates by itself. `horizon-server update --check` asks once. `horizon-server update`:
|
||||
updates by itself. `horizon-server update --check` (add `--json` for machine output) asks once. `horizon-server update`:
|
||||
|
||||
1. downloads the current Nightly and checks it against its signed SHA-256;
|
||||
2. stops the game, backs up the world (`backups/<time>-before-<release>.zip`) and switches to the new
|
||||
@@ -527,7 +535,8 @@ and the oldest automatic ones beyond `keep` are deleted. Backups you make by han
|
||||
|
||||
`restore` takes a backup's file name, a unique start of it, or the name you gave it. It moves the current
|
||||
world aside to `backups/replaced-<time>/` (never deleted), puts the backup in place, and restarts the server if
|
||||
it was running. The panel does the same with one click.
|
||||
it was running. Add `--dimension ID` (like `minecraft:the_nether`) to bring back only that dimension and leave
|
||||
the rest of the world as it is. The panel does the same with one click.
|
||||
|
||||
## 8. Start it with the computer
|
||||
|
||||
|
||||
@@ -236,6 +236,16 @@ machine can read it in the process list. Horizon keeps it out of argv:
|
||||
- `--session-stdin` takes the same JSON on standard input instead, so nothing touches the disk (for other
|
||||
launchers: [the Launch API](api/launch.md)).
|
||||
|
||||
|
||||
- While the game's Splitscreen setting is on (`splitscreen:true` in `horizon-extras.txt`), the
|
||||
other signed-in accounts get a session file each too, for splitscreen players to sign in with, listed in `HORIZON_ACCOUNT_SESSIONS` (`[{"name", "uuid", "file"}]`). Each token is renewed
|
||||
first when due; after ten seconds no further renewal starts (an in-flight request finishes).
|
||||
Accounts whose sessions cannot be provided are listed with `skipped` instead
|
||||
of `file`, with no token. They remain in the picker marked “(Offline)”, with a “Sign in again
|
||||
in the launcher” tooltip; valid sessions have a “Microsoft online” tooltip. The game logs
|
||||
unavailable accounts and still starts. These names can play in P1's world or on local offline-mode
|
||||
servers; external and online-mode servers require a valid session from the launcher.
|
||||
|
||||
## Online-mode login (game)
|
||||
|
||||
Vanilla's `ClientHandshakePacketListenerImpl.handleHello`, `Crypt` and authlib's
|
||||
|
||||
+2
-1
@@ -235,4 +235,5 @@ without the pack.
|
||||
|
||||
## Next
|
||||
|
||||
The overlay and lighting stages, and option values other than the defaults.
|
||||
An overlay showing what each pass draws (F3 already shows what each costs), and stages for entities, block entities
|
||||
and particles (the material stage covers terrain only).
|
||||
+257
-17
@@ -18,7 +18,7 @@ use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The keychain service name every secret is stored under.
|
||||
pub const SERVICE: &str = "dev.playhorizon.launcher";
|
||||
@@ -1868,6 +1868,12 @@ pub fn write_session_file(
|
||||
secrets: &Secrets,
|
||||
) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
// The folder too is this user's only (the files already are).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
// Leftovers of games that never started (refused by the game after two minutes anyway).
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for e in entries.flatten() {
|
||||
@@ -1902,6 +1908,72 @@ pub fn write_session_file(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// How long a game takes a session file: it refuses one older than this (the game's
|
||||
/// `MAX_SESSION_FILE_AGE`), so the launcher deletes it then if it is still there.
|
||||
const SESSION_FILE_LIFE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The session files this launcher wrote for games it starts, until they are gone: a game
|
||||
/// deletes each as it reads it; the rest go when the game would refuse them anyway
|
||||
/// ([`SESSION_FILE_LIFE`], a game that never started or crashed first) or when the launch failed
|
||||
/// ([`discard_unread_sessions`]). Nothing with a token stays on disk.
|
||||
static UNREAD: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
/// [`write_session_file`] for a game this launcher is about to start (see [`UNREAD`]).
|
||||
fn write_launch_session_file(
|
||||
dir: &Path,
|
||||
account: &Account,
|
||||
secrets: &Secrets,
|
||||
) -> Result<PathBuf, String> {
|
||||
let path = write_session_file(dir, account, secrets)?;
|
||||
let Ok(mut unread) = UNREAD.lock() else {
|
||||
return Ok(path);
|
||||
};
|
||||
if unread.is_empty() {
|
||||
// One reaper while files wait; it ends with the list (under its lock, so a file added
|
||||
// as it ends starts the next).
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Session files".into())
|
||||
.spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
let Ok(mut unread) = UNREAD.lock() else {
|
||||
return;
|
||||
};
|
||||
reap(&mut unread, SESSION_FILE_LIFE);
|
||||
if unread.is_empty() {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
unread.push(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Deletes the files of `unread` older than `life` (the game would refuse them) and forgets
|
||||
/// those gone (read).
|
||||
fn reap(unread: &mut Vec<PathBuf>, life: Duration) {
|
||||
unread.retain(|path| {
|
||||
let waiting = std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.is_ok_and(|m| m.elapsed().is_ok_and(|age| age <= life));
|
||||
if !waiting {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
waiting
|
||||
});
|
||||
}
|
||||
|
||||
/// Deletes the session files written for a launch whose game didn't start, or has ended: any it
|
||||
/// didn't read is left over (a game deletes those it reads).
|
||||
pub fn discard_unread_sessions() {
|
||||
let files = UNREAD
|
||||
.lock()
|
||||
.map(|mut unread| std::mem::take(&mut *unread))
|
||||
.unwrap_or_default();
|
||||
for path in files {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called under the account-operation lock, after the final profile is known. Rejected or
|
||||
/// cancelled sign-ins must not save a credential, add another identity, or change selection.
|
||||
fn accept_sign_in(
|
||||
@@ -2009,13 +2081,24 @@ impl SignIn {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game about to start gets from the accounts.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GameSession {
|
||||
/// The playing account's session (`--session-file`); none: offline.
|
||||
pub file: Option<PathBuf>,
|
||||
/// Why the account plays offline this time, if it does.
|
||||
pub offline: Option<String>,
|
||||
/// The other accounts' sessions, for splitscreen players (`account_sessions`).
|
||||
pub env: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// The owner gate for the game about to start, then the selected account's session in a file
|
||||
/// for it: the file (none: offline) and why the account plays offline this time, if it does.
|
||||
/// `offline_without_owner`: see [`launch_for_game`].
|
||||
/// for it, and the other accounts' for its splitscreen players. `offline_without_owner`: see
|
||||
/// [`launch_for_game`].
|
||||
pub fn session_for_game(
|
||||
game_home: &Path,
|
||||
offline_without_owner: bool,
|
||||
) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
|
||||
) -> Result<GameSession, NoOwner> {
|
||||
let mut accounts = Accounts::load();
|
||||
let pass = launch_for_game(
|
||||
&Endpoints::live(),
|
||||
@@ -2024,20 +2107,22 @@ pub fn session_for_game(
|
||||
&mut accounts,
|
||||
offline_without_owner,
|
||||
)?;
|
||||
let env = split_sessions(
|
||||
game_home,
|
||||
&mut accounts,
|
||||
pass.session.as_ref().map(|(a, _)| a.uuid.as_str()),
|
||||
);
|
||||
if let Err(e) = accounts.save() {
|
||||
eprintln!("Horizon: the accounts are not saved: {e}");
|
||||
}
|
||||
Ok(session_file(game_home, pass))
|
||||
Ok(session_file(game_home, pass, env))
|
||||
}
|
||||
|
||||
/// [`session_for_game`] for one launch as `who` (`play --account`, a signed-in account): who
|
||||
/// plays changes for this launch only, so several games can play as several accounts at once
|
||||
/// (#4952). What the services renewed (tokens, names, skins) is still kept; which account
|
||||
/// `account select` picked is not touched.
|
||||
pub fn session_for_game_as(
|
||||
game_home: &Path,
|
||||
who: &Player,
|
||||
) -> Result<(Option<PathBuf>, Option<String>), String> {
|
||||
pub fn session_for_game_as(game_home: &Path, who: &Player) -> Result<GameSession, String> {
|
||||
let mut accounts = Accounts::load();
|
||||
let pass = launch_as(
|
||||
&Endpoints::live(),
|
||||
@@ -2049,7 +2134,16 @@ pub fn session_for_game_as(
|
||||
if let Err(e) = accounts.save() {
|
||||
eprintln!("Horizon: the accounts are not saved: {e}");
|
||||
}
|
||||
Ok(session_file(game_home, pass?))
|
||||
let pass = pass?;
|
||||
let env = split_sessions(
|
||||
game_home,
|
||||
&mut accounts,
|
||||
pass.session.as_ref().map(|(a, _)| a.uuid.as_str()),
|
||||
);
|
||||
if let Err(e) = accounts.save() {
|
||||
eprintln!("Horizon: the accounts are not saved: {e}");
|
||||
}
|
||||
Ok(session_file(game_home, pass, env))
|
||||
}
|
||||
|
||||
/// The owner gate as `who`, on `accounts`, which keeps its choice of player.
|
||||
@@ -2076,15 +2170,161 @@ fn launch_as(
|
||||
pass
|
||||
}
|
||||
|
||||
/// The game's session file for a launch that passed the gate, or why it plays offline.
|
||||
fn session_file(game_home: &Path, pass: Launch) -> (Option<PathBuf>, Option<String>) {
|
||||
let Some((account, secrets)) = pass.session else {
|
||||
return (None, pass.offline);
|
||||
/// The game's session files for a launch that passed the gate.
|
||||
fn session_file(game_home: &Path, pass: Launch, env: Vec<(&'static str, String)>) -> GameSession {
|
||||
let mut out = GameSession {
|
||||
env,
|
||||
offline: pass.offline,
|
||||
..GameSession::default()
|
||||
};
|
||||
match write_session_file(&game_home.join("sessions"), &account, &secrets) {
|
||||
Ok(file) => (Some(file), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
if let Some((account, secrets)) = pass.session {
|
||||
match write_launch_session_file(&game_home.join("sessions"), &account, &secrets) {
|
||||
Ok(file) => out.file = Some(file),
|
||||
Err(e) => out.offline = Some(e),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_sessions(
|
||||
game_home: &Path,
|
||||
accounts: &mut Accounts,
|
||||
playing: Option<&str>,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
if splitscreen_on(game_home) {
|
||||
// The launch may override the saved player, or play with an offline name.
|
||||
let selected = std::mem::replace(&mut accounts.selected, playing.map(str::to_owned));
|
||||
let env = account_sessions(
|
||||
&Endpoints::live(),
|
||||
client_id().as_deref(),
|
||||
store(),
|
||||
&game_home.join("sessions"),
|
||||
accounts,
|
||||
);
|
||||
accounts.selected = selected;
|
||||
env
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the game's Splitscreen setting is on (`horizon-extras.txt`, `splitscreen:true`): only
|
||||
/// then do the other accounts' sessions go to it.
|
||||
fn splitscreen_on(game_home: &Path) -> bool {
|
||||
std::fs::read_to_string(game_home.join("horizon-extras.txt"))
|
||||
.is_ok_and(|text| text.lines().any(|l| l.trim() == "splitscreen:true"))
|
||||
}
|
||||
|
||||
/// The signed-in accounts but the one that plays, each its session in a file of its own, for
|
||||
/// the game's splitscreen players to sign in with (`HORIZON_ACCOUNT_SESSIONS`: name, uuid and
|
||||
/// file each). A token known to own the game and not due goes as it is; otherwise it is renewed
|
||||
/// and asked about (`check`), and an account that can't be renewed, doesn't own the game or
|
||||
/// whose token names another profile is left out: the game starts all the same. Nothing with one
|
||||
/// account.
|
||||
/// How long Play spends renewing the other accounts' sessions, in all: past it, the rest are
|
||||
/// offered only with a token that still holds (no request starts; one under way finishes).
|
||||
const OTHERS_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn account_sessions(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
dir: &Path,
|
||||
accounts: &mut Accounts,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
account_sessions_until(
|
||||
ep,
|
||||
client_id,
|
||||
store,
|
||||
dir,
|
||||
accounts,
|
||||
Instant::now() + OTHERS_WAIT,
|
||||
)
|
||||
}
|
||||
|
||||
fn account_sessions_until(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
dir: &Path,
|
||||
accounts: &mut Accounts,
|
||||
deadline: Instant,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
let others: Vec<Account> = accounts
|
||||
.accounts
|
||||
.iter()
|
||||
.filter(|a| Some(&a.uuid) != accounts.selected.as_ref())
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for account in others {
|
||||
let skipped =
|
||||
|reason: &str| json!({ "name": account.name, "uuid": account.uuid, "skipped": reason });
|
||||
// Under the account lock, as every renewal is: a refresh token Microsoft rotates here is
|
||||
// not lost to another launcher renewing the same account at once. Still busy at the
|
||||
// deadline: no request, the token while it holds.
|
||||
let operation = session::lock_within(
|
||||
store,
|
||||
Some(deadline.saturating_duration_since(Instant::now())),
|
||||
);
|
||||
let Some(secrets) = secrets_in(store, &account.uuid) else {
|
||||
list.push(skipped("Sign in again in the launcher."));
|
||||
continue;
|
||||
};
|
||||
let fallback = skipped("Sign in again in the launcher.");
|
||||
let before = serde_json::to_string(&secrets).ok();
|
||||
let uuid = account.uuid.clone();
|
||||
// An account the services sent back to sign-in (a refused refresh, a token now for
|
||||
// another profile) is never handed over, whatever its token's time says.
|
||||
let holds = |s: &Secrets, margin: u64| {
|
||||
s.owns && !s.sign_in_required && s.minecraft_expires_at > now() + margin
|
||||
};
|
||||
let known = holds(&secrets, RENEW_BEFORE);
|
||||
let late = operation.is_err() || Instant::now() >= deadline;
|
||||
let (account, secrets) = match known {
|
||||
true => (Some(account), secrets),
|
||||
// Out of time: its token while it holds, else Sign in again.
|
||||
false if late => (holds(&secrets, 60).then_some(account), secrets),
|
||||
false => match check(ep, client_id, &account, secrets) {
|
||||
Check::Owns(renewed, secrets) => {
|
||||
accounts.update(renewed.clone());
|
||||
(Some(renewed), secrets)
|
||||
}
|
||||
Check::DoesNot(secrets) => (None, secrets),
|
||||
Check::NoAnswer(e, secrets) => {
|
||||
eprintln!("{}'s session for splitscreen: {e}", account.name);
|
||||
// Its token may still hold (no network): offered only while it does. A token
|
||||
// for another profile is marked sign-in required, so it never is.
|
||||
(holds(&secrets, 60).then_some(account), secrets)
|
||||
}
|
||||
},
|
||||
};
|
||||
// A renewed token, or the services' answer, kept for the next launch.
|
||||
let after = serde_json::to_string(&secrets).ok();
|
||||
if let (true, Some(text)) = (after != before, after) {
|
||||
let _ = store.set(&uuid, &text);
|
||||
}
|
||||
drop(operation);
|
||||
let Some(account) = account else {
|
||||
list.push(if secrets.owns { fallback } else {
|
||||
json!({ "name": fallback["name"], "uuid": uuid, "skipped": "This account needs Minecraft: Java Edition ownership confirmed in the launcher." })
|
||||
});
|
||||
continue;
|
||||
};
|
||||
match write_launch_session_file(dir, &account, &secrets) {
|
||||
Ok(file) => {
|
||||
list.push(json!({ "name": account.name, "uuid": account.uuid, "file": file }))
|
||||
}
|
||||
Err(_) => list.push(fallback),
|
||||
}
|
||||
}
|
||||
if list.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![(
|
||||
"HORIZON_ACCOUNT_SESSIONS",
|
||||
serde_json::Value::from(list).to_string(),
|
||||
)]
|
||||
}
|
||||
|
||||
/// Who plays the real Minecraft: Java Edition (a Java instance), past the owner gate itself
|
||||
|
||||
@@ -404,6 +404,150 @@ fn kept(store: &MemoryStore) -> Secrets {
|
||||
secrets_in(store, UUID).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitscreen_gets_the_other_accounts_that_still_hold() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let other = |name: &str, uuid: &str| Account {
|
||||
uuid: uuid.into(),
|
||||
name: name.into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
};
|
||||
let expired = Secrets {
|
||||
minecraft_expires_at: 0,
|
||||
..secrets("old", true)
|
||||
};
|
||||
for (name, uuid, s) in [
|
||||
("Bea", "b", secrets("bea-token", true)),
|
||||
("Cal", "c", expired),
|
||||
("Dan", "d", secrets("dan-token", false)),
|
||||
] {
|
||||
accounts.add(&store, other(name, uuid), &s).unwrap();
|
||||
}
|
||||
accounts.selected = Some(UUID.into());
|
||||
let dir = std::env::temp_dir().join(format!("hz-split-sessions-{}", std::process::id()));
|
||||
// No network: Cal can't be renewed and Dan doesn't own the game; neither stops the launch.
|
||||
let env = account_sessions(&offline(), Some("test-client"), &store, &dir, &mut accounts);
|
||||
let (key, json) = &env[0];
|
||||
assert_eq!(*key, "HORIZON_ACCOUNT_SESSIONS");
|
||||
let list: Vec<Value> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
assert_eq!(list[0]["name"], "Bea");
|
||||
assert_eq!(list[1]["name"], "Cal");
|
||||
assert!(list[1]["skipped"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Sign in again"));
|
||||
assert!(list[1].get("file").is_none());
|
||||
assert_eq!(list[2]["name"], "Dan");
|
||||
assert!(list[2]["skipped"].as_str().unwrap().contains("ownership"));
|
||||
assert!(!json.contains("access_token") && !json.contains("dan-token"));
|
||||
let path = PathBuf::from(list[0]["file"].as_str().unwrap());
|
||||
let file = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(file.contains("bea-token") && !file.contains("mc-token"));
|
||||
// Only the Minecraft token goes: the refresh token stays in the keychain.
|
||||
assert!(!file.contains("ms-refresh-1"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600, "only the player reads it");
|
||||
assert_eq!(mode(&dir), 0o700, "nor lists the folder");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Alone: nothing.
|
||||
let mut alone = signed_in(&store, &secrets("mc-token", true));
|
||||
let env = account_sessions(&offline(), Some("test-client"), &store, &dir, &mut alone);
|
||||
assert!(env.is_empty());
|
||||
// Only for a game whose Splitscreen setting is on.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(!splitscreen_on(&dir));
|
||||
std::fs::write(
|
||||
dir.join("horizon-extras.txt"),
|
||||
"zoom:false\nsplitscreen:false\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!splitscreen_on(&dir));
|
||||
std::fs::write(
|
||||
dir.join("horizon-extras.txt"),
|
||||
"zoom:false\nsplitscreen:true\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(splitscreen_on(&dir));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitscreen_never_hands_over_an_account_sent_back_to_sign_in() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
// A token with hours left, for an account the services sent back to sign-in (a refused
|
||||
// refresh, or a token that now names another profile).
|
||||
let refused = Secrets {
|
||||
sign_in_required: true,
|
||||
..secrets("other-profile-token", true)
|
||||
};
|
||||
let eve = Account {
|
||||
uuid: "e".into(),
|
||||
name: "Eve".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
};
|
||||
accounts.add(&store, eve, &refused).unwrap();
|
||||
accounts.selected = Some(UUID.into());
|
||||
let dir = std::env::temp_dir().join(format!("hz-split-refused-{}", std::process::id()));
|
||||
// With time to renew, and out of time: never offered either way.
|
||||
for deadline in [
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(10),
|
||||
std::time::Instant::now(),
|
||||
] {
|
||||
let env = account_sessions_until(
|
||||
&offline(),
|
||||
Some("test-client"),
|
||||
&store,
|
||||
&dir,
|
||||
&mut accounts,
|
||||
deadline,
|
||||
);
|
||||
let list: Vec<Value> = serde_json::from_str(&env[0].1).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["name"], "Eve");
|
||||
assert!(list[0].get("file").is_none());
|
||||
assert!(list[0]["skipped"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Sign in again"));
|
||||
}
|
||||
let written = std::fs::read_dir(&dir).map_or(0, |d| d.count());
|
||||
assert_eq!(written, 0, "no session file for it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_session_files_go_once_the_game_would_refuse_them() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-session-reap-{}", std::process::id()));
|
||||
let secrets = secrets("not-a-real-token", true);
|
||||
let waiting = write_session_file(&dir, ¬ch(), &secrets).unwrap();
|
||||
let stale = write_session_file(&dir, ¬ch(), &secrets).unwrap();
|
||||
let read = write_session_file(&dir, ¬ch(), &secrets).unwrap();
|
||||
// A game that never started: its file is past the age the game takes.
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&stale)
|
||||
.unwrap()
|
||||
.set_modified(SystemTime::now() - Duration::from_secs(300))
|
||||
.unwrap();
|
||||
// A game that started took its file (and deleted it).
|
||||
std::fs::remove_file(&read).unwrap();
|
||||
let mut unread = vec![waiting.clone(), stale.clone(), read];
|
||||
reap(&mut unread, SESSION_FILE_LIFE);
|
||||
assert!(waiting.exists(), "a game may still take it");
|
||||
assert!(!stale.exists(), "no token left behind");
|
||||
assert_eq!(unread, std::slice::from_ref(&waiting));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_plays_with_a_session() {
|
||||
let (_, ep) = mock(|_| {});
|
||||
@@ -2099,6 +2243,53 @@ fn a_launch_as_another_account_leaves_the_selected_one() {
|
||||
assert_eq!(accounts.player(), Player::Microsoft(UUID.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitscreen_stops_renewing_other_accounts_once_out_of_time() {
|
||||
// A sign-in service that never answers: any request would hang the test.
|
||||
let silent = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/x", silent.local_addr().unwrap());
|
||||
let ep = Endpoints {
|
||||
microsoft: url.clone(),
|
||||
xbox_user: url.clone(),
|
||||
xsts: url.clone(),
|
||||
minecraft: url,
|
||||
};
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let expired = Secrets {
|
||||
minecraft_expires_at: 0,
|
||||
..secrets("old", true)
|
||||
};
|
||||
let cal = Account {
|
||||
uuid: "c".into(),
|
||||
name: "Cal".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
};
|
||||
accounts.add(&store, cal, &expired).unwrap();
|
||||
accounts.selected = Some(UUID.into());
|
||||
let dir = std::env::temp_dir().join(format!("hz-split-late-{}", std::process::id()));
|
||||
let start = std::time::Instant::now();
|
||||
let env = account_sessions_until(
|
||||
&ep,
|
||||
Some("test-client"),
|
||||
&store,
|
||||
&dir,
|
||||
&mut accounts,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
assert!(
|
||||
start.elapsed() < std::time::Duration::from_secs(2),
|
||||
"no request started"
|
||||
);
|
||||
let list: Vec<Value> = serde_json::from_str(&env[0].1).unwrap();
|
||||
assert!(list[0]["skipped"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Sign in again"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A service's account check: the launcher renews the account, then tells Mojang's session server
|
||||
/// it joins the id the service gave.
|
||||
#[test]
|
||||
|
||||
+10
-5
@@ -2275,6 +2275,7 @@ pub fn client_command(tag: &str) -> Command {
|
||||
let mut cmd = Command::new(client_program(tag));
|
||||
strip_launcher_render_env(&mut cmd);
|
||||
cmd.current_dir(home).arg("--resources").arg(resources(tag));
|
||||
cmd.env_remove("HORIZON_ACCOUNT_SESSIONS");
|
||||
// How the game asks for a renewed session (`account session`) while it runs.
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
cmd.env("HORIZON_LAUNCHER_EXE", exe);
|
||||
@@ -2466,19 +2467,23 @@ pub fn client(tag: &str) -> Result<Command, String> {
|
||||
|
||||
/// [`client`], playing as `who` for this launch only (`play --account`).
|
||||
pub fn client_as(tag: &str, who: Option<&account::Player>) -> Result<Command, String> {
|
||||
let (session, offline) = match who {
|
||||
let session = match who {
|
||||
None => account::session_for_game(&game_home(), false).map_err(|e| e.to_string()),
|
||||
Some(who) => account::session_for_game_as(&game_home(), who),
|
||||
}
|
||||
.map_err(|refused| format!("{refused} Sign in with `horizon-launcher account sign-in`."))?;
|
||||
need_prepared(tag)?;
|
||||
if let Err(e) = need_prepared(tag) {
|
||||
account::discard_unread_sessions();
|
||||
return Err(e);
|
||||
}
|
||||
complete_assets(tag);
|
||||
crate::browser::horizon::before_play(&game_home());
|
||||
let mut cmd = client_command(tag);
|
||||
if let Some(file) = session {
|
||||
if let Some(file) = session.file {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
}
|
||||
if let Some(e) = offline {
|
||||
cmd.envs(session.env);
|
||||
if let Some(e) = session.offline {
|
||||
eprintln!("Playing offline: {e}");
|
||||
}
|
||||
if let Some(e) = account::take_unsaved() {
|
||||
@@ -3150,7 +3155,7 @@ pub enum Msg {
|
||||
AccountRemoved(String, u64, Result<bool, String>),
|
||||
/// The owner gate's answer for the game about to start: the selected account's session
|
||||
/// file (none = offline) and why it plays offline, if it does.
|
||||
Session(Result<(Option<PathBuf>, Option<String>), crate::account::NoOwner>),
|
||||
Session(Result<crate::account::GameSession, crate::account::NoOwner>),
|
||||
/// A modpack step's line (`modpacks`).
|
||||
PackLine(String),
|
||||
/// A modpack step ended: the pack as it now is.
|
||||
|
||||
+7
-1
@@ -1126,7 +1126,13 @@ pub fn run() -> Option<i32> {
|
||||
let result = match command {
|
||||
Cmd::Mcp | Cmd::AgentConfig { .. } if no_ai => Err(horizon_core::no_ai::REFUSAL.into()),
|
||||
Cmd::Mcp => crate::mcp::serve(),
|
||||
command => execute(command, cli.json),
|
||||
command => {
|
||||
let result = execute(command, cli.json);
|
||||
// A game it started has ended (attached) or none did (an error): a session file it
|
||||
// didn't read goes now.
|
||||
account::discard_unread_sessions();
|
||||
result
|
||||
}
|
||||
};
|
||||
Some(match result {
|
||||
Ok(()) => 0,
|
||||
|
||||
+12
-2
@@ -1096,7 +1096,7 @@ impl Launcher {
|
||||
self.load_auth_statuses(false);
|
||||
if let Some(tag) = self.starting_client.take() {
|
||||
let mut cmd = backend::client_command(&tag);
|
||||
let (file, offline) = match session {
|
||||
let account::GameSession { file, offline, env } = match session {
|
||||
Ok(pass) => pass,
|
||||
Err(account::NoOwner) => {
|
||||
self.owner = Some(false);
|
||||
@@ -1115,6 +1115,7 @@ impl Launcher {
|
||||
if let Some(file) = file {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
}
|
||||
cmd.envs(env);
|
||||
// Still play, offline, and say why.
|
||||
if let Some(e) = offline {
|
||||
self.client_error = Some(format!("Playing offline: {e}"));
|
||||
@@ -1145,6 +1146,7 @@ impl Launcher {
|
||||
};
|
||||
if let Err(e) = profile {
|
||||
self.client_error = Some(e);
|
||||
account::discard_unread_sessions();
|
||||
continue;
|
||||
}
|
||||
if let Some(code) = self.joining.take() {
|
||||
@@ -1176,8 +1178,14 @@ impl Launcher {
|
||||
self.client_run = run.map(|id| (id, Instant::now()));
|
||||
self.step_aside(cx);
|
||||
}
|
||||
Err(e) => self.client_error = Some(e),
|
||||
Err(e) => {
|
||||
self.client_error = Some(e);
|
||||
account::discard_unread_sessions();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The start was called off: the sessions written for it go.
|
||||
account::discard_unread_sessions();
|
||||
}
|
||||
}
|
||||
Msg::Progress(tag, p) => {
|
||||
@@ -1351,6 +1359,8 @@ impl Launcher {
|
||||
}
|
||||
Job::Client => {
|
||||
self.client = None;
|
||||
// Any session file the game didn't read (it crashed first).
|
||||
account::discard_unread_sessions();
|
||||
self.come_back(cx);
|
||||
// The game saved: last played, sizes and pictures changed.
|
||||
self.worlds.stale();
|
||||
|
||||
+11
-2
@@ -159,10 +159,19 @@ class PrReadyTests(unittest.TestCase):
|
||||
"cargo": "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"}
|
||||
for name, script in scripts.items():
|
||||
path = os.path.join(fake_bin, name)
|
||||
with open(path, "w") as file:
|
||||
# LF on every host: text mode makes the shebang "#!/bin/sh\r" on
|
||||
# Windows, which bash cannot run, so the fake names are skipped.
|
||||
with open(path, "w", newline="\n") as file:
|
||||
file.write(script)
|
||||
os.chmod(path, 0o755)
|
||||
env = dict(os.environ, PATH=fake_bin + os.pathsep + os.environ["PATH"])
|
||||
env = dict(os.environ)
|
||||
if os.name == "nt":
|
||||
# Git Bash puts /usr/bin ahead of every Windows PATH entry, so a fake
|
||||
# named uname can only win when the shell itself puts its folder first.
|
||||
command = 'export PATH="$(cygpath -u "$FAKE_BIN"):$PATH"\n' + command
|
||||
env["FAKE_BIN"] = fake_bin
|
||||
else:
|
||||
env["PATH"] = fake_bin + os.pathsep + os.environ["PATH"]
|
||||
return subprocess.run([BASH, "-c", command], capture_output=True, text=True,
|
||||
cwd=pr_ready.ROOT, env=env, check=True).stdout.splitlines()
|
||||
|
||||
|
||||
@@ -1203,6 +1203,39 @@ def scenario_native_pointer():
|
||||
g.quit()
|
||||
|
||||
|
||||
def scenario_launcher():
|
||||
print("launcher: its other accounts are profiles; offline ones stay in this instance")
|
||||
folder = Path(tempfile.mkdtemp(prefix='hz-launcher-'))
|
||||
# Another signed-in account's session, as the launcher writes it (a token nobody checks:
|
||||
# P1's own world is offline-mode).
|
||||
session = folder / 'session-ann.json'
|
||||
session.write_text(json.dumps({'name': 'Ann', 'uuid': '069a79f444e94726a5befca90e38aaf5',
|
||||
'access_token': 'not-a-real-token', 'user_type': 'msa'}))
|
||||
session.chmod(0o600)
|
||||
os.environ['HORIZON_ACCOUNT_SESSIONS'] = json.dumps(
|
||||
[{'name': 'Ann', 'uuid': '069a79f444e94726a5befca90e38aaf5', 'file': str(session)}, {'name': 'Cal', 'uuid': '00000000000000000000000000000001', 'skipped': 'Sign in again'}])
|
||||
g = Game(3, pick=True, seed=lambda home: (home / 'splitscreen/profiles/Bea').mkdir(parents=True))
|
||||
try:
|
||||
menu = lambda n: g.ctl(n, 'state').get('menu') or {} # noqa: E731
|
||||
g.wait(lambda: menu(2).get('mode') == 'splitscreen-profiles', 'the picker')
|
||||
check('the accounts, then the offline profiles', menu(2).get('profiles'), ['Ann', 'Cal', 'Bea'])
|
||||
check('the account marked', 'Ann (Microsoft)' in g.labels(2), True)
|
||||
check('unavailable account marked offline', 'Cal (Offline)' in g.labels(2), True)
|
||||
check('unavailable account cannot be forgotten', 'Forget Cal' in g.labels(2), False)
|
||||
g.ctl(2, 'click-button', 'Ann (Microsoft)')
|
||||
g.wait(lambda: g.ctl(2, 'state').get('player') is not None, 'Ann in the world')
|
||||
g.wait(lambda: menu(3).get('mode') == 'splitscreen-profiles', "P3's picker")
|
||||
g.ctl(3, 'click-button', 'New Profile...')
|
||||
g.ctl(3, 'type', 'Dan')
|
||||
g.ctl(3, 'click-button', 'Create')
|
||||
g.wait(lambda: g.ctl(3, 'state').get('player') is not None, 'Dan in the world')
|
||||
check('a new offline profile is kept in the instance',
|
||||
(g.home / 'splitscreen/profiles/Dan').is_dir(), True)
|
||||
finally:
|
||||
g.quit()
|
||||
os.environ.pop('HORIZON_ACCOUNT_SESSIONS', None)
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
'alone': scenario_alone,
|
||||
'join': scenario_join,
|
||||
@@ -1233,6 +1266,7 @@ SCENARIOS = {
|
||||
'packs': scenario_packs,
|
||||
'dedicated_profiles': scenario_dedicated_profiles,
|
||||
'integrated_profiles': scenario_integrated_profiles,
|
||||
'launcher': scenario_launcher,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A dedicated server saves connected players on save-all/stop and keeps safe data after a panic.
|
||||
"""A dedicated server saves connected players on save-all/stop/SIGTERM and keeps safe data after a panic.
|
||||
|
||||
`MinecraftServer.runServer` catches what the tick throws and still stops the server. Horizon's
|
||||
`stop_after_crash` does the same, but a panic can interrupt a guest's turn (the game holds that
|
||||
@@ -17,6 +17,7 @@ checks, from the files the server leaves:
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -157,6 +158,49 @@ def connected_saves(build):
|
||||
print('connected player save-all and stop: ok', flush=True)
|
||||
|
||||
|
||||
def signal_saves(build):
|
||||
"""SIGTERM stops the server as `stop` does (vanilla's shutdown hook): a connected player's
|
||||
inventory and ender chest changed since the last save are in their file afterwards."""
|
||||
try:
|
||||
from . import anvil_dump, lan_e2e
|
||||
except ImportError:
|
||||
import anvil_dump
|
||||
import lan_e2e
|
||||
directory = build / 'signal-server'
|
||||
port = free_port()
|
||||
write_server_dir(directory, port)
|
||||
server = start(directory)
|
||||
client = lan_e2e.Game('sigterm', binary().with_name('horizon-ctl'), lab.RESOURCES)
|
||||
file = directory / 'world' / 'players' / 'data' / f'{offline_uuid(PLAYER)}.dat'
|
||||
try:
|
||||
client.raw('launch', '--headless', '--game', binary())
|
||||
client.raw('connect', f'127.0.0.1:{port}', PLAYER)
|
||||
client.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
server.wait_for(rf'{PLAYER} joined the game')
|
||||
server.console(f'give {PLAYER} minecraft:diamond 9')
|
||||
server.console(f'item replace entity {PLAYER} enderchest.0 with minecraft:emerald 5')
|
||||
server.console('say GIVEN-BEFORE-SIGTERM')
|
||||
server.wait_for(r'\[Server\] GIVEN-BEFORE-SIGTERM')
|
||||
assert not file.exists(), 'the player was saved before the signal'
|
||||
server.process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
server.process.wait(timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise AssertionError('the server did not stop within 120 s of SIGTERM')
|
||||
server.reader.join(timeout=10)
|
||||
assert any('Saving players' in line for line in server.lines), \
|
||||
'SIGTERM ended the server without saving:\n' + '\n'.join(server.lines[-10:])
|
||||
assert file.exists(), 'SIGTERM ended the server without writing the player file'
|
||||
player = anvil_dump.plain(anvil_dump.read_dat(file), full=True)
|
||||
diamonds = sum(i['count'] for i in player['Inventory'] if i['id'] == 'minecraft:diamond')
|
||||
emeralds = sum(i['count'] for i in player.get('EnderItems', []) if i['id'] == 'minecraft:emerald')
|
||||
assert (diamonds, emeralds) == (9, 5), f'saved {diamonds} diamonds and {emeralds} ender chest emeralds, not 9 and 5'
|
||||
finally:
|
||||
client.close()
|
||||
server.stop()
|
||||
print('connected player saved on SIGTERM: ok', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bin', 'horizon-client', '--bin', 'horizon-ctl'], cwd=lab.ROOT, check=True)
|
||||
build = lab.LOCAL / 'dedicated-crash-save'
|
||||
@@ -169,6 +213,7 @@ def main():
|
||||
out.mkdir()
|
||||
problems = []
|
||||
connected_saves(build)
|
||||
signal_saves(build)
|
||||
|
||||
# A clean stop writes the player's file.
|
||||
server = start(directory)
|
||||
|
||||
Reference in new issue
Block a user