mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #6912
game.rs: main removed the in-frame player tick (#23f8bd4fe), so its stat calls go with it. trading.rs: main's offer-based merchant_traded, with award_custom_stat. hub.rs: both new fields. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
efe72ef985
543 files changed
+36170
-8056
No files matched your search
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
animation) must be reached from `game/remote*.rs`.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ name: launcher
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -26,6 +28,7 @@ on:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mcp/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
@@ -240,6 +243,18 @@ jobs:
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
|
||||
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
setup="$RUNNER_TEMP/setup-smoke"
|
||||
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
|
||||
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
|
||||
installed="$setup/Horizon Launcher/horizon-launcher.exe"
|
||||
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
|
||||
"$installed" uninstall --quiet
|
||||
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
|
||||
rm -rf "$setup"
|
||||
fi
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
@@ -275,6 +290,12 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- name: Setup exe (Windows)
|
||||
# The launcher's binary under another name: started with no arguments it shows the per-user
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
|
||||
@@ -147,7 +147,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
@@ -178,28 +178,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
|
||||
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
## Single player runs on a server thread
|
||||
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
|
||||
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
|
||||
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
|
||||
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
Generated
+9
@@ -1399,6 +1399,7 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mcp",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
@@ -1543,6 +1544,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -1647,6 +1655,7 @@ dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-mcp",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"libc",
|
||||
|
||||
@@ -17,6 +17,8 @@ members = [
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
|
||||
"crates/horizon-mcp",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
+4
-13
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -115,3 +115,35 @@ licensed under the MIT license:
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## BlueMap
|
||||
|
||||
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
|
||||
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
|
||||
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
|
||||
`settings.json`, at commit
|
||||
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
|
||||
BlueMap is licensed under the MIT license:
|
||||
|
||||
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
|
||||
> Copyright (c) contributors
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
> of this software and associated documentation files (the "Software"), to deal
|
||||
> in the Software without restriction, including without limitation the rights
|
||||
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
> copies of the Software, and to permit persons to whom the Software is
|
||||
> furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in
|
||||
> all copies or substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
> THE SOFTWARE.
|
||||
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
Some("custom") => Some(crate::text::ClickEvent::Custom {
|
||||
id: crate::identifier::parse_identifier(&text("id")?)?,
|
||||
payload: e
|
||||
.get("payload")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(crate::scoreboard::json_to_tag),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -585,6 +592,63 @@ mod tests {
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
|
||||
use crate::text::ClickEvent;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
for (payload, expected) in [
|
||||
(None, None),
|
||||
(Some(serde_json::Value::Null), None),
|
||||
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
|
||||
(
|
||||
Some(serde_json::json!({"shop": "potions", "amount": 3})),
|
||||
Some(Tag::Compound(
|
||||
[
|
||||
("amount".into(), Tag::Int(3)),
|
||||
("shop".into(), Tag::String("potions".into())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = payload;
|
||||
}
|
||||
let component = serde_json::json!({
|
||||
"text": "", "click_event": event,
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
for (_, style) in text.chars() {
|
||||
assert_eq!(
|
||||
style.click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: expected.clone(),
|
||||
})
|
||||
);
|
||||
}
|
||||
let encoded = text.component();
|
||||
let decoded = to_text(&encoded, &mut |_| Text::empty());
|
||||
assert_eq!(decoded, text);
|
||||
if expected.is_none() {
|
||||
assert!(!encoded.to_string().contains("\"payload\""));
|
||||
}
|
||||
}
|
||||
for id in ["Bad ID", "Uppercase:shop"] {
|
||||
let text = to_text(
|
||||
&serde_json::json!({
|
||||
"text": "Shop", "click_event": {"action": "custom", "id": id}
|
||||
}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
assert_eq!(text.spans[0].style.click, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
|
||||
b("universal_anger", "mobs", false),
|
||||
b("water_source_conversion", "updates", true),
|
||||
];
|
||||
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
|
||||
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
|
||||
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
|
||||
|
||||
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
|
||||
RULES
|
||||
.iter()
|
||||
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
|
||||
}
|
||||
|
||||
pub fn rule(name: &str) -> Option<&'static Rule> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
RULES.iter().find(|r| r.name == name)
|
||||
available_rules(true).find(|r| r.name == name)
|
||||
}
|
||||
|
||||
/// Current values (defaults unless set).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GameRules {
|
||||
values: BTreeMap<&'static str, Value>,
|
||||
minecart_improvements: bool,
|
||||
}
|
||||
impl GameRules {
|
||||
pub fn with_features(features: &[String]) -> Self {
|
||||
Self {
|
||||
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
|
||||
/// newly enabled rules and discard rules absent from the target feature set.
|
||||
pub fn copy_with_features(&self, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
for (&name, &value) in &self.values {
|
||||
rules.set(name, value);
|
||||
}
|
||||
rules
|
||||
}
|
||||
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
|
||||
available_rules(self.minecart_improvements)
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<Value> {
|
||||
let r = rule(name)?;
|
||||
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
|
||||
return None;
|
||||
}
|
||||
Some(self.values.get(r.name).copied().unwrap_or(r.default))
|
||||
}
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
@@ -160,7 +192,9 @@ impl GameRules {
|
||||
}
|
||||
/// Set a rule; false if the name or value type is wrong.
|
||||
pub fn set(&mut self, name: &str, value: Value) -> bool {
|
||||
let Some(r) = rule(name) else { return false };
|
||||
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
|
||||
return false;
|
||||
};
|
||||
let ok = match (r.default, value) {
|
||||
(Value::Bool(_), Value::Bool(_)) => true,
|
||||
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
|
||||
@@ -176,8 +210,7 @@ impl GameRules {
|
||||
/// Every rule's value by name without namespace, as `save::Level` holds
|
||||
/// them (booleans as 0 or 1).
|
||||
pub fn by_name(&self) -> BTreeMap<String, i32> {
|
||||
RULES
|
||||
.iter()
|
||||
self.available_rules()
|
||||
.map(|r| {
|
||||
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
@@ -189,8 +222,8 @@ impl GameRules {
|
||||
}
|
||||
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
|
||||
pub fn to_nbt(&self) -> NamedTag {
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
|
||||
.iter()
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
|
||||
.available_rules()
|
||||
.map(|r| {
|
||||
let v = self.values.get(r.name).copied().unwrap_or(r.default);
|
||||
let tag = match v {
|
||||
@@ -212,11 +245,14 @@ impl GameRules {
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(root: &Tag) -> Self {
|
||||
let mut rules = Self::default();
|
||||
Self::from_nbt_with_features(root, &[])
|
||||
}
|
||||
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
let Some(data) = root.get("data") else {
|
||||
return rules;
|
||||
};
|
||||
for r in RULES {
|
||||
for r in rules.available_rules() {
|
||||
let key = format!("minecraft:{}", r.name);
|
||||
let value = data.get(&key).or_else(|| data.get(r.name));
|
||||
match (r.default, value) {
|
||||
@@ -232,15 +268,18 @@ impl GameRules {
|
||||
rules
|
||||
}
|
||||
pub fn load(world: &Path) -> Self {
|
||||
Self::load_with_features(world, &[])
|
||||
}
|
||||
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
|
||||
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
|
||||
return Self::default();
|
||||
return Self::with_features(features);
|
||||
};
|
||||
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
|
||||
return Self::default();
|
||||
return Self::with_features(features);
|
||||
};
|
||||
match nbt::read_named(&raw, Limits::default()) {
|
||||
Ok((root, _)) => Self::from_nbt(&root.tag),
|
||||
Err(_) => Self::default(),
|
||||
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
|
||||
Err(_) => Self::with_features(features),
|
||||
}
|
||||
}
|
||||
pub fn save(&self, world: &Path) -> crate::Result<()> {
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
|
||||
}
|
||||
|
||||
/// Click actions carried by locally constructed text components.
|
||||
/// Click actions carried by text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
@@ -53,6 +53,11 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<horizon_world::nbt::Tag>,
|
||||
},
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -539,7 +544,7 @@ const STYLE_KEYS: [&str; 11] = [
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
/// `Style.Serializer`: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = style.color {
|
||||
@@ -581,6 +586,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::Custom { id, payload } => {
|
||||
let mut event = json!({ "action": "custom", "id": id });
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = crate::scoreboard::tag_to_json(payload);
|
||||
}
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
|
||||
horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
|
||||
@@ -105,6 +105,8 @@ pub struct RenderAssets {
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -224,7 +226,7 @@ impl RenderAssets {
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
let mut models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
@@ -244,6 +246,11 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
@@ -304,6 +311,7 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
|
||||
}
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
|
||||
&path,
|
||||
)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
@@ -277,21 +278,22 @@ impl Biomes {
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
|
||||
// from the prepared data file when there is one.
|
||||
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
|
||||
let mut files: Vec<_> = folder
|
||||
.files
|
||||
.iter()
|
||||
.filter(|f| !f.name.contains('/'))
|
||||
.collect();
|
||||
names.sort();
|
||||
for path in names {
|
||||
let name = format!(
|
||||
"minecraft:{}",
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
files.sort_by_cached_key(|f| format!("{}.json", f.name));
|
||||
for file in files {
|
||||
let v: Value = serde_json::from_slice(file.bytes()?)?;
|
||||
list.push(BiomeInfo::from_json(
|
||||
format!("minecraft:{}", file.name),
|
||||
&v,
|
||||
&maps,
|
||||
));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,452 @@
|
||||
//! Finding a server's BlueMap without the player naming it
|
||||
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
|
||||
//! likely web addresses (`candidates`), taking only one whose `settings.json`
|
||||
//! is BlueMap's web-app settings, and the answer is kept per server in
|
||||
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
|
||||
//! A `blueMap:` line the player wrote always wins.
|
||||
use crate::bluemap::Settings;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// The answer kept per server, beside its BlueMap tiles.
|
||||
pub const FILE: &str = "detected.txt";
|
||||
/// A server found without a map is asked again after this long.
|
||||
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
|
||||
/// A map found is checked again (its address first) after this long.
|
||||
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
/// Each probe gets this long, connecting included.
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Probes start this far apart at least.
|
||||
pub const GAP: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Where a level's BlueMap comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The player's `blueMap:` line, or a map found before.
|
||||
Url {
|
||||
url: String,
|
||||
maps: Vec<(String, String)>,
|
||||
},
|
||||
/// To be looked for at these addresses, in order.
|
||||
Detect(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the server at `address` draws far terrain from, its BlueMap folder
|
||||
/// being `dir`: `None` when the player named no map and either auto-detect
|
||||
/// is off or the server was found without one recently.
|
||||
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
|
||||
if let Some(server) = settings.server(address) {
|
||||
return Some(Source::Url {
|
||||
url: server.url.clone(),
|
||||
maps: server.maps.clone(),
|
||||
});
|
||||
}
|
||||
if !settings.auto_detect {
|
||||
return None;
|
||||
}
|
||||
let mut probes = Vec::new();
|
||||
// Only an address this server's would be looked for at: a remembered
|
||||
// one from elsewhere (a copied folder) names someone else's.
|
||||
let ours = candidates(address);
|
||||
match remembered(dir, now) {
|
||||
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
|
||||
Remembered::Found(url) => {
|
||||
return Some(Source::Url {
|
||||
url,
|
||||
maps: Vec::new(),
|
||||
});
|
||||
}
|
||||
Remembered::Nothing => return None,
|
||||
Remembered::Stale(Some(url)) => probes.push(url),
|
||||
Remembered::Stale(None) => {}
|
||||
}
|
||||
for candidate in ours {
|
||||
if !probes.contains(&candidate) {
|
||||
probes.push(candidate);
|
||||
}
|
||||
}
|
||||
(!probes.is_empty()).then_some(Source::Detect(probes))
|
||||
}
|
||||
|
||||
/// The first labels a game server's host name takes beside its site's.
|
||||
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
|
||||
|
||||
/// Domains whose names belong to many customers (dynamic DNS, server
|
||||
/// hosts): `map.` of them is someone else's.
|
||||
const SHARED: &[&str] = &[
|
||||
"aternos.me",
|
||||
"ddns.net",
|
||||
"duckdns.org",
|
||||
"hopto.org",
|
||||
"minehut.gg",
|
||||
"no-ip.org",
|
||||
"playit.gg",
|
||||
"serveminecraft.net",
|
||||
"zapto.org",
|
||||
];
|
||||
|
||||
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
|
||||
/// web server (port 8100), the server's host, and its `map.` subdomain, also
|
||||
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
|
||||
pub fn candidates(address: &str) -> Vec<String> {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let host = if let Some(rest) = address.strip_prefix('[') {
|
||||
rest.split(']').next().unwrap_or(rest)
|
||||
} else if address.matches(':').count() > 1 {
|
||||
&address[..]
|
||||
} else {
|
||||
address.split(':').next().unwrap_or(&address)
|
||||
};
|
||||
let host = host.trim_end_matches('.');
|
||||
if host.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
// Certificates are for names: plain HTTP only.
|
||||
let host = match ip {
|
||||
IpAddr::V6(_) => format!("[{host}]"),
|
||||
IpAddr::V4(_) => host.to_owned(),
|
||||
};
|
||||
out.push(format!("http://{host}:8100"));
|
||||
out.push(format!("http://{host}"));
|
||||
return out;
|
||||
}
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
out.push(format!("http://{host}:8100"));
|
||||
let labels = host.split('.').count();
|
||||
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
}
|
||||
out.push(format!("https://{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
out.push(format!("http://{host}"));
|
||||
// `play.example.net` → `map.example.net`, only for a game server's
|
||||
// usual names: the parent of `name.hosting.example` is another party's.
|
||||
if labels >= 3
|
||||
&& let Some((first, parent)) = host.split_once('.')
|
||||
&& GAME_LABELS.contains(&first)
|
||||
&& !SHARED.contains(&parent)
|
||||
&& let Some(map) = map(parent)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
out.retain(|url| {
|
||||
let new = !seen.contains(url);
|
||||
seen.push(url.clone());
|
||||
new
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
|
||||
/// and where their data lies, not any page a web server returns.
|
||||
pub fn is_web_app_settings(json: &[u8]) -> bool {
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
|
||||
return false;
|
||||
};
|
||||
let maps = v.get("maps").and_then(|m| m.as_array());
|
||||
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
|
||||
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
|
||||
}
|
||||
|
||||
/// Whether a BlueMap web app answers at `base`.
|
||||
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(response) = agent
|
||||
.get(&format!("{base}/settings.json"))
|
||||
.timeout(TIMEOUT)
|
||||
.call()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(256 << 10)
|
||||
.read_to_end(&mut body)
|
||||
.is_ok()
|
||||
&& is_web_app_settings(&body)
|
||||
}
|
||||
|
||||
/// What an earlier look found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Remembered {
|
||||
Found(String),
|
||||
/// Nothing, recently.
|
||||
Nothing,
|
||||
/// Never looked, or long enough ago to look again (first at the map
|
||||
/// found then, if any).
|
||||
Stale(Option<String>),
|
||||
}
|
||||
|
||||
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
|
||||
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
let mut words = text.split_whitespace();
|
||||
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
|
||||
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
|
||||
let Ok(age) = now.duration_since(at) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
match (kind, words.next()) {
|
||||
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
|
||||
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
|
||||
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
|
||||
_ => Remembered::Stale(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps what a look found (`None`: no map).
|
||||
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
|
||||
let secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let text = match found {
|
||||
Some(url) => format!("found {secs} {url}\n"),
|
||||
None => format!("none {secs}\n"),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = std::fs::write(dir.join(FILE), text);
|
||||
}
|
||||
|
||||
/// The servers being looked at now, by BlueMap folder: one look a server at
|
||||
/// a time, however many levels open meanwhile.
|
||||
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The right to look for `dir`'s server's map, until dropped.
|
||||
pub struct Claim(PathBuf);
|
||||
impl Claim {
|
||||
/// `None` while another look at the same server runs.
|
||||
pub fn take(dir: &Path) -> Option<Self> {
|
||||
let mut looking = LOOKING.lock().unwrap();
|
||||
if looking.iter().any(|d| d == dir) {
|
||||
return None;
|
||||
}
|
||||
looking.push(dir.to_owned());
|
||||
Some(Self(dir.to_owned()))
|
||||
}
|
||||
}
|
||||
impl Drop for Claim {
|
||||
fn drop(&mut self) {
|
||||
LOOKING.lock().unwrap().retain(|d| *d != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn candidates_cover_the_web_server_host_and_map_subdomains() {
|
||||
assert_eq!(
|
||||
candidates("Play.Example.net:25566"),
|
||||
[
|
||||
"http://play.example.net:8100",
|
||||
"https://map.play.example.net",
|
||||
"https://play.example.net",
|
||||
"http://map.play.example.net",
|
||||
"http://play.example.net",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("example.net"),
|
||||
[
|
||||
"http://example.net:8100",
|
||||
"https://map.example.net",
|
||||
"https://example.net",
|
||||
"http://map.example.net",
|
||||
"http://example.net",
|
||||
]
|
||||
);
|
||||
// Already the map's host: no `map.map.`.
|
||||
assert_eq!(
|
||||
candidates("map.example.net"),
|
||||
[
|
||||
"http://map.example.net:8100",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("192.0.2.7:25565"),
|
||||
["http://192.0.2.7:8100", "http://192.0.2.7"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("[2001:db8::7]:25565"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("2001:db8::7"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("localhost"),
|
||||
[
|
||||
"http://localhost:8100",
|
||||
"https://localhost",
|
||||
"http://localhost"
|
||||
]
|
||||
);
|
||||
// A hosted name's parent is the host's, not the server's.
|
||||
assert!(
|
||||
!candidates("play.ddns.net")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.ddns.net"))
|
||||
);
|
||||
assert!(
|
||||
!candidates("name.host.example")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.host.example"))
|
||||
);
|
||||
assert!(candidates("").is_empty());
|
||||
assert!(candidates("evil.example.net/path?x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_blue_maps_settings_count() {
|
||||
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
|
||||
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
|
||||
assert!(is_web_app_settings(web_app));
|
||||
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
|
||||
assert!(
|
||||
!is_web_app_settings(br#"{"maps":["world"]}"#),
|
||||
"no data root"
|
||||
);
|
||||
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":[1],"mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":"world","mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
|
||||
));
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-bluemap-detect-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_are_kept_and_looked_at_again_later() {
|
||||
let dir = scratch("remember");
|
||||
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let hour = Duration::from_secs(3600);
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
remember(&dir, None, t0);
|
||||
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
|
||||
// Written ahead of the clock: not trusted.
|
||||
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
|
||||
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
|
||||
remember(&dir, Some("https://map.example.net"), t0);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + 2 * RETRY_NONE),
|
||||
Remembered::Found("https://map.example.net".into())
|
||||
);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + RETRY_FOUND),
|
||||
Remembered::Stale(Some("https://map.example.net".into()))
|
||||
);
|
||||
std::fs::write(dir.join(FILE), "garbage").unwrap();
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_players_map_wins_and_detection_waits_for_the_setting() {
|
||||
let dir = scratch("source");
|
||||
let now = SystemTime::now();
|
||||
let mut settings = Settings::default();
|
||||
let address = "play.example.net";
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"off by default"
|
||||
);
|
||||
settings.auto_detect = true;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, None, now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"none, recently"
|
||||
);
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "https://map.example.net".into(),
|
||||
maps: Vec::new()
|
||||
})
|
||||
);
|
||||
// A remembered map this server's addresses don't include (a copied
|
||||
// folder's) is no map.
|
||||
remember(&dir, Some("https://tracker.example"), now);
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for, the remembered one ignored");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
// Past a week the found map is tried first, then the rest.
|
||||
let later = now + RETRY_FOUND;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
|
||||
panic!("looked for again");
|
||||
};
|
||||
assert_eq!(probes[0], "https://map.example.net");
|
||||
assert_eq!(probes.len(), candidates(address).len());
|
||||
settings.servers.push(
|
||||
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "http://example.net:8123".into(),
|
||||
maps: vec![("minecraft:the_nether".into(), "n".into())]
|
||||
})
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_look_a_server_at_a_time() {
|
||||
let dir = scratch("claim");
|
||||
let claim = Claim::take(&dir).unwrap();
|
||||
assert!(Claim::take(&dir).is_none());
|
||||
drop(claim);
|
||||
assert!(Claim::take(&dir).is_some());
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
quad: ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
|
||||
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// A server's own chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
|
||||
@@ -211,6 +211,36 @@ impl SceneCollisions<'_, '_> {
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
|
||||
}
|
||||
/// [`Self::resolve`], also stopped by the world border's walls when one is
|
||||
/// given with the moving entity's x and z: `Entity.collectColliders` adds
|
||||
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
|
||||
pub fn resolve_in_border(
|
||||
self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
/// also affect contextual block shapes (for example powder snow).
|
||||
@@ -617,6 +647,25 @@ mod border_movement_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
|
||||
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
|
||||
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
|
||||
#[test]
|
||||
fn a_minecart_sized_body_stops_at_the_border_wall() {
|
||||
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
|
||||
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.11).abs() < 1.0e-9,
|
||||
"the cart was not stopped by the border: {moved:?}"
|
||||
);
|
||||
assert_eq!(moved.z, 0.0);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Pick {
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// A tile drawn over a face, after its own (`overlay*` methods).
|
||||
|
||||
@@ -389,6 +389,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:jukebox" {
|
||||
return Some(Self::Jukebox(Jukebox::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
@@ -729,6 +732,20 @@ mod tests {
|
||||
assert_eq!(entity.contents().len(), 1);
|
||||
}
|
||||
|
||||
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
|
||||
/// so the item's block entity data lands somewhere and the box can hold a
|
||||
/// disc without a player putting one in.
|
||||
#[test]
|
||||
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
|
||||
assert_eq!(entity.id(), "minecraft:jukebox");
|
||||
assert!(matches!(entity, BlockEntity::Jukebox(_)));
|
||||
assert_eq!(entity.slots().len(), 1);
|
||||
assert!(entity.slots()[0].is_none());
|
||||
// Another block's name is not a jukebox.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -121,7 +121,11 @@ impl DataPack {
|
||||
}
|
||||
/// The packs in `<world>/datapacks`, sorted by name.
|
||||
pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
|
||||
discover_folder(&world.join("datapacks"))
|
||||
}
|
||||
/// Packs in Create World's temporary pack folder, before a world exists.
|
||||
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packs = Vec::new();
|
||||
|
||||
@@ -375,6 +375,14 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
|
||||
// sky, so they take its value, which their blending would otherwise cover.
|
||||
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
|
||||
if a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
|
||||
@@ -120,6 +120,9 @@ pub struct Extras {
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
@@ -132,6 +135,9 @@ pub struct Extras {
|
||||
pub splitscreen_three: u8,
|
||||
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
|
||||
pub splitscreen_names: bool,
|
||||
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
|
||||
/// on by default as vanilla); off, the wheel only scrolls the list.
|
||||
pub wheel_cycles_settings: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -168,11 +174,13 @@ impl Default for Extras {
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
wheel_cycles_settings: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,11 +259,13 @@ impl Extras {
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
|
||||
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -263,7 +273,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -301,11 +311,13 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
bool_text(self.splitscreen_names),
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -446,8 +458,11 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -472,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -490,10 +507,12 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -511,10 +530,12 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -543,8 +564,10 @@ impl Item {
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -556,8 +579,8 @@ impl Item {
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -609,6 +632,12 @@ impl Item {
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -624,7 +653,8 @@ impl Item {
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -644,8 +674,10 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -676,8 +708,10 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -701,7 +735,7 @@ mod tests {
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(d.discord_presence);
|
||||
assert!(d.discord_presence && d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -742,6 +776,8 @@ mod tests {
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
Item::WheelCyclesSettings.cycle(&mut e, false);
|
||||
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
|
||||
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
|
||||
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
|
||||
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
|
||||
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
|
||||
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
|
||||
//!
|
||||
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
|
||||
//! turned about x, or turned with `uvlock`, join a quarter turn off.
|
||||
//!
|
||||
//! The format is `horizon_optifine::fusion`'s.
|
||||
|
||||
use crate::connected::{Overlay, Pick, TileLayer};
|
||||
use crate::models::StateModels;
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::cem::json::Json;
|
||||
use horizon_optifine::ctm::connect::{self, Axes, Face};
|
||||
use horizon_optifine::fusion::{
|
||||
Connection, Layout, Tiles,
|
||||
model::{Connections, ModelType},
|
||||
predicate::Query,
|
||||
texture::{Kind, Meta},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
layout: Layout,
|
||||
tiles: Vec<Option<TileLayer>>,
|
||||
}
|
||||
|
||||
/// A sheet and what it joins by, blocks as sorted state ids.
|
||||
struct Rule {
|
||||
sheet: usize,
|
||||
connection: Connection<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FusionTextures {
|
||||
sheets: Vec<Sheet>,
|
||||
/// By [`crate::models::BakedFace::fusion`].
|
||||
rules: Vec<Rule>,
|
||||
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
|
||||
blocks: Vec<u32>,
|
||||
}
|
||||
|
||||
/// A model's reference to a texture or another model (`block/glass`), namespaced.
|
||||
fn namespaced(reference: &str) -> String {
|
||||
horizon_optifine::matchers::namespaced(reference)
|
||||
}
|
||||
|
||||
fn read_model(root: &Path, name: &str) -> Option<Json> {
|
||||
let (ns, path) = name.split_once(':')?;
|
||||
let text = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{ns}/models/{path}.json")),
|
||||
)
|
||||
.ok()?;
|
||||
Json::parse(&text).ok()
|
||||
}
|
||||
|
||||
/// What a model's face draws and joins by: its sprite, its side in the model, and its
|
||||
/// predicates (`None`: the texture's own).
|
||||
type ModelFace = (String, Face, Option<Connection>);
|
||||
|
||||
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
|
||||
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
|
||||
let Some(model) = read_model(root, name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if ModelType::of(&model) != Some(ModelType::Connecting) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
|
||||
// levels is a loop.
|
||||
let mut chain = vec![model];
|
||||
while chain.len() < 16
|
||||
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
|
||||
&& let Some(parent) = read_model(root, &namespaced(parent))
|
||||
{
|
||||
chain.push(parent);
|
||||
}
|
||||
// Parents first, so a child's keys replace theirs.
|
||||
let mut connections = Connections::Textures;
|
||||
let mut textures: HashMap<&str, &str> = HashMap::new();
|
||||
for model in chain.iter().rev() {
|
||||
match Connections::of(model) {
|
||||
Ok(own) => connections = own.inherit(&connections),
|
||||
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
|
||||
}
|
||||
for (key, value) in model
|
||||
.get("textures")
|
||||
.and_then(Json::as_object)
|
||||
.unwrap_or(&[])
|
||||
{
|
||||
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
|
||||
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
|
||||
if let Some(value) = value {
|
||||
textures.insert(key.as_str(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `#key` as vanilla resolves it.
|
||||
let resolve = |reference: &str| {
|
||||
let mut value = reference;
|
||||
for _ in 0..16 {
|
||||
match value.strip_prefix('#') {
|
||||
Some(key) => value = textures.get(key)?,
|
||||
None => return Some(namespaced(value)),
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
// The elements are the nearest model's that has some.
|
||||
let elements = chain
|
||||
.iter()
|
||||
.find_map(|m| m.get("elements")?.as_array())
|
||||
.unwrap_or(&[]);
|
||||
let mut out: Vec<ModelFace> = Vec::new();
|
||||
let faces = elements
|
||||
.iter()
|
||||
.filter_map(|e| e.get("faces")?.as_object())
|
||||
.flatten();
|
||||
for (side, face) in faces {
|
||||
let side = match side.as_str() {
|
||||
"down" => Face::Down,
|
||||
"up" => Face::Up,
|
||||
"north" => Face::North,
|
||||
"south" => Face::South,
|
||||
"west" => Face::West,
|
||||
"east" => Face::East,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(sprite) = resolve(texture) else {
|
||||
continue;
|
||||
};
|
||||
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
|
||||
let key = face.get("connections").and_then(Json::as_str);
|
||||
out.push((sprite, side, connections.for_face(key, texture).cloned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
|
||||
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
|
||||
let mut n = face.normal();
|
||||
for _ in 0..x % 4 {
|
||||
// x=90 takes up to north.
|
||||
n = [n[0], n[2], -n[1]];
|
||||
}
|
||||
for _ in 0..y % 4 {
|
||||
// y=90 takes north to east.
|
||||
n = [-n[2], n[1], n[0]];
|
||||
}
|
||||
Face::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.normal() == n)
|
||||
.expect("a quarter turn maps faces onto faces")
|
||||
}
|
||||
|
||||
/// The connecting textures under `root`, by sprite name.
|
||||
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
|
||||
fn sheet(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
name: &str,
|
||||
meta: Meta,
|
||||
) -> Option<(Sheet, Connection)> {
|
||||
let Kind::Connecting {
|
||||
layout,
|
||||
connections,
|
||||
per_tile_animation,
|
||||
..
|
||||
} = meta.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let tiles = sprites
|
||||
.insert_tiles(root, name, layout.grid(), per_tile_animation)
|
||||
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
|
||||
.ok()?;
|
||||
// A layer past the mesher's `u16` leaves the sheet to vanilla.
|
||||
let tiles = tiles
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
None => Some(None),
|
||||
Some(l) => Some(Some(TileLayer::Layer {
|
||||
layer: u16::try_from(l).ok()?,
|
||||
translucent: sprites.translucent[l as usize],
|
||||
opaque: sprites.opaque[l as usize],
|
||||
})),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((Sheet { layout, tiles }, connections))
|
||||
}
|
||||
|
||||
impl FusionTextures {
|
||||
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
|
||||
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
|
||||
/// shows one gets its rule.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
catalog: &mut StateModels,
|
||||
) -> Self {
|
||||
let mut textures = connecting_textures(root);
|
||||
if textures.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The layers models show the sheets as: only their models are read.
|
||||
let shown: std::collections::HashSet<u32> = textures
|
||||
.keys()
|
||||
.filter_map(|name| sprites.layers.get(name).copied())
|
||||
.collect();
|
||||
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
|
||||
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
|
||||
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
|
||||
let mut rules: Vec<(usize, Connection)> = Vec::new();
|
||||
for (variant, model) in catalog.models_mut() {
|
||||
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
|
||||
continue;
|
||||
}
|
||||
let found = models
|
||||
.entry(namespaced(&variant.model))
|
||||
.or_insert_with_key(|name| connecting_model(root, name));
|
||||
for face in &mut model.faces {
|
||||
let sprite = &face.quad.material.sprite;
|
||||
// The model's face on the side this one turned to, else any with its sprite.
|
||||
let side = Face::ALL[face.quad.facing as usize];
|
||||
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
|
||||
let Some((_, _, connection)) = drawing
|
||||
.clone()
|
||||
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
|
||||
.or_else(|| drawing.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The sheet, cut into tiles on first use.
|
||||
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
|
||||
let meta = textures.remove(sprite)?;
|
||||
sheets.push(sheet(root, sprites, sprite, meta)?);
|
||||
Some(sheets.len() - 1)
|
||||
});
|
||||
let Some(sheet) = sheet else {
|
||||
continue;
|
||||
};
|
||||
let connection = connection
|
||||
.clone()
|
||||
.unwrap_or_else(|| sheets[sheet].1.clone());
|
||||
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
|
||||
Some(rule) => rule,
|
||||
None => {
|
||||
rules.push((sheet, connection));
|
||||
rules.len() - 1
|
||||
}
|
||||
};
|
||||
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
|
||||
}
|
||||
}
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
|
||||
Self::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
|
||||
fn build<'a>(
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
) -> Self {
|
||||
let states: Vec<_> = states.collect();
|
||||
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
let mut names = HashMap::new();
|
||||
let blocks = (0..states.len() as u32)
|
||||
.map(|id| match states[id as usize] {
|
||||
Some((name, _)) => {
|
||||
block_states.entry(name).or_default().push(id);
|
||||
let next = names.len() as u32;
|
||||
*names.entry(name).or_insert(next)
|
||||
}
|
||||
None => u32::MAX,
|
||||
})
|
||||
.collect();
|
||||
// Block lists to the states they match.
|
||||
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
|
||||
let mut ids: Vec<u32> = list
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
let candidates = block_states
|
||||
.get(m.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
candidates.iter().copied().filter(|&id| {
|
||||
states[id as usize].is_some_and(|(name, props)| {
|
||||
m.matches(name, |k| props.get(k).map(String::as_str))
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
};
|
||||
let rules = rules
|
||||
.into_iter()
|
||||
.map(|(sheet, connection)| Rule {
|
||||
sheet,
|
||||
connection: connection.map_blocks(&mut resolve),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sheets,
|
||||
rules,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
|
||||
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
|
||||
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
|
||||
/// pieces, tinted as the quad, go to `overlay`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn pick(
|
||||
&self,
|
||||
rule: NonZeroU16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
tint: i32,
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
covers: impl Fn(u32) -> bool,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = self.rules.get(usize::from(rule.get() - 1))?;
|
||||
let normal = face.normal();
|
||||
let offsets = connect::offsets(Axes::cube(face));
|
||||
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
|
||||
let mask = rule.connection.mask(|bit, query| {
|
||||
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
|
||||
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
|
||||
match query {
|
||||
Query::SameBlock => {
|
||||
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
|
||||
}
|
||||
Query::SameState => neighbour([x, y, z]) == state,
|
||||
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
|
||||
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
|
||||
Query::FaceVisible => !covers(in_front()),
|
||||
}
|
||||
});
|
||||
let sheet = &self.sheets[rule.sheet];
|
||||
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
|
||||
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
|
||||
// never need.
|
||||
Some(match sheet.layout.tiles(mask) {
|
||||
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
|
||||
Tiles::Quarters(q) => match q.map(tile) {
|
||||
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
|
||||
_ => Pick::Nothing,
|
||||
},
|
||||
Tiles::Overlay(mask) => {
|
||||
for t in Layout::overlay_tiles(mask) {
|
||||
if let Some(TileLayer::Layer {
|
||||
layer, translucent, ..
|
||||
}) = tile(t)
|
||||
{
|
||||
overlay(Overlay {
|
||||
layer,
|
||||
translucent,
|
||||
tint: (tint >= 0).then_some((tint, None)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Pick::Nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
|
||||
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
|
||||
let (w, h) = layout.grid();
|
||||
Sheet {
|
||||
layout,
|
||||
tiles: (0..(w * h) as u16)
|
||||
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn predicate(json: &str) -> Connection {
|
||||
Connection::parse(&Json::parse(json).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn layer(layer: u16) -> TileLayer {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookups over blocks named `names` (a state each).
|
||||
fn world(
|
||||
names: &[&str],
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
) -> FusionTextures {
|
||||
let none = BlockProperties::default();
|
||||
let states = names.iter().map(|n| Some((*n, &none)));
|
||||
FusionTextures::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
|
||||
/// `opaque`; with the overlays it hands out.
|
||||
fn pick_in(
|
||||
fusion: &FusionTextures,
|
||||
rule: u16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
opaque: &[u32],
|
||||
) -> (Option<Pick>, Vec<u16>) {
|
||||
// A face's rule counts from 1.
|
||||
let rule = NonZeroU16::new(rule + 1).unwrap();
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
let mut overlays = Vec::new();
|
||||
let pick = fusion.pick(
|
||||
rule,
|
||||
state,
|
||||
face,
|
||||
-1,
|
||||
at,
|
||||
|s| opaque.contains(&s),
|
||||
|o| overlays.push(o.layer),
|
||||
);
|
||||
(pick, overlays)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_pieces_join_the_same_block() {
|
||||
// Glass twice (two states of one block), then stone.
|
||||
let fusion = world(
|
||||
&[
|
||||
"minecraft:air",
|
||||
"minecraft:glass",
|
||||
"minecraft:glass",
|
||||
"minecraft:stone",
|
||||
],
|
||||
vec![sheet(Layout::Pieced, 100, &[])],
|
||||
vec![(0, Connection::SameBlock)],
|
||||
);
|
||||
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
|
||||
// Alone: every quarter the lone tile.
|
||||
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
|
||||
// A north face's right is west: glass there (in its other state) joins the right
|
||||
// quarters, the horizontal piece. Stone doesn't.
|
||||
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
|
||||
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
|
||||
assert_eq!(
|
||||
at(&[([-1, 0, 0], 3)]),
|
||||
Some(Pick::Quarters([layer(100); 4]))
|
||||
);
|
||||
// A rule that doesn't exist draws as vanilla.
|
||||
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_reach_toward_visible_sand() {
|
||||
let sand = predicate(
|
||||
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
|
||||
);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
|
||||
vec![sheet(Layout::Overlay, 200, &[7])],
|
||||
vec![(0, sand)],
|
||||
);
|
||||
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
|
||||
// over a face that draws nothing itself.
|
||||
let north = [([0, 0, -1], 2)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
|
||||
(Some(Pick::Nothing), vec![213])
|
||||
);
|
||||
// Stone over that sand hides its top: no edge.
|
||||
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
|
||||
(Some(Pick::Nothing), vec![])
|
||||
);
|
||||
// Sand all round: the ring, (4, 1).
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
|
||||
.filter(|(o, _)| *o != [0, 0, 0])
|
||||
.collect();
|
||||
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
|
||||
// Its pieces take the quad's tint index.
|
||||
let mut tints = Vec::new();
|
||||
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
|
||||
let rule = NonZeroU16::new(1).unwrap();
|
||||
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
|
||||
assert_eq!(tints, [Some((0, None))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_front_and_empty_tiles() {
|
||||
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
|
||||
// lone tile empty.
|
||||
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
|
||||
vec![sheet(Layout::Full, 300, &[0])],
|
||||
vec![(0, front)],
|
||||
);
|
||||
// Sand on the block east of the top face: joined right, the full template's (1, 0).
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
|
||||
Some(Pick::Tile(layer(301)))
|
||||
);
|
||||
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
|
||||
Some(Pick::Nothing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_name_the_predicates_their_faces_draw() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// A pane arm over a template whose `particle` names the pane sheet but draws no face
|
||||
// with it (as Fusion Connected Glass's posts do): only the faces count.
|
||||
fs::write(
|
||||
models.join("template.json"),
|
||||
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
|
||||
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("arm.json"),
|
||||
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
|
||||
"connections": {"edge": {"type": "true"}},
|
||||
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("plain.json"),
|
||||
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let arm = connecting_model(&root, "demo:block/arm");
|
||||
let plain = connecting_model(&root, "demo:block/plain");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// `edge` has the model's predicate; `pane` none, so the texture's own.
|
||||
assert_eq!(
|
||||
arm,
|
||||
[
|
||||
(
|
||||
"demo:block/edge".to_owned(),
|
||||
Face::Up,
|
||||
Some(Connection::Const(true))
|
||||
),
|
||||
("demo:block/glass".to_owned(), Face::West, None),
|
||||
]
|
||||
);
|
||||
// Not a Fusion model: vanilla.
|
||||
assert!(plain.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_sprite_join_by_their_own_keys() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// The wiki's example: four keys on one texture, two of them joining by `blue`.
|
||||
fs::write(
|
||||
models.join("tiles.json"),
|
||||
r##"{"loader": "fusion:model", "type": "connecting",
|
||||
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
|
||||
"connections": {"blue": {"type": "match_block", "block": "ice"},
|
||||
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
|
||||
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
|
||||
"up": {"texture": "#ice", "connections": "#blue"},
|
||||
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let faces = connecting_model(&root, "demo:block/tiles");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
|
||||
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
|
||||
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
|
||||
assert_eq!(joins(Face::South), Some(Connection::SameState));
|
||||
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
|
||||
// the east.
|
||||
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
|
||||
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
|
||||
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -157,21 +157,24 @@ impl Arena {
|
||||
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// The sorted-index arena and, when `split`, the translucent arena
|
||||
/// (vertices of `stride`), both with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena, and the indices
|
||||
/// in pages flat draws (`flat`) bind as storage.
|
||||
/// (vertices of `stride`) with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena. The indices go in
|
||||
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
|
||||
pub fn translucent_arenas(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
flat: bool,
|
||||
split: bool,
|
||||
) -> (Self, Option<Self>) {
|
||||
if split {
|
||||
let indices = Self::translucent_indices(device, 1 << 16);
|
||||
(indices, Some(Self::translucent(device, stride, 1 << 18)))
|
||||
let indices = if split && !flat {
|
||||
Self::translucent_indices(device, 1 << 16)
|
||||
} else {
|
||||
(Self::indices(device, 1 << 16, flat), None)
|
||||
}
|
||||
Self::indices(device, 1 << 16, flat)
|
||||
};
|
||||
(
|
||||
indices,
|
||||
split.then(|| Self::translucent(device, stride, 1 << 18)),
|
||||
)
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
@@ -927,6 +930,121 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
|
||||
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
|
||||
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
|
||||
/// put them at another section's origin.
|
||||
#[test]
|
||||
fn moved_translucent_quads_keep_their_section_s_flat_id() {
|
||||
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[],
|
||||
});
|
||||
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
flat.set_translucent_page_vertices(translucent.page_vertices());
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
|
||||
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
|
||||
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(
|
||||
alloc(&mut translucent, &device, &queue, GRAIN),
|
||||
Some((1, 0))
|
||||
);
|
||||
translucent.release(0, rest, GRAIN);
|
||||
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
|
||||
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
|
||||
// Its solid quads in the section arena's page 0, its translucent ones here.
|
||||
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
|
||||
flat.place(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
id,
|
||||
key(0),
|
||||
at,
|
||||
2 * GRAIN,
|
||||
);
|
||||
let mut section = SectionGpu {
|
||||
page: 0,
|
||||
start: 0,
|
||||
translucent_at: (0, at),
|
||||
solid: 64,
|
||||
sides: crate::mesher::Sides::unsorted(64),
|
||||
translucent: 128,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
translucent.close(0);
|
||||
let mut budget = u64::MAX;
|
||||
assert!(move_off_page(
|
||||
&mut translucent,
|
||||
&mut encoder,
|
||||
Some((&mut flat, &device, &mut uploads)),
|
||||
std::iter::once(&mut section),
|
||||
0,
|
||||
Part::Translucent,
|
||||
&mut budget
|
||||
));
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (page, start) = section.translucent_at;
|
||||
assert_eq!(page, 1);
|
||||
assert_eq!(section.flat_id, Some(id));
|
||||
let named =
|
||||
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
|
||||
for grain in start / GRAIN..(start / GRAIN + 2) {
|
||||
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
|
||||
}
|
||||
// The section arena's table and the other section's grain are untouched.
|
||||
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
|
||||
assert_eq!(named(0, other / GRAIN), other_id);
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
@@ -1028,9 +1146,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The translucent arena comes with the split layout alone, and only then
|
||||
/// are the index pages large. Otherwise, as while a shader pack's terrain
|
||||
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
|
||||
/// section arena, and flat draws (`flat`) bind the index pages as storage.
|
||||
/// are the index pages large, unless flat draws (`flat`) bind them as
|
||||
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
|
||||
/// `translucent_split`), translucent quads keep to the section arena.
|
||||
#[test]
|
||||
fn translucent_arenas_follow_the_layout() {
|
||||
let devices = devices();
|
||||
@@ -1050,9 +1168,9 @@ mod tests {
|
||||
assert_eq!(translucent.is_some(), split, "{case}");
|
||||
let bytes = indices.page_vertices() * 4;
|
||||
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
|
||||
assert_eq!(storage, flat && !split, "{case}");
|
||||
assert_eq!(storage, flat, "{case}");
|
||||
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
|
||||
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
|
||||
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
|
||||
if let Some(t) = translucent {
|
||||
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
|
||||
//! their quads all face an axis, and their shading is baked into the colour.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
@@ -35,6 +36,20 @@ pub struct SkinPart {
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
impl SkinPart {
|
||||
/// An item layer's part: its pose, and its quads' colours by axis.
|
||||
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
|
||||
let mut normal = [0.0; 9];
|
||||
for (word, color) in normal.iter_mut().zip(colors) {
|
||||
*word = f32::from_bits(color);
|
||||
}
|
||||
Self {
|
||||
matrix: matrix.to_cols_array(),
|
||||
normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
@@ -51,7 +66,12 @@ pub struct SkinJob {
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
|
||||
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
|
||||
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub item: u32,
|
||||
/// Always 0: the shader reads the last as a zero it cannot fold away.
|
||||
pub spare: [u32; 2],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
@@ -352,13 +372,19 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
let (color, normal) = if job.item != 0 {
|
||||
let color = part.normal[v.axis as usize].to_bits();
|
||||
(color, crate::mesher::CULL_BACK)
|
||||
} else {
|
||||
(job.color, [packed[0], packed[1], packed[2], -127])
|
||||
};
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
normal,
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
@@ -406,8 +432,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
/// texture and the armour glint, and an item's: the shader writes what
|
||||
/// `expand` does, to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -440,6 +466,18 @@ mod tests {
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
|
||||
SkinPart::item(
|
||||
child * turn,
|
||||
[
|
||||
0xff80_4020,
|
||||
0x7fc0_0001,
|
||||
0x0000_0001,
|
||||
0xffff_ffff,
|
||||
0x7f80_0001,
|
||||
0x1234_5678,
|
||||
],
|
||||
),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
@@ -467,8 +505,20 @@ mod tests {
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 24,
|
||||
part: 4,
|
||||
dst: 72,
|
||||
rect: [0.5, 0.25, 0.5625, 0.3125],
|
||||
light: 0xa0_30,
|
||||
overlay: 2 << 16,
|
||||
item: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
vertices: 96,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
|
||||
@@ -15,7 +15,9 @@ const MODEL_WORDS: u32 = 7u;
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
|
||||
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
|
||||
// `keep`'s).
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
@@ -30,6 +32,20 @@ fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
|
||||
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
|
||||
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
|
||||
// to it, so each operation below rounds on its own, in the CPU's order.
|
||||
var<private> keep: u32;
|
||||
|
||||
fn r(v: f32) -> f32 {
|
||||
return bitcast<f32>(bitcast<u32>(v) ^ keep);
|
||||
}
|
||||
|
||||
fn r3(v: vec3<f32>) -> vec3<f32> {
|
||||
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
@@ -40,8 +56,8 @@ fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
let s = r(u + scroll);
|
||||
return r(s - r(trunc(s)));
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
@@ -72,33 +88,42 @@ fn skin(
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
let item = jobs[j + 13u] != 0u;
|
||||
keep = jobs[j + 15u];
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// glam's Mat4::transform_point3, in its order, each operation rounded.
|
||||
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
|
||||
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
|
||||
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
|
||||
pos = r3(vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos);
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
|
||||
var vertex_color = color;
|
||||
var normal = pack(n);
|
||||
if item {
|
||||
vertex_color = parts[part * PART_WORDS + 16u + axis];
|
||||
normal = 1u << 24u;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 6u] = vertex_color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
out[o + 8u] = normal;
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Plan {
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
|
||||
/// Batching): its page plus this. The section arena's pages are their own keys, all far
|
||||
/// below it. A call names its key: its vertices come from that arena's page.
|
||||
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
|
||||
|
||||
struct PageTable {
|
||||
/// Section id of every block of the page.
|
||||
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
|
||||
/// A run of the frame's index list drawn from one arena page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
/// The page's key (`TRANSLUCENT_KEYS`).
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
@@ -98,6 +103,9 @@ pub struct Flat {
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
|
||||
translucent_blocks_per_page: u64,
|
||||
/// Block tables by page key.
|
||||
pages: Vec<Option<PageTable>>,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
@@ -218,7 +226,14 @@ fn buffer(
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST,
|
||||
// Tests read the tables back.
|
||||
usage: usage
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| if cfg!(test) {
|
||||
wgpu::BufferUsages::COPY_SRC
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
@@ -242,6 +257,7 @@ fn plan(
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
align: u64,
|
||||
key: impl Fn(u32, Option<u32>) -> u32,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
@@ -273,6 +289,7 @@ fn plan(
|
||||
}
|
||||
let starts =
|
||||
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
|
||||
let page = key(page, sorted);
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && !starts => last.count += d[0],
|
||||
_ => calls.push(Call {
|
||||
@@ -451,6 +468,7 @@ impl Flat {
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
pages: Vec::new(),
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
@@ -479,8 +497,8 @@ impl Flat {
|
||||
.max(1),
|
||||
}
|
||||
}
|
||||
/// A section uploaded to `vertices` vertices from `start` of `page`: its
|
||||
/// id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
|
||||
/// its id, which its blocks now name (written through `uploads` in `encoder`).
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -494,6 +512,28 @@ impl Flat {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.place(device, encoder, uploads, id, page, start, vertices);
|
||||
id
|
||||
}
|
||||
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
|
||||
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
|
||||
/// a range that moved.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn place(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
) {
|
||||
let blocks = if page >= TRANSLUCENT_KEYS {
|
||||
self.translucent_blocks_per_page
|
||||
} else {
|
||||
self.blocks_per_page
|
||||
};
|
||||
let page = page as usize;
|
||||
if self.pages.len() <= page {
|
||||
self.pages.resize_with(page + 1, || None);
|
||||
@@ -502,7 +542,7 @@ impl Flat {
|
||||
blocks: buffer(
|
||||
device,
|
||||
"Section ids by block",
|
||||
self.blocks_per_page * 4,
|
||||
blocks * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
),
|
||||
group: None,
|
||||
@@ -515,7 +555,19 @@ impl Flat {
|
||||
first * 4,
|
||||
bytemuck::cast_slice(&ids),
|
||||
);
|
||||
id
|
||||
}
|
||||
/// The block table of the page keyed `page`, for tests that read it back.
|
||||
#[cfg(test)]
|
||||
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize]
|
||||
.as_ref()
|
||||
.expect("a page table")
|
||||
.blocks
|
||||
}
|
||||
/// The most vertices a page of the translucent arena holds: its block tables' size.
|
||||
/// Tables made before keep theirs, so this goes with `clear`.
|
||||
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
|
||||
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
|
||||
}
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
@@ -525,7 +577,7 @@ impl Flat {
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
/// Forgets the tables of pages the arena released.
|
||||
/// Forgets the tables of pages the arenas released (`live`, by page key).
|
||||
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if page.is_some() && !live(i as u32) {
|
||||
@@ -556,6 +608,7 @@ impl Flat {
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
translucent_arena: bool,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
@@ -609,6 +662,11 @@ impl Flat {
|
||||
shadow,
|
||||
passes,
|
||||
self.range_align,
|
||||
// Translucent draws, and only they, have sorted indices.
|
||||
|page, sorted| match sorted {
|
||||
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
|
||||
_ => page,
|
||||
},
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
@@ -801,7 +859,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
self.draw_with(pass, calls, vertices, |_, _| {});
|
||||
}
|
||||
@@ -811,7 +869,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -828,7 +886,7 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
instances: u32,
|
||||
) {
|
||||
@@ -842,7 +900,7 @@ impl Flat {
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -857,13 +915,13 @@ impl Flat {
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
page: u32,
|
||||
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
|
||||
) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_ref()
|
||||
.and_then(|t| t.group.as_ref())
|
||||
.expect("prepared page");
|
||||
pass.set_vertex_buffer(0, vertices(page).slice(..));
|
||||
pass.set_vertex_buffer(0, vertices(page));
|
||||
pass.set_bind_group(1, table, &[]);
|
||||
}
|
||||
}
|
||||
@@ -912,6 +970,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -960,6 +1019,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[3, 1],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -995,6 +1055,7 @@ mod tests {
|
||||
[camera, camera + 2],
|
||||
[camera, 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1023,6 +1084,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
@@ -1140,6 +1202,7 @@ mod tests {
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
align,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
|
||||
@@ -645,6 +645,7 @@ mod tests {
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
include_str!("../hdr_stats.wgsl"),
|
||||
include_str!("stars.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
|
||||
@@ -7,13 +7,19 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
|
||||
/// once every mesh is in) and the columns they leave out.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pending {
|
||||
meshes: FxHashMap<TileKey, SectionMesh>,
|
||||
/// The order they go up in: as handed over (`Lod::update`: finer tiles
|
||||
/// first, so no tile clipped around a finer one is drawn before it).
|
||||
order: VecDeque<TileKey>,
|
||||
removed: FxHashSet<TileKey>,
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
}
|
||||
@@ -30,7 +36,9 @@ impl Pending {
|
||||
) {
|
||||
for (key, mesh) in meshes {
|
||||
self.removed.remove(&key);
|
||||
self.meshes.insert(key, mesh);
|
||||
if self.meshes.insert(key, mesh).is_none() {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
}
|
||||
for key in removed {
|
||||
self.meshes.remove(&key);
|
||||
@@ -330,7 +338,10 @@ impl Renderer {
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
// Keys meshed again keep their place; removed ones are skipped.
|
||||
let next = std::iter::from_fn(|| pending.order.pop_front())
|
||||
.find(|key| pending.meshes.contains_key(key));
|
||||
let Some(key) = next else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
@@ -498,7 +509,16 @@ mod tests {
|
||||
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
|
||||
removed.sort_unstable();
|
||||
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
|
||||
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
|
||||
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
|
||||
// They go up in the order handed over, each once.
|
||||
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
|
||||
let order: Vec<_> = pending
|
||||
.order
|
||||
.iter()
|
||||
.filter(|k| pending.meshes.contains_key(k))
|
||||
.map(|k| k.x)
|
||||
.collect();
|
||||
assert_eq!(order, [1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct NativePack {
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// `[[lights]]`, for the colour of held and dropped items' light.
|
||||
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -136,6 +138,7 @@ impl NativePack {
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
light_colors: report.manifest.lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
|
||||
pub sprite: u32,
|
||||
/// Layers per sprite array.
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -57,6 +59,7 @@ impl PackAtlas {
|
||||
columns,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
@@ -179,7 +182,12 @@ impl PackAtlas {
|
||||
];
|
||||
let columns = self.columns;
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
"hz_v_color.a"
|
||||
} else {
|
||||
"1.0"
|
||||
};
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
@@ -189,7 +197,7 @@ impl PackAtlas {
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
|
||||
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
|
||||
scale[0], scale[1],
|
||||
@@ -313,4 +321,50 @@ mod tests {
|
||||
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut atlas = PackAtlas {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_default
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
|
||||
);
|
||||
|
||||
atlas.separate_ao = true;
|
||||
let inputs_separate = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_separate
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
enum Source {
|
||||
Color(usize),
|
||||
Depth,
|
||||
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
|
||||
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
|
||||
Depth1,
|
||||
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
|
||||
Depth2,
|
||||
Noise,
|
||||
/// A 1×1 stand-in for names this slice does not provide yet.
|
||||
Dummy,
|
||||
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
|
||||
Normals,
|
||||
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
|
||||
Specular,
|
||||
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
|
||||
/// `.mcmeta` says.
|
||||
Custom {
|
||||
@@ -81,10 +87,18 @@ enum SamplerKind {
|
||||
impl SamplerKind {
|
||||
fn of(source: Source, filtering: bool) -> Self {
|
||||
match source {
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
if !filtering =>
|
||||
{
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels.
|
||||
@@ -97,9 +111,12 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
| Source::Lightmap => Self::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +404,8 @@ struct Targets {
|
||||
depth_texture: wgpu::Texture,
|
||||
/// `depthtex1`, when a program after translucent terrain reads it.
|
||||
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `depthtex2`, when a program after the hand reads it without the hand.
|
||||
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
|
||||
mips: [Option<[Mips; 2]>; BUFFERS],
|
||||
/// Where a pass that both samples and stores a buffer samples it from.
|
||||
@@ -459,6 +478,7 @@ struct ShadowCacheKey {
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
setup_pending: bool,
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
@@ -493,6 +513,8 @@ pub struct PackPost {
|
||||
hand: Option<(Pass, super::pack_hand::PackHand)>,
|
||||
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
depth2: bool,
|
||||
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
@@ -535,6 +557,8 @@ pub struct PackPost {
|
||||
targets: Option<Targets>,
|
||||
noise: wgpu::TextureView,
|
||||
dummy: wgpu::TextureView,
|
||||
dummy_black: wgpu::TextureView,
|
||||
dummy_normal: wgpu::TextureView,
|
||||
dummy_3d: wgpu::TextureView,
|
||||
dummy_1d: wgpu::TextureView,
|
||||
/// `image.*` and `bufferObject.*`.
|
||||
@@ -649,11 +673,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
}
|
||||
match name {
|
||||
"depthtex0" | "gdepthtex" => Source::Depth,
|
||||
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
|
||||
"depthtex1" | "depthtex2" => Source::Depth1,
|
||||
"depthtex1" => Source::Depth1,
|
||||
"depthtex2" => Source::Depth2,
|
||||
"noisetex" => Source::Noise,
|
||||
"texture" | "gtexture" | "tex" => Source::Atlas,
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1101,7 @@ fn bindings(
|
||||
Source::Atlas => geometry.is_none(),
|
||||
Source::Depth => geometry.is_some(),
|
||||
// Translucent terrain reads the copy; opaque terrain would read itself.
|
||||
Source::Depth1 => group == Group::Gbuffers,
|
||||
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
|
||||
Source::Color(n) => in_place & 1 << n != 0,
|
||||
_ => false,
|
||||
};
|
||||
@@ -1086,7 +1112,7 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
|
||||
@@ -2080,13 +2106,20 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
}
|
||||
let depth1 = water.is_some()
|
||||
let depth1 = (water.is_some() || hand.is_some())
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
|
||||
let depth2 = hand.is_some()
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
|
||||
let every_pass = || {
|
||||
(passes
|
||||
.iter()
|
||||
@@ -2161,7 +2194,7 @@ impl PackPost {
|
||||
|| passes.iter().any(|p| {
|
||||
p.samplers
|
||||
.iter()
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
|
||||
});
|
||||
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
|
||||
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
|
||||
@@ -2250,8 +2283,11 @@ impl PackPost {
|
||||
});
|
||||
}
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
water,
|
||||
@@ -2268,6 +2304,7 @@ impl PackPost {
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
shadow_water,
|
||||
@@ -2302,6 +2339,14 @@ impl PackPost {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
|
||||
dummy_normal: texture(
|
||||
device,
|
||||
queue,
|
||||
"Shader pack normal stand-in",
|
||||
1,
|
||||
&flat_normal,
|
||||
),
|
||||
images: {
|
||||
let mut images = images;
|
||||
for (k, image) in images.iter_mut().enumerate() {
|
||||
@@ -2507,6 +2552,27 @@ impl PackPost {
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
Source::Depth2 => match &targets.depth2 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => match &targets.depth1 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
},
|
||||
Source::Noise => &self.noise,
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
|
||||
&self.dummy_3d
|
||||
@@ -2514,6 +2580,8 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals => &self.dummy_normal,
|
||||
Source::Specular => &self.dummy_black,
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
@@ -2685,16 +2753,6 @@ impl PackPost {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
if list == TerrainList::Translucent
|
||||
&& let Some(t) = &self.targets
|
||||
&& let Some((copy, _)) = &t.depth1
|
||||
{
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
let mut rp = self.begin(encoder, &pass);
|
||||
@@ -2972,6 +3030,22 @@ impl PackPost {
|
||||
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
|
||||
self.draw_entities(device, queue, encoder, size, false);
|
||||
self.draw_clouds(device, queue, encoder, size);
|
||||
if let Some(t) = &self.targets {
|
||||
if let Some((copy, _)) = &t.depth1 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
if let Some((copy, _)) = &t.depth2 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_hand(device, queue, encoder, size, frame);
|
||||
}
|
||||
|
||||
@@ -3266,6 +3340,7 @@ impl PackPost {
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
self.setup_pending = true;
|
||||
let sizes: [[u32; 2]; BUFFERS] =
|
||||
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
|
||||
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
|
||||
@@ -3362,6 +3437,11 @@ impl PackPost {
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let depth2 = self.depth2.then(|| {
|
||||
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
@@ -3395,6 +3475,7 @@ impl PackPost {
|
||||
depth: depth_texture.create_view(&Default::default()),
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -3693,7 +3774,6 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3823,8 +3903,17 @@ impl PackPost {
|
||||
.any(|(p, late)| p.is_some() && late)
|
||||
});
|
||||
let mut water_drawn = self.water.is_none() && self.weather.is_none() && !late_entities;
|
||||
let mut shadow_drawn = false;
|
||||
for pass in &mut passes {
|
||||
if !terrain_drawn && pass.group != Group::Prepare {
|
||||
if pass.group == Group::Setup && !self.setup_pending {
|
||||
continue;
|
||||
}
|
||||
let early = matches!(pass.group, Group::Setup | Group::Begin);
|
||||
if !early && !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
shadow_drawn = true;
|
||||
}
|
||||
if !terrain_drawn && !early && pass.group != Group::Prepare {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
@@ -3903,6 +3992,10 @@ impl PackPost {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.passes = passes;
|
||||
self.setup_pending = false;
|
||||
if !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
}
|
||||
if !terrain_drawn {
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
@@ -4078,7 +4171,10 @@ mod tests {
|
||||
let main = |n| source(n, false, false, false);
|
||||
assert!(main("gaux1") == Source::Color(4));
|
||||
assert!(main("gdepthtex") == Source::Depth);
|
||||
assert!(main("depthtex2") == Source::Depth1);
|
||||
assert!(main("depthtex1") == Source::Depth1);
|
||||
assert!(main("depthtex2") == Source::Depth2);
|
||||
assert!(main("normals") == Source::Normals);
|
||||
assert!(main("specular") == Source::Specular);
|
||||
assert!(main("noisetex") == Source::Noise);
|
||||
// Without the shadow pass, the shadow buffers are stand-ins.
|
||||
assert!(main("shadowcolor0") == Source::Dummy);
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn upload(
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
|
||||
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -60,8 +60,8 @@ pub(super) fn upload(
|
||||
Image::Atlas
|
||||
} else {
|
||||
notes.push(format!(
|
||||
"texture.{}.{}: `{location}` is not provided yet",
|
||||
t.stage, t.name
|
||||
"{}: `{location}` is not provided yet",
|
||||
key(&t.stage, &t.name)
|
||||
));
|
||||
continue;
|
||||
};
|
||||
@@ -86,6 +86,8 @@ pub(super) fn upload(
|
||||
fn stage(group: Group) -> &'static str {
|
||||
match group {
|
||||
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
|
||||
Group::Setup => "setup",
|
||||
Group::Begin => "begin",
|
||||
Group::Prepare => "prepare",
|
||||
Group::Deferred => "deferred",
|
||||
Group::Composite | Group::Final => "composite",
|
||||
@@ -95,7 +97,8 @@ fn stage(group: Group) -> &'static str {
|
||||
|
||||
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
|
||||
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
|
||||
/// may hold one texture per dimension.
|
||||
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
|
||||
/// dimension, which every program reads.
|
||||
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
|
||||
let dimension = if ty.contains("1D") {
|
||||
wgpu::TextureViewDimension::D1
|
||||
@@ -105,14 +108,27 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
textures.iter().position(|t| {
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The `shaders.properties` key a texture came from, for notes.
|
||||
fn key(stage: &str, name: &str) -> String {
|
||||
match stage {
|
||||
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
|
||||
_ => format!("texture.{stage}.{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `texture.noise`, if the pack has one.
|
||||
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
|
||||
textures
|
||||
@@ -316,6 +332,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
|
||||
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
|
||||
#[test]
|
||||
fn custom_textures_reach_every_program() {
|
||||
use wgpu::TextureViewDimension as D;
|
||||
let every = horizon_shaders::EVERY_STAGE;
|
||||
let textures = [
|
||||
lightmap(every, "texClouds", D::D3),
|
||||
lightmap("composite", "texClouds", D::D3),
|
||||
lightmap(every, "colortex4", D::D2),
|
||||
];
|
||||
for group in [
|
||||
Group::Gbuffers,
|
||||
Group::Translucent,
|
||||
Group::Shadow,
|
||||
Group::Prepare,
|
||||
Group::Deferred,
|
||||
Group::ShadowComp,
|
||||
] {
|
||||
assert_eq!(
|
||||
find(&textures, group, "sampler3D", "texClouds"),
|
||||
Some(0),
|
||||
"{group:?}"
|
||||
);
|
||||
}
|
||||
// `final` reads the `composite` stage's textures too.
|
||||
for group in [Group::Composite, Group::Final] {
|
||||
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
|
||||
}
|
||||
assert_eq!(
|
||||
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
|
||||
None
|
||||
);
|
||||
// Only its own name: no other name of the same buffer.
|
||||
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
|
||||
assert_eq!(
|
||||
find(&textures, Group::Final, "sampler2D", "colortex4"),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texels_pack_into_their_format() {
|
||||
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
|
||||
|
||||
@@ -964,6 +964,9 @@ mod initial_history_tests {
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
#[cfg(test)]
|
||||
use super::Frustum;
|
||||
use super::{Cull, SectionKey};
|
||||
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
streaming::in_view_distance,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
/// One chunk column's resident sections.
|
||||
@@ -141,6 +144,24 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Removes every section of the `gone` columns, the others keeping their order: one
|
||||
/// pass over the store, where `remove` moves a slot and looks its column up for each.
|
||||
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
|
||||
let items = std::mem::take(&mut self.items);
|
||||
let bounds = std::mem::take(&mut self.bounds);
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
let mut removed = Vec::new();
|
||||
for ((key, value), bounds) in items.into_iter().zip(bounds) {
|
||||
if gone.contains(&(key.0, key.2)) {
|
||||
removed.push(value);
|
||||
} else {
|
||||
self.insert_with_bounds(key, value, bounds);
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
|
||||
let slot = self.slots.remove(&key)?;
|
||||
let c = self.column_index[&(key.0, key.2)] as usize;
|
||||
@@ -512,7 +533,19 @@ mod tests {
|
||||
((seed >> 4) % 6) as i32 - 1,
|
||||
((seed >> 8) % 5) as i32 - 2,
|
||||
);
|
||||
if (seed >> 12).is_multiple_of(3) {
|
||||
if (seed >> 12).is_multiple_of(97) {
|
||||
// Whole columns at once: the column of `key` and the next one along x.
|
||||
let gone: FxHashSet<(i32, i32)> =
|
||||
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
|
||||
let mut removed = r.remove_columns(&gone);
|
||||
let mut expected: Vec<u32> = model
|
||||
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
|
||||
.map(|(_, v)| v)
|
||||
.collect();
|
||||
removed.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key));
|
||||
} else {
|
||||
r.insert(key, step);
|
||||
|
||||
@@ -132,14 +132,15 @@ impl Stars {
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one).
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
|
||||
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::OVER,
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
|
||||
@@ -11,6 +11,10 @@ struct Stars {
|
||||
return stars.clip_from_sky * vec4(position, 1.0);
|
||||
}
|
||||
|
||||
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
|
||||
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
|
||||
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
return stars.color;
|
||||
let b = stars.color.a;
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -543,6 +543,9 @@ pub struct Graphics {
|
||||
pub lod_distance: u32,
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Far terrain from servers' BlueMap web maps; no server has one unless
|
||||
/// the player names it (`blueMap:` lines) or turns on auto-detect.
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
@@ -551,6 +554,20 @@ pub struct Graphics {
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Cache settled shulker-box models.
|
||||
pub cache_shulker_boxes: bool,
|
||||
/// Cache sign-text geometry.
|
||||
pub cache_signs: bool,
|
||||
/// Cache decorated pots outside their wobble animation.
|
||||
pub cache_decorated_pots: bool,
|
||||
/// Cache bells while they are not ringing.
|
||||
pub cache_bells: bool,
|
||||
/// Cache closed chests, including copper variants.
|
||||
pub cache_chests: bool,
|
||||
/// Cache banner poles while leaving their flags animated.
|
||||
pub cache_banners: bool,
|
||||
/// Cache copper-golem statue models.
|
||||
pub cache_copper_golem_statues: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -627,9 +644,18 @@ impl Default for Graphics {
|
||||
preset: Preset::Custom,
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
cache_shulker_boxes: false,
|
||||
// Sign text has used its renderer cache independently of the new model caches.
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: false,
|
||||
cache_bells: false,
|
||||
cache_chests: false,
|
||||
cache_banners: false,
|
||||
cache_copper_golem_statues: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -670,6 +696,14 @@ impl Graphics {
|
||||
let mut g: Self = fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default();
|
||||
g.blue_map.servers = match fs::read_to_string(dir.join(crate::bluemap::SERVERS_FILE)) {
|
||||
Ok(text) => crate::bluemap::Settings::parse_servers(&text),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}; no BlueMap is named", crate::bluemap::SERVERS_FILE);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
g.save(dir);
|
||||
@@ -698,9 +732,38 @@ impl Graphics {
|
||||
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"blueMapRadius" => {
|
||||
if let Some(radius) = crate::bluemap::Settings::parse_radius(value) {
|
||||
g.blue_map.radius = radius;
|
||||
}
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"blockEntityCache" => {
|
||||
let enabled = flag(false);
|
||||
g.cache_shulker_boxes = enabled;
|
||||
g.cache_signs = enabled;
|
||||
g.cache_decorated_pots = enabled;
|
||||
g.cache_bells = enabled;
|
||||
g.cache_chests = enabled;
|
||||
g.cache_banners = enabled;
|
||||
g.cache_copper_golem_statues = enabled;
|
||||
}
|
||||
"blockEntityCacheShulkerBoxes" => {
|
||||
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
|
||||
}
|
||||
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
|
||||
"blockEntityCacheDecoratedPots" => {
|
||||
g.cache_decorated_pots = flag(g.cache_decorated_pots)
|
||||
}
|
||||
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
|
||||
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
|
||||
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
|
||||
"blockEntityCacheCopperGolemStatues" => {
|
||||
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
|
||||
}
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -766,14 +829,21 @@ impl Graphics {
|
||||
g
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\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",
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\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",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
bool_text(self.cache_signs),
|
||||
bool_text(self.cache_decorated_pots),
|
||||
bool_text(self.cache_bells),
|
||||
bool_text(self.cache_chests),
|
||||
bool_text(self.cache_banners),
|
||||
bool_text(self.cache_copper_golem_statues),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -810,7 +880,9 @@ impl Graphics {
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
)
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
@@ -937,9 +1009,22 @@ mod tests {
|
||||
preset: Preset::Balanced,
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
blue_map: crate::bluemap::Settings {
|
||||
radius: 40,
|
||||
auto_detect: true,
|
||||
// In their own file (`bluemap::SERVERS_FILE`).
|
||||
servers: Vec::new(),
|
||||
},
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
cache_shulker_boxes: true,
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: true,
|
||||
cache_bells: true,
|
||||
cache_chests: true,
|
||||
cache_banners: true,
|
||||
cache_copper_golem_statues: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
@@ -986,6 +1071,13 @@ mod tests {
|
||||
Graphics::parse("entityCulling:false\n").entity_culling,
|
||||
EntityCullingMode::Off
|
||||
);
|
||||
// A map named here (a shared instance's, a pack's) is no map.
|
||||
assert!(
|
||||
Graphics::parse("blueMap:play.example.net https://map.example.net\n")
|
||||
.blue_map
|
||||
.servers
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is on, in files from before it too; only false turns it off,
|
||||
// and only under its key: the old one held the false every save wrote while it was
|
||||
|
||||
@@ -28,9 +28,18 @@ pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
CacheShulkerBoxes,
|
||||
CacheSigns,
|
||||
CacheDecoratedPots,
|
||||
CacheBells,
|
||||
CacheChests,
|
||||
CacheBanners,
|
||||
CacheCopperGolemStatues,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -183,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
h(
|
||||
H::BlueMapAutoDetect,
|
||||
None,
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
),
|
||||
h(
|
||||
H::BlueMapRadius,
|
||||
None,
|
||||
"How far around you BlueMap tiles are downloaded. Next join.",
|
||||
),
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -207,13 +226,49 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Hides entities behind opaque blocks.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
),
|
||||
Row::Header("Block Entities"),
|
||||
h(
|
||||
H::CacheShulkerBoxes,
|
||||
Saves,
|
||||
"Reuses settled boxes; lid movement stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheSigns,
|
||||
Saves,
|
||||
"Reuses sign text until text, light, or font changes.",
|
||||
),
|
||||
h(
|
||||
H::CacheDecoratedPots,
|
||||
Saves,
|
||||
"Reuses still pots; break wobble and sherd changes redraw.",
|
||||
),
|
||||
h(
|
||||
H::CacheBells,
|
||||
Saves,
|
||||
"Reuses still bells; ringing stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheChests,
|
||||
Saves,
|
||||
"Reuses closed chests; moving lids stay animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheBanners,
|
||||
Saves,
|
||||
"Reuses poles; flags and pattern layers still update.",
|
||||
),
|
||||
h(
|
||||
H::CacheCopperGolemStatues,
|
||||
Saves,
|
||||
"Reuses statue geometry until its block state changes.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
Opt::SmoothLighting,
|
||||
@@ -387,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
@@ -470,6 +527,11 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::EntityCulling), _) => match s.horizon.entity_culling {
|
||||
EntityCullingMode::Off => "No entity occlusion culling.",
|
||||
EntityCullingMode::Synchronous => "CPU occlusion test runs this frame.",
|
||||
EntityCullingMode::Asynchronous => "Occlusion checks run on a worker thread.",
|
||||
},
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
@@ -548,9 +610,23 @@ impl H {
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
|
||||
H::BlueMapRadius => labelled(
|
||||
"BlueMap Radius",
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
|
||||
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
|
||||
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
|
||||
H::CacheBells => labelled("Bells", on_off(g.cache_bells)),
|
||||
H::CacheChests => labelled("Chests", on_off(g.cache_chests)),
|
||||
H::CacheBanners => labelled("Banners", on_off(g.cache_banners)),
|
||||
H::CacheCopperGolemStatues => {
|
||||
labelled("Copper Golem Statues", on_off(g.cache_copper_golem_statues))
|
||||
}
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -686,6 +762,10 @@ impl H {
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
|
||||
H::BlueMapRadius => {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
@@ -693,6 +773,15 @@ impl H {
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
|
||||
H::CacheSigns => g.cache_signs = !g.cache_signs,
|
||||
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
|
||||
H::CacheBells => g.cache_bells = !g.cache_bells,
|
||||
H::CacheChests => g.cache_chests = !g.cache_chests,
|
||||
H::CacheBanners => g.cache_banners = !g.cache_banners,
|
||||
H::CacheCopperGolemStatues => {
|
||||
g.cache_copper_golem_statues = !g.cache_copper_golem_statues
|
||||
}
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -789,7 +878,15 @@ impl Item {
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
|
||||
Item::Horizon(H::CacheBells) => g.cache_bells,
|
||||
Item::Horizon(H::CacheChests) => g.cache_chests,
|
||||
Item::Horizon(H::CacheBanners) => g.cache_banners,
|
||||
Item::Horizon(H::CacheCopperGolemStatues) => g.cache_copper_golem_statues,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
@@ -853,6 +950,8 @@ impl Item {
|
||||
self,
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::BlueMapAutoDetect
|
||||
| H::BlueMapRadius
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
@@ -1290,6 +1389,11 @@ impl GraphicsUi {
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Action> {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !s.extras.wheel_cycles_settings {
|
||||
self.scroll(level, s, h, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
// Horizon improvement: focused slider/cycle intercepts wheel first.
|
||||
if amount != 0.0
|
||||
&& self.can_change_value
|
||||
@@ -1818,6 +1922,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blue_map_settings_are_advanced_rendering_options() {
|
||||
let mut s = Settings::default();
|
||||
let find = Item::Horizon(H::BlueMapAutoDetect);
|
||||
let radius = Item::Horizon(H::BlueMapRadius);
|
||||
let advanced: Vec<_> = rows(Level::Advanced)
|
||||
.into_iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Setting(setting) => Some(setting.item),
|
||||
Row::Header(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(advanced.contains(&find) && advanced.contains(&radius));
|
||||
assert_eq!(find.switch(&s), Some(false), "off by default");
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
|
||||
find.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.horizon.blue_map.auto_detect);
|
||||
radius.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.blue_map.radius, 128);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1907,8 +2036,8 @@ mod tests {
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
// The culling group is followed by the separate block-entity cache
|
||||
// group, then Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
@@ -1928,6 +2057,27 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Block Entities"))
|
||||
));
|
||||
for (offset, cache) in [
|
||||
H::CacheShulkerBoxes,
|
||||
H::CacheSigns,
|
||||
H::CacheDecoratedPots,
|
||||
H::CacheBells,
|
||||
H::CacheChests,
|
||||
H::CacheBanners,
|
||||
H::CacheCopperGolemStatues,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(matches!(
|
||||
rows.get(culling + 5 + offset),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(cache)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
rows.get(culling + 12),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
@@ -2159,6 +2309,15 @@ mod tests {
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
|
||||
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let scroll_before = ui.scroll.amount;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
assert!(ui.scroll.amount > scroll_before);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.scroll.amount = scroll_before;
|
||||
|
||||
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
|
||||
s.horizon.chunk_updates = 16;
|
||||
|
||||
@@ -59,6 +59,9 @@ impl TextureFlags {
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
/// Identity of this packed atlas, used to invalidate cached model UV data
|
||||
/// even when a resource reload places sprites at the same coordinates.
|
||||
pub revision: u64,
|
||||
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
|
||||
/// compressed them (read them through `pixels()`).
|
||||
pub pixels: Vec<u8>,
|
||||
@@ -129,7 +132,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
decode_bytes(&horizon_core::prepared_files::read(path)?)
|
||||
}
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
@@ -181,9 +184,10 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
@@ -362,7 +366,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
|
||||
// armour layers are twice as wide as they are tall.
|
||||
@@ -460,7 +465,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&file.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
@@ -525,7 +533,66 @@ fn galactic_widths(
|
||||
}
|
||||
widths
|
||||
}
|
||||
/// `TextureAtlas.dumpContents` (`F3 + S`, vanilla's "dynamic textures"): the atlas
|
||||
/// written as `name.png` beside `name.txt`, every sprite in name order with its
|
||||
/// rect in texels (`dumpSpriteNames`). Vanilla names each file after its atlas
|
||||
/// with `/` and `:` as `_` (`Identifier.toDebugFileName`); Horizon packs the GUI,
|
||||
/// font, particle, map and skin sprites into one atlas, so one file stands in for
|
||||
/// the atlases vanilla dumps apart.
|
||||
pub fn dump_atlas(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: &[u8],
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
// A build whose pixels were freed (`fonts`) has the size and no texels:
|
||||
// write nothing rather than a file every reader would reject.
|
||||
if pixels.len() != (width as usize) * (height as usize) * 4 {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"wrong data size, expected {} got {}",
|
||||
(width as usize) * (height as usize) * 4,
|
||||
pixels.len()
|
||||
)));
|
||||
}
|
||||
fs::create_dir_all(dir)?;
|
||||
let png_path = dir.join(format!("{name}.png"));
|
||||
let mut encoder = png::Encoder::new(fs::File::create(&png_path)?, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
|
||||
writer
|
||||
.write_image_data(pixels)
|
||||
.map_err(std::io::Error::other)?;
|
||||
drop(writer);
|
||||
let mut names = sprites.iter().collect::<Vec<_>>();
|
||||
names.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let mut list = String::new();
|
||||
for (sprite, rect) in names {
|
||||
let (w, h) = rect.texels((width, height));
|
||||
let x = (rect.u0 * width as f32).round() as u32;
|
||||
let y = (rect.v0 * height as f32).round() as u32;
|
||||
list.push_str(&format!("{sprite}\tx={x}\ty={y}\tw={w}\th={h}\n"));
|
||||
}
|
||||
fs::write(dir.join(format!("{name}.txt")), list)?;
|
||||
Ok(png_path)
|
||||
}
|
||||
|
||||
impl HudAtlas {
|
||||
/// `TextureAtlas.dumpContents` for this atlas: `dir` is `screenshots/debug`,
|
||||
/// the folder `TextureUtil.getDebugTexturePath` names.
|
||||
pub fn dump_contents(&self, dir: &Path, name: &str) -> std::io::Result<PathBuf> {
|
||||
// `pixels()`: the copy `pack_pixels` compressed once the GPU had the
|
||||
// atlas, decompressed; vanilla reads the texture back from the GPU.
|
||||
let pixels = self.pixels();
|
||||
dump_atlas(dir, name, self.width, self.height, &pixels, &self.sprites)
|
||||
}
|
||||
|
||||
fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// The atlas's pixels: as built, or uncompressed again after
|
||||
/// `pack_pixels` (a renderer being rebuilt).
|
||||
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
@@ -700,6 +767,7 @@ impl HudAtlas {
|
||||
);
|
||||
let white = sprites["horizon/white"];
|
||||
Self {
|
||||
revision: Self::next_revision(),
|
||||
font: crate::font::Font::load(
|
||||
Path::new("horizon-boot"),
|
||||
&sprites,
|
||||
@@ -952,7 +1020,9 @@ impl HudAtlas {
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
@@ -1143,6 +1213,7 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
@@ -2551,6 +2622,51 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
|
||||
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let pixels: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
|
||||
let rect = |u0: f32, v0: f32| Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 0.5,
|
||||
v1: v0 + 0.5,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
let sprites = HashMap::from([
|
||||
("particle/smoke".to_string(), rect(0.5, 0.0)),
|
||||
("gui/sprites/bars/experience".to_string(), rect(0.0, 0.5)),
|
||||
]);
|
||||
let path = dump_atlas(
|
||||
&root,
|
||||
"minecraft_textures_atlas_gui",
|
||||
2,
|
||||
2,
|
||||
&pixels,
|
||||
&sprites,
|
||||
)
|
||||
.expect("the dump writes");
|
||||
assert_eq!(path, root.join("minecraft_textures_atlas_gui.png"));
|
||||
let written = fs::read(&path).expect("the PNG is there");
|
||||
let (width, height, rgba) = decode_bytes(&written).expect("the PNG decodes");
|
||||
assert_eq!((width, height), (2, 2));
|
||||
assert_eq!(rgba, pixels);
|
||||
// Sorted by sprite name, rects in texels, as `dumpSpriteNames` writes them.
|
||||
let mut expected = String::from("gui/sprites/bars/experience\tx=0\ty=1\tw=1\th=1\n");
|
||||
expected.push_str("particle/smoke\tx=1\ty=0\tw=1\th=1\n");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("minecraft_textures_atlas_gui.txt")).unwrap(),
|
||||
expected
|
||||
);
|
||||
// A build with no texels in hand writes no file at all.
|
||||
let empty = dump_atlas(&root, "empty", 2, 2, &[], &sprites);
|
||||
assert!(empty.is_err());
|
||||
assert!(!root.join("empty.png").exists());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bytes_ignores_crc_checksum_error() {
|
||||
let mut encoded = Vec::new();
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
@@ -38,6 +40,7 @@ pub mod extras;
|
||||
pub mod fast_hash;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
@@ -81,6 +84,7 @@ pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod server_terrain;
|
||||
pub mod settings;
|
||||
pub mod shader_options;
|
||||
pub mod shader_pack_list;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1347,6 +1347,8 @@ pub struct Mesher<'a> {
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
pub assets: &'a Assets,
|
||||
pub render: &'a RenderAssets,
|
||||
pub tables: &'a MeshTables,
|
||||
@@ -1434,7 +1436,11 @@ impl Mesher<'_> {
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let shade = (self.nether_shading, self.shader_pack_lighting);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
self.separate_ao,
|
||||
);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
@@ -1582,27 +1588,57 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
let (gray, coords) = if ao {
|
||||
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
|
||||
let (gray, coords, ao_factor) = if ao {
|
||||
let info = &self.tables.info;
|
||||
let (brightness, coords) =
|
||||
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
|
||||
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
|
||||
(gray, coords)
|
||||
let gray = if self.separate_ao {
|
||||
[lighting::gray(directional); 4]
|
||||
} else {
|
||||
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
|
||||
};
|
||||
(gray, coords, brightness)
|
||||
} else {
|
||||
let coords = match q.cull {
|
||||
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
|
||||
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
|
||||
};
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
|
||||
// drawn over it (a few per face at most; more are dropped).
|
||||
let connected = &self.render.connected;
|
||||
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
|
||||
// and the overlays drawn over it (a few per face at most; more are dropped).
|
||||
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let pick = if connected.is_empty() {
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour =
|
||||
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
id,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
q.tint_index,
|
||||
neighbour,
|
||||
|s| {
|
||||
self.tables
|
||||
.info
|
||||
.get(s as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
});
|
||||
let pick = if let Some(pick) = fused {
|
||||
pick
|
||||
} else if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
@@ -1611,7 +1647,7 @@ impl Mesher<'_> {
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
neighbour,
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
@@ -1621,12 +1657,7 @@ impl Mesher<'_> {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
|
||||
@@ -1637,12 +1668,18 @@ impl Mesher<'_> {
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
let color = if self.separate_ao {
|
||||
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
|
||||
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
|
||||
} else {
|
||||
emitting(pack_rgb(rgb), tier)
|
||||
};
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
color,
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
uv[i],
|
||||
@@ -1696,6 +1733,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Pick::Nothing => {}
|
||||
}
|
||||
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
|
||||
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
|
||||
@@ -1708,6 +1746,7 @@ impl Mesher<'_> {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
|
||||
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
|
||||
/// the faces (`Mesher::shader_pack_lighting`).
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting): (bool, bool),
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
pad: &Padded,
|
||||
water_color: u32,
|
||||
out: &mut SectionMesh,
|
||||
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
if separate_ao {
|
||||
pack_rgb(rgb)
|
||||
} else {
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
}
|
||||
};
|
||||
let height = |p: [i32; 3]| -> f32 {
|
||||
let s = at(p);
|
||||
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
|
||||
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
|
||||
sprite_layers.push(layer);
|
||||
}
|
||||
let color = fluid_face(color, face);
|
||||
let color = if separate_ao {
|
||||
color
|
||||
} else {
|
||||
fluid_face(color, face)
|
||||
};
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
if back {
|
||||
|
||||
@@ -72,6 +72,7 @@ fn mesh(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
|
||||
@@ -132,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
@@ -158,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
// Place stone with neighbouring blocks to create AO variations.
|
||||
chunk.set(8, MIN_Y + 8, 8, stone);
|
||||
chunk.set(9, MIN_Y + 8, 8, stone);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
assert!(!mesh.solid.is_empty());
|
||||
for v in &mesh.solid {
|
||||
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
|
||||
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
|
||||
// Alpha holds the AO factor, which must be non-zero.
|
||||
assert!(v.color >> 24 > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
@@ -209,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
|
||||
@@ -522,6 +522,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
let mut world = world_with(
|
||||
(-2..=2)
|
||||
@@ -609,6 +610,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
// Grass everywhere, one stone block 10 blocks north of the player.
|
||||
let mut world = world_with(
|
||||
|
||||
@@ -44,7 +44,7 @@ impl ParsedModels {
|
||||
}
|
||||
}
|
||||
let parsed = crate::par::par_map(&files, |(_, path)| {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
|
||||
CuboidModel::from_json(&text).ok().map(Arc::new)
|
||||
});
|
||||
let models = files
|
||||
@@ -89,8 +89,8 @@ impl ParsedModels {
|
||||
let read = || -> Result<CuboidModel> {
|
||||
let (namespace, path) =
|
||||
name.split_once(':').ok_or("invalid model identifier")?;
|
||||
let text = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
let text = horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/models/{path}.json")),
|
||||
)?;
|
||||
Ok(CuboidModel::from_json(&text)?)
|
||||
};
|
||||
|
||||
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
|
||||
.state(state)
|
||||
.ok_or("missing gallery registry state")?;
|
||||
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
|
||||
let selected = catalog.collect(state, position)?;
|
||||
// A state no definition binds (a pack's partial blockstate file
|
||||
// without vanilla's below it) is left out, as the mesher skips it,
|
||||
// instead of failing the whole pack.
|
||||
let selected = match catalog.collect(state, position) {
|
||||
Ok(selected) => selected,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: gallery block {name} has no model: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
|
||||
"position":[position.x,position.y,position.z],
|
||||
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct BakedFace {
|
||||
pub opaque: bool,
|
||||
/// Fractional alpha: drawn in the sorted translucent pass.
|
||||
pub translucent: bool,
|
||||
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
|
||||
/// two bytes, within the padding every face already has.
|
||||
pub fusion: Option<std::num::NonZeroU16>,
|
||||
}
|
||||
pub struct BakedModel {
|
||||
pub ambient_occlusion: bool,
|
||||
@@ -90,6 +93,7 @@ fn bake_resolved(
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
fusion: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
@@ -101,6 +105,35 @@ fn bake_resolved(
|
||||
cube,
|
||||
})
|
||||
}
|
||||
/// The definitions a blockstate file holds, bottom first: the layers of a
|
||||
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
|
||||
/// file itself.
|
||||
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
return Ok(vec![text.to_owned()]);
|
||||
}
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
|
||||
_ => Ok(vec![text.to_owned()]),
|
||||
}
|
||||
}
|
||||
/// Each state's model from definitions bound bottom first: the topmost one
|
||||
/// that binds the state, as (its layer, its selection). A higher pack's
|
||||
/// definition replaces only the states it binds (`putAll` in
|
||||
/// `loadBlockStateDefinitionStack`).
|
||||
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
|
||||
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
|
||||
for (layer, bound) in layers.into_iter().enumerate() {
|
||||
picked.resize(picked.len().max(bound.len()), None);
|
||||
for (slot, selected) in picked.iter_mut().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
*slot = Some((layer, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
impl StateModels {
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
@@ -190,21 +223,17 @@ impl StateModels {
|
||||
.collect::<Result<_>>()?;
|
||||
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
|
||||
// Vanilla's BlockStateModelLoader: a definition that does not
|
||||
// read or bind is logged and its block gets no model; the other
|
||||
// blocks still load.
|
||||
let loaded = fs::read_to_string(
|
||||
root.join(format!("assets/{namespace}/blockstates/{path}.json")),
|
||||
// read or bind is logged and left out; with none left the block
|
||||
// gets no model, and the other blocks still load. A file several
|
||||
// packs provide binds each pack's definition on its own, bottom
|
||||
// first (`loadBlockStateDefinitionStack`).
|
||||
let layers = match horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
let (definition, bound) = match loaded {
|
||||
Ok(loaded) => loaded,
|
||||
.and_then(|text| definition_layers(&text))
|
||||
{
|
||||
Ok(layers) => layers,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
@@ -212,6 +241,26 @@ impl StateModels {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut loaded = Vec::with_capacity(layers.len());
|
||||
for text in &layers {
|
||||
let layer = BlockStateDefinition::from_json(text)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
match layer {
|
||||
Ok(layer) => loaded.push(layer),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
if loaded.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let native_order: Vec<_> = by_name
|
||||
.get(name.as_str())
|
||||
.map(Vec::as_slice)
|
||||
@@ -229,32 +278,36 @@ impl StateModels {
|
||||
"model state definitions disagree with the imported block registry".into(),
|
||||
);
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
|
||||
let first = choices.len();
|
||||
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
|
||||
if let Some((layer, selected)) = picked {
|
||||
states[id.0 as usize] = Some((first + layer, selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
for definition in definitions {
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
|
||||
let library = match models.block_library(root, names.clone()) {
|
||||
@@ -343,6 +396,12 @@ impl StateModels {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Every baked model with the variant it was baked from, to change its faces.
|
||||
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
|
||||
self.baked
|
||||
.iter_mut()
|
||||
.map(|(variant, model)| (&*variant, model))
|
||||
}
|
||||
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
|
||||
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
|
||||
let &i = self.standalone.get(name)?;
|
||||
@@ -418,6 +477,44 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::model_library::ParsedModels;
|
||||
|
||||
/// #7013: a pack's blockstate file that defines only some states (here
|
||||
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
|
||||
/// rest, as vanilla binds each pack's definition and lets a higher one
|
||||
/// replace only what it binds.
|
||||
#[test]
|
||||
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
|
||||
let schema: PropertySchema = BTreeMap::from([(
|
||||
"powered".to_owned(),
|
||||
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
|
||||
)]);
|
||||
let states: Vec<Properties> = ["false", "true"]
|
||||
.iter()
|
||||
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
|
||||
.collect();
|
||||
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
|
||||
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
|
||||
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
|
||||
]})
|
||||
.to_string();
|
||||
let layers = definition_layers(&stacked).unwrap();
|
||||
assert_eq!(layers.len(), 2);
|
||||
let bound = layers
|
||||
.iter()
|
||||
.map(|text| {
|
||||
BlockStateDefinition::from_json(text)
|
||||
.unwrap()
|
||||
.bind(&schema, &states)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let picked = topmost(bound);
|
||||
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
|
||||
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
|
||||
// A file that isn't stacked is one definition.
|
||||
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
|
||||
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
|
||||
@@ -119,6 +119,7 @@ mod tests {
|
||||
layer: 7,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
let Some(styles) = cube_styles(&faces) else {
|
||||
@@ -153,6 +154,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
faces[0].opaque = false;
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
@@ -937,6 +936,7 @@ impl Opt {
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::RotateWithMinecarts
|
||||
| Opt::NarratorHotkey => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
@@ -1308,6 +1308,10 @@ enum Act {
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
/// AI Agent: start or stop the repro recording.
|
||||
ToggleRecording,
|
||||
/// AI Agent: write the repro bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -1435,6 +1439,10 @@ pub enum Event {
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The AI Agent page's recording button: start or stop.
|
||||
ToggleRecording,
|
||||
/// The AI Agent page's export button.
|
||||
ExportRepro,
|
||||
/// The narrator option changed (after its `Changed`): the option's
|
||||
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
|
||||
Narrator,
|
||||
@@ -1504,6 +1512,8 @@ pub struct OptionsUi {
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
|
||||
pub minecart_improvements: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
@@ -1515,6 +1525,8 @@ pub struct OptionsUi {
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// A repro recording is running: the AI Agent page's button says stop.
|
||||
pub agent_recording: bool,
|
||||
/// Keyboard focus: the interactive target currently focused.
|
||||
focus: Option<FocusId>,
|
||||
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
|
||||
@@ -1566,6 +1578,7 @@ impl Default for OptionsUi {
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
minecart_improvements: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
@@ -1573,6 +1586,7 @@ impl Default for OptionsUi {
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
agent_recording: false,
|
||||
focus: None,
|
||||
focus_from_keyboard: false,
|
||||
can_change_value: false,
|
||||
@@ -2378,6 +2392,30 @@ impl OptionsUi {
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr(if self.agent_recording {
|
||||
"horizon.agent.record_stop"
|
||||
} else {
|
||||
"horizon.agent.record_session"
|
||||
}),
|
||||
Act::ToggleRecording,
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.agent.export_repro"),
|
||||
Act::ExportRepro,
|
||||
)),
|
||||
));
|
||||
rows.push(Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::ReproKeys),
|
||||
None,
|
||||
));
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
@@ -3201,6 +3239,14 @@ impl OptionsUi {
|
||||
if widget.list {
|
||||
widget.y += previous_scroll - self.scroll;
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(Opt::RotateWithMinecarts),
|
||||
active,
|
||||
..
|
||||
} = &mut widget.kind
|
||||
{
|
||||
*active = self.in_world && self.minecart_improvements;
|
||||
}
|
||||
}
|
||||
self.row_bands = rows
|
||||
.bands
|
||||
@@ -3350,6 +3396,8 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
// The controller picks a tile up between presses: the screen's size from the draw.
|
||||
self.split_setup.size = (batch.width, batch.height);
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
@@ -3388,6 +3436,9 @@ impl OptionsUi {
|
||||
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
|
||||
}
|
||||
let mut tooltip = None;
|
||||
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
|
||||
// keeps it against that widget, not at the pointer.
|
||||
let mut tooltip_rect: Option<[f32; 4]> = None;
|
||||
for (wd_idx, wd) in widgets.iter().enumerate() {
|
||||
if !visible(wd) {
|
||||
continue;
|
||||
@@ -3397,9 +3448,9 @@ impl OptionsUi {
|
||||
.as_ref()
|
||||
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
|
||||
let start = batch.vertices.len();
|
||||
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|
||||
|| is_focused;
|
||||
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
|
||||
let hover = pointer_over || is_focused;
|
||||
// A pack's option: its label within the widget (the name shortened), and on hover
|
||||
// the whole label when it was, then what the pack says of it.
|
||||
let shader = s.shader_options.as_ref();
|
||||
@@ -3443,6 +3494,7 @@ impl OptionsUi {
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
tooltip = Some(lines);
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
}
|
||||
if hover && let Some(tip) = tooltip_text(s, wd) {
|
||||
@@ -3457,6 +3509,7 @@ impl OptionsUi {
|
||||
.collect::<Vec<_>>(),
|
||||
_ => atlas.font.split(&tip, 170.0),
|
||||
});
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, .. } => {
|
||||
@@ -3628,7 +3681,10 @@ impl OptionsUi {
|
||||
self.settings_search = search;
|
||||
}
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
match tooltip_rect {
|
||||
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
|
||||
None => ui::tooltip(batch, atlas, &lines, cursor),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Mouse press/drag. Returns events for the game.
|
||||
@@ -4026,6 +4082,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -4188,7 +4246,8 @@ impl OptionsUi {
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !plain || !s.extras.wheel_cycles_settings {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -4970,6 +5029,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -7274,6 +7335,55 @@ mod tests {
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_only_scrolls_with_the_extra_off() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let size = (427.0, 240.0);
|
||||
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let (opt, over) = widgets
|
||||
.iter()
|
||||
.find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
|
||||
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let before = format!("{s:?}");
|
||||
let start = ui.scroll;
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(
|
||||
ui.scroll > start,
|
||||
"the list scrolls under the cursor instead"
|
||||
);
|
||||
// Keyboard focus doesn't take the wheel either.
|
||||
ui.focus = Some(FocusId::Cycle(opt));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before);
|
||||
|
||||
// On (the default), vanilla's CycleButton.mouseScrolled again.
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
let before = format!("{s:?}");
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
|
||||
let stepped_opts = [
|
||||
|
||||
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
|
||||
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
@@ -840,7 +841,14 @@ enum Merge {
|
||||
Font,
|
||||
/// `SpriteSourceList.load`: sources from the bottom pack up.
|
||||
Atlas,
|
||||
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
|
||||
/// definition binds on its own, and a higher one replaces only the states
|
||||
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
|
||||
BlockStates,
|
||||
}
|
||||
/// The key of a stacked blockstate file: its definitions, bottom first, each
|
||||
/// as the pack wrote it.
|
||||
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
|
||||
fn merge_kind(name: &str) -> Option<Merge> {
|
||||
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
|
||||
if rest == "sounds.json" {
|
||||
@@ -851,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
"lang" => Some(Merge::Lang),
|
||||
"font" => Some(Merge::Font),
|
||||
"atlases" => Some(Merge::Atlas),
|
||||
"blockstates" => Some(Merge::BlockStates),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -858,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
|
||||
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
use serde_json::{Map, Value};
|
||||
let parsed: Vec<Map<String, Value>> = layers
|
||||
let mut parsed: Vec<Map<String, Value>> = layers
|
||||
.iter()
|
||||
// SoundManager skips a sounds.json Gson rejects, whole.
|
||||
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
|
||||
@@ -901,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
}
|
||||
Merge::Font => list("providers", &mut parsed.iter().rev()),
|
||||
Merge::Atlas => list("sources", &mut parsed.iter()),
|
||||
// One definition is the file as it is.
|
||||
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
|
||||
Merge::BlockStates => Value::Object(Map::from_iter([(
|
||||
BLOCKSTATE_LAYERS.to_owned(),
|
||||
Value::Array(parsed.into_iter().map(Value::Object).collect()),
|
||||
)])),
|
||||
};
|
||||
serde_json::to_vec(&out).ok()
|
||||
}
|
||||
@@ -1404,6 +1419,36 @@ mod tests {
|
||||
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
|
||||
);
|
||||
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
|
||||
// BlockStateModelLoader: every definition kept, bottom first, for the
|
||||
// catalog to bind one by one; one that does not parse is skipped.
|
||||
let gate = merge(
|
||||
Merge::BlockStates,
|
||||
&[
|
||||
json(r#"{"variants":{"":{"model":"v"}}}"#),
|
||||
b"not json".to_vec(),
|
||||
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(gate.unwrap()),
|
||||
serde_json::json!({BLOCKSTATE_LAYERS:[
|
||||
{"variants":{"":{"model":"v"}}},
|
||||
{"variants":{"powered=true":{"model":"p"}}}
|
||||
]})
|
||||
);
|
||||
// A single definition stays the file as it is.
|
||||
let alone = merge(
|
||||
Merge::BlockStates,
|
||||
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(alone.unwrap()),
|
||||
serde_json::json!({"variants":{"":{"model":"p"}}})
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
|
||||
Some(Merge::BlockStates)
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/lang/en_us.json"),
|
||||
Some(Merge::Lang)
|
||||
|
||||
@@ -12,7 +12,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -1158,7 +1158,9 @@ impl ParticleEngine {
|
||||
}
|
||||
let dir = root.join("assets/minecraft/particles");
|
||||
for &ty in ParticleType::ALL {
|
||||
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
|
||||
let Ok(bytes) =
|
||||
horizon_core::prepared_files::read(&dir.join(format!("{}.json", ty.name())))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
//! Far terrain on servers: every chunk a server sends is kept as its
|
||||
//! far-terrain summary (`lod::Column`s), on disk per server, world and
|
||||
//! dimension, and drawn beyond the server's view distance on this visit and
|
||||
//! the next ones. A server never sends its seed, so its
|
||||
//! distant terrain can't be generated as a local world's is.
|
||||
//!
|
||||
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
|
||||
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
|
||||
//! A chunk it doesn't have may be in the player's Bobby cache of the same
|
||||
//! level (`Bobby`), only ever read; past both, the server's BlueMap may fill
|
||||
//! in (`bluemap`).
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
bluemap::{BlueMap, Found},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Spans, Surface, state_kinds, summarize},
|
||||
streaming::{LodDetail, LodSampler, LodSource},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
compression::Compression,
|
||||
nbt::{Limits, NamedTag, Tag},
|
||||
region::{ChunkRecord, RegionFile},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// Bumped when the summary layout changes; older records are ignored.
|
||||
const FORMAT: i32 = 1;
|
||||
/// Decoded chunks kept in memory, and open region files.
|
||||
const MAX_CHUNKS: usize = 8192;
|
||||
const MAX_REGIONS: usize = 64;
|
||||
/// The widest cell, in chunks, drawn from the chunks kept under it
|
||||
/// (`Sampler::reduced_column`): its regions all stay open.
|
||||
const MAX_KEPT_SIDE: i32 = 128;
|
||||
|
||||
type Columns = Arc<[Column; 256]>;
|
||||
|
||||
/// The folder of a server's terrain: its address as typed, lowercased, with
|
||||
/// the default port left out, and every character a file name can't hold
|
||||
/// replaced (`play.example.net:25566` → `play.example.net_25566`).
|
||||
pub fn server_folder(address: &str) -> String {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let address = address.strip_suffix(":25565").unwrap_or(&address);
|
||||
let name: String = address
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = name.trim_matches('.');
|
||||
if name.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
name.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// `root/<server>/<seed>/<namespace>/<path>` for a level key
|
||||
/// (`minecraft:overworld`) and the login's hashed seed, which keeps
|
||||
/// the worlds behind one address (a proxy's servers, a reset) apart.
|
||||
pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> PathBuf {
|
||||
root.join(server_folder(address))
|
||||
.join(hashed_seed.to_string())
|
||||
.join(level_path(level))
|
||||
}
|
||||
|
||||
/// The official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set),
|
||||
/// where Bobby keeps its cache (`.bobby`): as
|
||||
/// `horizon_prepare::artifacts::minecraft_dir` finds it, which this crate
|
||||
/// can't depend on.
|
||||
pub fn minecraft_dir() -> PathBuf {
|
||||
let home = || {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var).map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
};
|
||||
if let Some(dir) = std::env::var_os("HORIZON_MINECRAFT_DIR").filter(|d| !d.is_empty()) {
|
||||
let dir = PathBuf::from(dir);
|
||||
if let Ok(rest) = dir.strip_prefix("~") {
|
||||
return home().join(rest);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
home().join("Library/Application Support/minecraft")
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA")
|
||||
.map_or_else(home, PathBuf::from)
|
||||
.join(".minecraft")
|
||||
} else {
|
||||
home().join(".minecraft")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bobby's folder of a level under `root` (`.bobby`), as Bobby leaves it on
|
||||
/// disk: `<address as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>`.
|
||||
/// Names are compared without case, and `host` and `host_25565` both stand
|
||||
/// for the default port: neither was seen, so both are taken.
|
||||
pub fn bobby_level_dir(
|
||||
root: &Path,
|
||||
address: &str,
|
||||
hashed_seed: i64,
|
||||
level: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let typed = address.trim().replace(':', "_");
|
||||
let bare = typed.strip_suffix("_25565").unwrap_or(&typed);
|
||||
if bare.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names = [bare.to_owned(), format!("{bare}_25565")];
|
||||
let mut servers: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| names.iter().any(|n| n.eq_ignore_ascii_case(name)))
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
servers.sort();
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|server| server.join(hashed_seed.to_string()).join(level_path(level)))
|
||||
.find(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// `<namespace>/<path>` of a level key.
|
||||
fn level_path(level: &str) -> PathBuf {
|
||||
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
|
||||
let clean = |s: &str| -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
let mut dir = PathBuf::from(clean(namespace));
|
||||
for part in path.split('/').map(clean).filter(|p| !p.is_empty()) {
|
||||
dir.push(part);
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
struct Region {
|
||||
file: RegionFile,
|
||||
present: [bool; 1024],
|
||||
}
|
||||
|
||||
/// Open region files by region; `None`: the file doesn't exist.
|
||||
type Regions = FxHashMap<(i32, i32), Option<Region>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
regions: Regions,
|
||||
/// Bobby's, opened read-only.
|
||||
bobby: Regions,
|
||||
/// `None`: the chunk isn't stored.
|
||||
chunks: FxHashMap<(i32, i32), Option<Columns>>,
|
||||
}
|
||||
|
||||
/// A saved full chunk with its stored light, or `None` (`save::decode_lit`
|
||||
/// in the game).
|
||||
pub type DecodeChunk = Box<dyn Fn(&NamedTag) -> Option<Chunk> + Send + Sync>;
|
||||
|
||||
/// The chunks the player's Bobby kept of the same level: vanilla chunk NBT
|
||||
/// in region files, as Bobby leaves them on disk. Only ever read; a chunk
|
||||
/// far terrain needs from there is kept in the level's own folder.
|
||||
pub struct Bobby {
|
||||
dir: PathBuf,
|
||||
decode: DecodeChunk,
|
||||
/// `lod::state_kinds`, to summarize its chunks as loaded ones are.
|
||||
kinds: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Bobby {
|
||||
pub fn new(dir: PathBuf, decode: DecodeChunk, facts: &BlockFacts) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
decode,
|
||||
kinds: state_kinds(facts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One level's stored terrain, shared by the far-terrain threads. A chunk
|
||||
/// it doesn't have comes from Bobby's cache, else the server's BlueMap, if
|
||||
/// the player named it or Find BlueMap found it.
|
||||
pub struct ServerTerrain {
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
state: Mutex<State>,
|
||||
warned: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ServerTerrain {
|
||||
pub fn new(dir: PathBuf, blue_map: Option<Arc<BlueMap>>) -> Arc<Self> {
|
||||
Self::with_bobby(dir, blue_map, None)
|
||||
}
|
||||
|
||||
pub fn with_bobby(
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
dir,
|
||||
blue_map,
|
||||
bobby,
|
||||
state: Mutex::default(),
|
||||
warned: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// BlueMap's coarser levels at block (x, z) (a void there is `Ready`).
|
||||
fn coarser(&self, x: i32, z: i32) -> Found<Column> {
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.coarser_column(x, z))
|
||||
}
|
||||
|
||||
/// A chunk's columns: stored, else BlueMap's (`Waiting`: on its way).
|
||||
fn chunk_or_map(&self, x: i32, z: i32) -> Found<Columns> {
|
||||
if let Some(columns) = self.chunk(x, z) {
|
||||
return Found::Ready(columns);
|
||||
}
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.chunk(x, z))
|
||||
}
|
||||
|
||||
/// The stored chunk nearest the middle of the `side` × `side` chunks
|
||||
/// from `(x, z)`, found in the region headers without reading any.
|
||||
#[cfg(test)]
|
||||
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
|
||||
self.kept(x, z, side).1
|
||||
}
|
||||
|
||||
/// How many of the `side` × `side` chunks from `(x, z)` are stored here
|
||||
/// or in Bobby's cache, and the one nearest their middle (`nearest`).
|
||||
fn kept(&self, x: i32, z: i32, side: i32) -> (usize, Option<(i32, i32)>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let middle = (x * 2 + side, z * 2 + side);
|
||||
let mut count = 0;
|
||||
let mut best: Option<(i64, (i32, i32))> = None;
|
||||
for rz in z >> 5..=(z + side - 1) >> 5 {
|
||||
for rx in x >> 5..=(x + side - 1) >> 5 {
|
||||
let mut present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
.map_or([false; 1024], |r| r.present);
|
||||
if let Some(bobby) = &self.bobby
|
||||
&& let Some(theirs) = read_only(&mut state.bobby, &bobby.dir, (rx, rz))
|
||||
{
|
||||
for (here, there) in present.iter_mut().zip(theirs.present) {
|
||||
*here |= there;
|
||||
}
|
||||
}
|
||||
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
|
||||
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
|
||||
if cx < x || cz < z || cx >= x + side || cz >= z + side {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let (dx, dz) = (
|
||||
i64::from(cx * 2 + 1 - middle.0),
|
||||
i64::from(cz * 2 + 1 - middle.1),
|
||||
);
|
||||
let distance = dx * dx + dz * dz;
|
||||
if best.is_none_or(|(d, _)| distance < d) {
|
||||
best = Some((distance, (cx, cz)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(count, best.map(|(_, chunk)| chunk))
|
||||
}
|
||||
|
||||
/// A stored chunk's columns, else Bobby's (kept here from then on).
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
if let Some(known) = self.state.lock().unwrap().chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
let columns = self.stored(x, z).or_else(|| self.bobby_chunk(x, z));
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Stored by another thread meanwhile (`write`): that, newer than
|
||||
// what was read here.
|
||||
if let Some(Some(stored)) = state.chunks.get(&(x, z)) {
|
||||
return Some(stored.clone());
|
||||
}
|
||||
remember(&mut state, (x, z), columns.clone());
|
||||
columns
|
||||
}
|
||||
|
||||
/// The chunk's record in the level's own folder; one of another game
|
||||
/// version or layout is none.
|
||||
fn stored(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let record = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
read(
|
||||
region(&mut state.regions, &self.dir, (x >> 5, z >> 5)),
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}?;
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
}
|
||||
|
||||
/// A chunk in Bobby's cache, summarized and kept here. One of another
|
||||
/// game version, or that doesn't decode, is none.
|
||||
fn bobby_chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let bobby = self.bobby.as_ref()?;
|
||||
let (record, kept) = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
let kept =
|
||||
region(&mut state.regions, &self.dir, key).is_some_and(|r| r.present[slot(x, z)]);
|
||||
(
|
||||
read(read_only(&mut state.bobby, &bobby.dir, key), x, z),
|
||||
kept,
|
||||
)
|
||||
};
|
||||
let record = record?;
|
||||
let root = record.nbt(Limits::default()).ok()?;
|
||||
if root.tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION)) {
|
||||
return None;
|
||||
}
|
||||
let chunk = (bobby.decode)(&root).filter(|chunk| (chunk.x, chunk.z) == (x, z))?;
|
||||
let columns = summarize(&chunk, &bobby.kinds);
|
||||
// Not over a chunk the server sent while this one was decoded.
|
||||
if let Err(e) = self.write((x, z), &columns, Some(kept)) {
|
||||
self.warn_once(&e);
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
|
||||
/// A chunk couldn't be kept (disk full, a folder it can't write): said
|
||||
/// once a level, not for every chunk after.
|
||||
fn warn_once(&self, error: &std::io::Error) {
|
||||
if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
log::warn!("Horizon: far terrain can't keep the server's chunks: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a chunk's columns, unless they are what is stored already.
|
||||
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
let known = self.state.lock().unwrap().chunks.get(&key).cloned();
|
||||
let old = known.unwrap_or_else(|| {
|
||||
let old = self.stored(key.0, key.1);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if !state.chunks.contains_key(&key) {
|
||||
remember(&mut state, key, old.clone());
|
||||
}
|
||||
old
|
||||
});
|
||||
if old.is_some_and(|old| *old == **columns) {
|
||||
return Ok(());
|
||||
}
|
||||
self.write(key, columns, None)
|
||||
}
|
||||
|
||||
/// `kept`: whether the chunk's record was in the folder before; the
|
||||
/// columns aren't written over one another thread kept meanwhile.
|
||||
fn write(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
columns: &Columns,
|
||||
kept: Option<bool>,
|
||||
) -> std::io::Result<()> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: encode(x, z, columns),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
if let Some(kept) = kept
|
||||
&& (matches!(state.chunks.get(&(x, z)), Some(Some(_)))
|
||||
|| (!kept
|
||||
&& region(&mut state.regions, &self.dir, key)
|
||||
.is_some_and(|r| r.present[slot(x, z)])))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if region(&mut state.regions, &self.dir, key).is_none() {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
|
||||
state.regions.insert(key, Some(Region::new(file)));
|
||||
}
|
||||
let region = region(&mut state.regions, &self.dir, key).expect("region just opened");
|
||||
region.file.write(ChunkPos::new(x, z), &record)?;
|
||||
region.present[slot(x, z)] = true;
|
||||
remember(&mut state, (x, z), Some(columns.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
|
||||
dir.join(format!("r.{rx}.{rz}.mca"))
|
||||
}
|
||||
|
||||
/// A region of `dir`, opened on first use.
|
||||
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || {
|
||||
RegionFile::open_writable(region_path(dir, key), false)
|
||||
})
|
||||
}
|
||||
|
||||
/// A region of Bobby's `dir`, opened for reading only.
|
||||
fn read_only<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || RegionFile::open(region_path(dir, key)))
|
||||
}
|
||||
|
||||
fn open(
|
||||
regions: &mut Regions,
|
||||
key: (i32, i32),
|
||||
file: impl FnOnce() -> std::io::Result<RegionFile>,
|
||||
) -> Option<&mut Region> {
|
||||
if !regions.contains_key(&key) {
|
||||
if regions.len() >= MAX_REGIONS {
|
||||
regions.clear();
|
||||
}
|
||||
regions.insert(key, file().ok().map(Region::new));
|
||||
}
|
||||
regions.get_mut(&key)?.as_mut()
|
||||
}
|
||||
|
||||
fn read(region: Option<&mut Region>, x: i32, z: i32) -> Option<ChunkRecord> {
|
||||
let region = region.filter(|r| r.present[slot(x, z)])?;
|
||||
region.file.read(ChunkPos::new(x, z)).ok().flatten()
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(file: RegionFile) -> Self {
|
||||
let mut present = [false; 1024];
|
||||
for (pos, _) in file.chunks() {
|
||||
present[slot(pos.x, pos.z)] = true;
|
||||
}
|
||||
Self { file, present }
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(x: i32, z: i32) -> usize {
|
||||
(x & 31) as usize + (z & 31) as usize * 32
|
||||
}
|
||||
|
||||
fn remember(state: &mut State, key: (i32, i32), columns: Option<Columns>) {
|
||||
if state.chunks.len() >= MAX_CHUNKS {
|
||||
state.chunks.clear();
|
||||
}
|
||||
state.chunks.insert(key, columns);
|
||||
}
|
||||
|
||||
fn now() -> u32 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as u32)
|
||||
}
|
||||
|
||||
/// The columns as one int array: per column a surface (`0`, or `1`, height,
|
||||
/// state, water flag, water height, water state, biome), the span count, and
|
||||
/// three ints a span (min and max, state, biome and light).
|
||||
fn encode(x: i32, z: i32, columns: &[Column; 256]) -> Tag {
|
||||
let mut ints = Vec::with_capacity(256 * 8);
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => ints.push(0),
|
||||
Some(s) => {
|
||||
let (water, water_height, water_state) = s
|
||||
.water
|
||||
.map_or((0, 0, 0), |(h, state)| (1, h, state.0 as i32));
|
||||
ints.extend([
|
||||
1,
|
||||
s.height,
|
||||
s.state.0 as i32,
|
||||
water,
|
||||
water_height,
|
||||
water_state,
|
||||
i32::from(s.biome),
|
||||
]);
|
||||
}
|
||||
}
|
||||
ints.push(column.spans.len() as i32);
|
||||
for span in &column.spans {
|
||||
ints.extend([
|
||||
i32::from(span.min as u16) << 16 | i32::from(span.max as u16),
|
||||
span.state.0 as i32,
|
||||
i32::from(span.biome) << 16 | i32::from(span.light),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("HorizonFarTerrain", Tag::Int(FORMAT)),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
("columns", Tag::IntArray(ints)),
|
||||
])
|
||||
}
|
||||
|
||||
/// `encode`'s columns, or `None` for a record of another game version or
|
||||
/// layout (their block states and biomes are numbered differently).
|
||||
fn decode(tag: &Tag) -> Option<Columns> {
|
||||
if tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION))
|
||||
|| tag.get("HorizonFarTerrain") != Some(&Tag::Int(FORMAT))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let Some(Tag::IntArray(ints)) = tag.get("columns") else {
|
||||
return None;
|
||||
};
|
||||
let mut ints = ints.iter().copied();
|
||||
let mut next = || ints.next();
|
||||
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
|
||||
for column in &mut columns {
|
||||
column.surface = match next()? {
|
||||
0 => None,
|
||||
1 => {
|
||||
let height = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let water = next()?;
|
||||
let water_height = next()?;
|
||||
let water_state = BlockStateId(next()? as u32);
|
||||
let biome = next()? as u16;
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water: (water != 0).then_some((water_height, water_state)),
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let count = usize::try_from(next()?).ok().filter(|&n| n <= 4096)?;
|
||||
column.spans = Spans::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let range = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let material = next()?;
|
||||
column.spans.push(Span {
|
||||
min: (range >> 16) as i16,
|
||||
max: range as i16,
|
||||
state,
|
||||
biome: (material >> 16) as u16,
|
||||
light: material as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
next().is_none().then(|| Arc::new(columns))
|
||||
}
|
||||
|
||||
impl LodSource for ServerTerrain {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
||||
Box::new(Sampler {
|
||||
terrain: self,
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
})
|
||||
}
|
||||
fn keeps_observed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
/// For a column whose chunk of the map is on its way: its coarser
|
||||
/// levels' meanwhile, as they come first (a void there is drawn as
|
||||
/// one). None there either is a hole (`waited`: the sample isn't drawn);
|
||||
/// either way the column is sampled again when its tile arrives
|
||||
/// (`Lod::refresh`).
|
||||
fn coarser(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.coarser(x, z) {
|
||||
Found::Ready(column) => column,
|
||||
Found::Waiting | Found::Absent => {
|
||||
self.waited = true;
|
||||
Column::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sampler {
|
||||
terrain: Arc<ServerTerrain>,
|
||||
details: Vec<LodDetail>,
|
||||
/// A column was left empty for BlueMap's on its way, with no coarser
|
||||
/// level to stand in (`waited`).
|
||||
waited: bool,
|
||||
}
|
||||
|
||||
impl LodSampler for Sampler {
|
||||
fn fork(&self) -> Option<Box<dyn LodSampler>> {
|
||||
Some(Box::new(Self {
|
||||
terrain: self.terrain.clone(),
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
}))
|
||||
}
|
||||
/// Stored chunks come back whole, else BlueMap's (its coarser levels'
|
||||
/// where the finest is out of reach); the others are known empty. One
|
||||
/// whose BlueMap tile is still downloading is asked again later.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
for &(x, z) in chunks {
|
||||
let columns = match self.terrain.chunk_or_map(x, z) {
|
||||
Found::Ready(columns) => columns,
|
||||
Found::Waiting => continue,
|
||||
Found::Absent => Arc::new(std::array::from_fn(|_| Column::default())),
|
||||
};
|
||||
self.details.push(((x, z), columns));
|
||||
}
|
||||
}
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
std::mem::take(&mut self.details)
|
||||
}
|
||||
fn observed(&mut self, key: (i32, i32), columns: &Columns) {
|
||||
if let Err(e) = self.terrain.store(key, columns) {
|
||||
self.terrain.warn_once(&e);
|
||||
}
|
||||
}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
|
||||
self.column(x, z).surface
|
||||
}
|
||||
fn column(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
|
||||
Found::Waiting => self.coarser(x, z),
|
||||
Found::Absent => Column::default(),
|
||||
}
|
||||
}
|
||||
fn waited(&mut self) -> bool {
|
||||
std::mem::take(&mut self.waited)
|
||||
}
|
||||
/// A cell of `width` blocks from one stored chunk: its own area up to a
|
||||
/// chunk, else the stored chunk nearest the cell's middle (the server
|
||||
/// sent only the chunks a player came near), when most of the cell is
|
||||
/// stored. With a BlueMap, a wider
|
||||
/// cell takes the chunk at its middle if stored, else the map's pixel
|
||||
/// there, and the nearest stored chunk only while the map has none.
|
||||
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
|
||||
if width <= 16 {
|
||||
let chunk = match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(chunk) => chunk,
|
||||
Found::Waiting => return self.coarser(x, z),
|
||||
Found::Absent => return Column::default(),
|
||||
};
|
||||
let width = width.max(1) as usize;
|
||||
let lx = (x & 15) as usize & !(width - 1);
|
||||
let lz = (z & 15) as usize & !(width - 1);
|
||||
return Column::reduced(&chunk, lx, lz, width);
|
||||
}
|
||||
let mut waiting = false;
|
||||
if let Some(map) = &self.terrain.blue_map {
|
||||
let (mx, mz) = ((x + width / 2) >> 4, (z + width / 2) >> 4);
|
||||
if let Some(chunk) = self.terrain.chunk(mx, mz) {
|
||||
return Column::reduced(&chunk, 0, 0, 16);
|
||||
}
|
||||
match map.reduced_column(x, z, width) {
|
||||
Found::Ready(column) => return column,
|
||||
Found::Waiting => waiting = true,
|
||||
Found::Absent => {}
|
||||
}
|
||||
}
|
||||
// Only over a cell the server sent most of, as `Column::reduced`
|
||||
// keeps what fills half a cell: one chunk drawn as a whole cell stood
|
||||
// as a flat slab far wider than anything seen there, until finer
|
||||
// tiles replaced it with the nothing they know of.
|
||||
let side = width >> 4;
|
||||
// Wider cells are never mostly sent, and counting them would open
|
||||
// thousands of region files.
|
||||
if side > MAX_KEPT_SIDE {
|
||||
self.waited |= waiting;
|
||||
return Column::default();
|
||||
}
|
||||
let (count, nearest) = self.terrain.kept(x >> 4, z >> 4, side);
|
||||
let column = (count * 2 >= (side * side) as usize)
|
||||
.then(|| nearest.and_then(|(cx, cz)| self.terrain.chunk(cx, cz)))
|
||||
.flatten()
|
||||
.map(|chunk| Column::reduced(&chunk, 0, 0, 16));
|
||||
self.waited |= waiting && column.is_none();
|
||||
column.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn columns() -> Columns {
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
if i % 3 == 0 {
|
||||
return Column::default();
|
||||
}
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 63 + i as i32 % 7,
|
||||
state: BlockStateId(9 + i as u32),
|
||||
water: (i % 2 == 0).then_some((62, BlockStateId(86))),
|
||||
biome: (i % 5) as u16,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: 40,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: 40,
|
||||
max: 63 + i as i16 % 7,
|
||||
state: BlockStateId(9),
|
||||
biome: 7,
|
||||
light: 0xF0,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_survive_their_encoding() {
|
||||
let columns = columns();
|
||||
assert_eq!(decode(&encode(3, -4, &columns)), Some(columns));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_of_another_version_are_ignored() {
|
||||
let mut tag = encode(0, 0, &columns());
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert("DataVersion".into(), Tag::Int(DATA_VERSION - 1));
|
||||
}
|
||||
assert_eq!(decode(&tag), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_folders_are_plain_names() {
|
||||
assert_eq!(server_folder("Play.Example.net"), "play.example.net");
|
||||
assert_eq!(server_folder("play.example.net:25565"), "play.example.net");
|
||||
assert_eq!(
|
||||
server_folder("play.example.net:25566"),
|
||||
"play.example.net_25566"
|
||||
);
|
||||
assert_eq!(server_folder("[::1]:25570"), "___1__25570");
|
||||
assert_eq!(server_folder("../.."), "_");
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", -7, "minecraft:the_nether"),
|
||||
Path::new("/c/a/-7/minecraft/the_nether")
|
||||
);
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", 0, "mod:../../x"),
|
||||
Path::new("/c/a/0/mod/x")
|
||||
);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_chunks_come_back_after_a_restart() {
|
||||
let dir = scratch("server-terrain");
|
||||
let columns = columns();
|
||||
{
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
assert_eq!(terrain.chunk(-33, 5), None);
|
||||
terrain.store((-33, 5), &columns).unwrap();
|
||||
}
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
|
||||
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
|
||||
assert_eq!(terrain.nearest(-32, 0, 64), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
|
||||
assert_eq!(sampler.column(0, 0), Column::default());
|
||||
// One chunk doesn't stand for a far cell of 64 × 64.
|
||||
assert_eq!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_cells_are_drawn_where_most_of_them_was_kept() {
|
||||
let dir = scratch("server-terrain-wide");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
// A cell of 4 × 4 chunks, and a chunk of it kept.
|
||||
terrain.store((2, 1), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
for (x, z) in [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (1, 1)] {
|
||||
terrain.store((x, z), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
}
|
||||
// Half of it: drawn from the kept chunk nearest its middle.
|
||||
terrain.store((2, 2), &columns).unwrap();
|
||||
assert_eq!(
|
||||
sampler.reduced_column(0, 0, 64),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Far terrain samples a cell before the server sent its chunk, then
|
||||
/// again once it is kept: nothing remembers the empty sample.
|
||||
#[test]
|
||||
fn a_chunk_kept_after_an_empty_sample_is_sampled_next_time() {
|
||||
let dir = scratch("server-terrain-late");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
let mut other = sampler.fork().expect("a sampling thread's own");
|
||||
for width in [1, 4, 16] {
|
||||
assert_eq!(other.reduced_column(-16, 32, width), Column::default());
|
||||
}
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), vec![Column::default(); 256]);
|
||||
// `Job::Observe` stores it on the far-terrain worker.
|
||||
sampler.observed((-1, 2), &columns);
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), columns.to_vec());
|
||||
assert_eq!(
|
||||
other.reduced_column(-16, 32, 16),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bobby_folders_follow_the_address_as_typed() {
|
||||
let root = scratch("bobby-folders");
|
||||
for dir in [
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether",
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld",
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
"Play.Example.net_25565/5/minecraft/overworld",
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let find = |address: &str, seed: i64, level: &str| {
|
||||
bobby_level_dir(&root, address, seed, level)
|
||||
.map(|dir| dir.strip_prefix(&root).unwrap().to_owned())
|
||||
};
|
||||
let overworld = "minecraft:overworld";
|
||||
let first = Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(find("127.0.0.1", -9030034075591913594, overworld), first);
|
||||
assert_eq!(
|
||||
find("127.0.0.1:25565", -9030034075591913594, overworld),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_nether"),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", 4696776658964135993, overworld),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
let other_port = Some(PathBuf::from(
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(
|
||||
find("localhost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("LocalHost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("play.example.net", 5, overworld),
|
||||
Some(PathBuf::from(
|
||||
"Play.Example.net_25565/5/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
// Another world behind the address, another dimension, another port.
|
||||
assert_eq!(find("127.0.0.1", 1111, overworld), None);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_end"),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("localhost", -7181178862026813658, overworld), None);
|
||||
assert_eq!(
|
||||
find("localhost:25567", -7181178862026813658, overworld),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("", 0, overworld), None);
|
||||
assert_eq!(
|
||||
bobby_level_dir(&root.join("none"), "127.0.0.1", 0, overworld),
|
||||
None
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A Bobby-shaped region of chunk records, written with `version`.
|
||||
fn bobby_region(dir: &Path, chunks: &[(i32, i32)], version: i32) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let (rx, rz) = (chunks[0].0 >> 5, chunks[0].1 >> 5);
|
||||
let mut file = RegionFile::open_writable(region_path(dir, (rx, rz)), true).unwrap();
|
||||
for &(x, z) in chunks {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(version)),
|
||||
("Status", Tag::String("full".into())),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
]),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, 0, Limits::default());
|
||||
file.write(ChunkPos::new(x, z), &record.unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stone from y 60 to 70 in every column (the game decodes vanilla chunk NBT
|
||||
/// with `save::decode_lit`; its palettes are tested there).
|
||||
fn stone(root: &NamedTag) -> Option<Chunk> {
|
||||
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (root.tag.get("xPos"), root.tag.get("zPos"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut chunk = Chunk::empty(*x, *z);
|
||||
for i in 0..256 {
|
||||
for y in 60..70 {
|
||||
chunk.set(i & 15, y, i >> 4, 1);
|
||||
}
|
||||
}
|
||||
Some(chunk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_only_bobby_has_are_read_once_and_kept() {
|
||||
let dir = scratch("server-terrain-bobby");
|
||||
let (own, theirs) = (dir.join("own"), dir.join("bobby"));
|
||||
bobby_region(&theirs, &[(-3, 5), (-2, 5), (-1, 6)], DATA_VERSION);
|
||||
bobby_region(&theirs.join("old"), &[(-3, 5)], DATA_VERSION - 1);
|
||||
let region = region_path(&theirs, (-1, 0));
|
||||
let before = std::fs::read(®ion).unwrap();
|
||||
// State 0 air, state 1 a kept block (`lod::state_kinds`).
|
||||
let bobby = |dir: &Path| Bobby {
|
||||
dir: dir.to_owned(),
|
||||
decode: Box::new(stone),
|
||||
kinds: vec![4, 1],
|
||||
};
|
||||
let terrain = ServerTerrain::with_bobby(own.clone(), None, Some(bobby(&theirs)));
|
||||
// Counted for wide cells before they are read.
|
||||
assert_eq!(terrain.kept(-4, 4, 4), (3, Some((-3, 5))));
|
||||
let expected = summarize(
|
||||
&stone(&NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([("xPos", Tag::Int(-2)), ("zPos", Tag::Int(5))]),
|
||||
})
|
||||
.unwrap(),
|
||||
&[4, 1],
|
||||
);
|
||||
assert!(expected[0].surface.is_some());
|
||||
assert_eq!(terrain.chunk(-2, 5), Some(expected.clone()));
|
||||
assert_eq!(terrain.chunk(-2, 6), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.column(-2 * 16 + 3, 5 * 16), expected[3]);
|
||||
drop(sampler);
|
||||
drop(terrain);
|
||||
// Kept in the level's own folder; Bobby's left as it was.
|
||||
let kept = ServerTerrain::new(own.clone(), None);
|
||||
assert_eq!(kept.chunk(-2, 5), Some(expected));
|
||||
assert_eq!(kept.chunk(-3, 5), None);
|
||||
assert_eq!(std::fs::read(®ion).unwrap(), before);
|
||||
// Another game version's chunk is none.
|
||||
let old =
|
||||
ServerTerrain::with_bobby(dir.join("own-old"), None, Some(bobby(&theirs.join("old"))));
|
||||
assert_eq!(old.chunk(-3, 5), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -222,10 +222,14 @@ fn component_text(tag: &Tag) -> String {
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// Whether a block name is a sign (standing, wall, hanging or wall hanging).
|
||||
/// Whether a vanilla block's name is a sign (standing, wall, hanging or wall
|
||||
/// hanging). A mod's sign goes by its template's class, whatever its name; a
|
||||
/// mod's `road_sign` of stone is no sign.
|
||||
pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
if name.contains(':') {
|
||||
None
|
||||
} else if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
Some(true)
|
||||
} else if name == "sign" || name.ends_with("_sign") {
|
||||
Some(false)
|
||||
@@ -233,15 +237,17 @@ pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Wood type of a sign block (`SignBlock.getWoodType`), for the editor texture.
|
||||
pub fn wood_type(name: &str) -> &str {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
for suffix in ["_wall_hanging_sign", "_hanging_sign", "_wall_sign", "_sign"] {
|
||||
if let Some(wood) = name.strip_suffix(suffix) {
|
||||
return wood;
|
||||
/// The sprite of a wood type's editor background, `folder` being `signs` or
|
||||
/// `hanging_signs`: `textures/gui/<folder>/<wood>.png` (`SignEditScreen`), in
|
||||
/// a mod's namespace when the wood names one.
|
||||
pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
match wood.split_once(':') {
|
||||
Some((namespace, path)) if namespace != "minecraft" => {
|
||||
format!("{namespace}:gui/{folder}/{path}")
|
||||
}
|
||||
Some((_, path)) => format!("gui/{folder}/{path}"),
|
||||
None => format!("gui/{folder}/{wood}"),
|
||||
}
|
||||
"oak"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -262,12 +268,25 @@ mod tests {
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
#[test]
|
||||
fn a_mods_sign_like_name_does_not_make_it_a_sign() {
|
||||
assert_eq!(sign_kind("ruby:road_sign"), None);
|
||||
assert!(crate::containers::BlockEntity::for_block("ruby:road_sign").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn names_and_colors() {
|
||||
assert_eq!(sign_kind("minecraft:oak_wall_hanging_sign"), Some(true));
|
||||
assert_eq!(sign_kind("minecraft:sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:spruce_wall_sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:stone"), None);
|
||||
assert_eq!(wood_type("minecraft:dark_oak_wall_sign"), "dark_oak");
|
||||
assert_eq!(editor_sprite("signs", "dark_oak"), "gui/signs/dark_oak");
|
||||
assert_eq!(
|
||||
editor_sprite("signs", "minecraft:cherry"),
|
||||
"gui/signs/cherry"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_sprite("hanging_signs", "ruby:elm"),
|
||||
"ruby:gui/hanging_signs/elm"
|
||||
);
|
||||
assert_eq!(SignText::default().dark_color(), 0);
|
||||
let red = SignText {
|
||||
color: 14,
|
||||
|
||||
@@ -255,6 +255,30 @@ pub mod startup {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a world's view is still filling (between [`arm`] and the
|
||||
/// `world.view_full` mark).
|
||||
#[inline]
|
||||
pub fn filling() -> bool {
|
||||
on() && FILLING.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Values at this moment, as a counter track named `name` (Perfetto draws
|
||||
/// each value as a line): what the view waits on while it fills.
|
||||
pub fn counters(name: &'static str, values: &[(&'static str, usize)]) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let args: Vec<String> = values
|
||||
.iter()
|
||||
.map(|(k, v)| format!(r#""{k}":{v}"#))
|
||||
.collect();
|
||||
write(format_args!(
|
||||
r#""ph":"C","name":"{name}","ts":{},"args":{{{}}}"#,
|
||||
micros(Instant::now()),
|
||||
args.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
/// Called each frame in a world: marks `world.view_full` the first frame
|
||||
/// after [`arm`] where nothing waits to load, generate, light, mesh or
|
||||
/// upload and the resident columns cover perf_bench's share of the view
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
|
||||
use crate::{
|
||||
gamepad::Button as PadButton,
|
||||
graphics_style::STYLE,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
@@ -48,12 +49,28 @@ pub struct SplitUi {
|
||||
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
|
||||
pub names_shown: bool,
|
||||
pub toggle_names: bool,
|
||||
/// P1 has a controller (the game sets it), and Give Up Pad pressed: it's offered to join.
|
||||
pub p1_pad: bool,
|
||||
pub give_up_pad: bool,
|
||||
edit: Option<Draft>,
|
||||
selected: Option<usize>,
|
||||
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
|
||||
drag: Option<(usize, Option<(f32, f32)>)>,
|
||||
/// The screen's size at the last press: where a drag's release lands.
|
||||
size: (f32, f32),
|
||||
/// A player picked up with the controller (A), moved with the bumpers.
|
||||
held: Option<Held>,
|
||||
/// Dropped or put back: the player whose tile the controller's focus goes to.
|
||||
refocus: Option<usize>,
|
||||
/// The screen's size: at the last draw (the options screen sets it) or press.
|
||||
pub(crate) size: (f32, f32),
|
||||
}
|
||||
|
||||
/// A player picked up with the controller: where it could go, the one picked so far, and the
|
||||
/// draft with it there (the map shows it).
|
||||
struct Held {
|
||||
player: usize,
|
||||
spots: Vec<(String, Drop)>,
|
||||
at: Option<usize>,
|
||||
preview: Option<Draft>,
|
||||
}
|
||||
|
||||
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
|
||||
@@ -73,6 +90,8 @@ enum Target {
|
||||
Identify,
|
||||
/// The players' names in their views' corners, on or off.
|
||||
Names,
|
||||
/// P1's controller freed and offered to join (handed to a friend).
|
||||
GiveUpPad,
|
||||
Revert,
|
||||
Apply,
|
||||
Done,
|
||||
@@ -124,9 +143,113 @@ impl SplitUi {
|
||||
self.selected = None;
|
||||
self.apply = None;
|
||||
self.drag = None;
|
||||
self.held = None;
|
||||
}
|
||||
/// The draft shown: with a held player where the controller put it, else the changes so
|
||||
/// far, else the arrangement now.
|
||||
fn draft(&self) -> Option<&Draft> {
|
||||
self.edit.as_ref().or(self.live.as_ref())
|
||||
let preview = self.held.as_ref().and_then(|h| h.preview.as_ref());
|
||||
preview.or(self.edit.as_ref()).or(self.live.as_ref())
|
||||
}
|
||||
|
||||
/// The controller (`pad_menu`), A at `at`: on a player's tile, picks it up. True when it did
|
||||
/// (the press is taken).
|
||||
pub fn pad_pick_up(&mut self, at: (f32, f32)) -> bool {
|
||||
let (w, h) = self.size;
|
||||
let hit = self
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|([x, y, bw, bh], _, _, active)| *active && ui::hovered(at, *x, *y, *bw, *bh));
|
||||
let Some((_, _, Target::Player(i), _)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(i);
|
||||
let spots = self.stops(i);
|
||||
self.held = Some(Held {
|
||||
player: i,
|
||||
spots,
|
||||
at: None,
|
||||
preview: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The controller while a player is held: the bumpers or left and right cycle where it goes,
|
||||
/// as when joining (the map shows it there), A drops it there (into the draft), B puts it
|
||||
/// back. True when the button was taken: every button, while one is held.
|
||||
pub fn pad_button(&mut self, button: PadButton) -> bool {
|
||||
let Some(held) = &self.held else {
|
||||
return false;
|
||||
};
|
||||
let player = held.player;
|
||||
match button {
|
||||
PadButton::LeftBumper
|
||||
| PadButton::RightBumper
|
||||
| PadButton::DPadLeft
|
||||
| PadButton::DPadRight => {
|
||||
let n = held.spots.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let back = matches!(button, PadButton::LeftBumper | PadButton::DPadLeft);
|
||||
let next = match held.at {
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
Some(k) if back => (k + n - 1) % n,
|
||||
Some(k) => (k + 1) % n,
|
||||
};
|
||||
let (player, drop) = (held.player, held.spots[next].1.clone());
|
||||
let open = self.open_now();
|
||||
let preview = self.edit.clone().or(self.live.clone()).map(|mut draft| {
|
||||
match drop {
|
||||
Drop::At(spot) => draft.place_player(player, spot),
|
||||
Drop::Place(n, k) => {
|
||||
draft.place_player(player, Spot::Window(n));
|
||||
draft.put_at(player, k);
|
||||
}
|
||||
}
|
||||
draft.drop_empty(&open);
|
||||
draft
|
||||
});
|
||||
if let Some(held) = &mut self.held {
|
||||
held.at = Some(next);
|
||||
held.preview = preview;
|
||||
}
|
||||
}
|
||||
PadButton::South => {
|
||||
let held = self.held.take().expect("held");
|
||||
// One highlight after: the focus on the player where it landed, not selected too.
|
||||
(self.selected, self.refocus) = (None, Some(held.player));
|
||||
match held.at.map(|k| held.spots[k].1.clone()) {
|
||||
Some(Drop::At(spot)) => self.place(held.player, spot),
|
||||
Some(Drop::Place(n, k)) => self.place_at(held.player, n, k),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
PadButton::East => {
|
||||
(self.selected, self.refocus) = (None, Some(player));
|
||||
self.held = None;
|
||||
}
|
||||
// While one is held, the focus stays put: A drops, it presses nothing else.
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a player is held (the controller's left and right cycle it).
|
||||
pub fn holding(&self) -> bool {
|
||||
self.held.is_some()
|
||||
}
|
||||
|
||||
/// The tile of the player just dropped or put back, for the controller's focus. Taken once.
|
||||
pub fn take_refocus(&mut self) -> Option<[f32; 4]> {
|
||||
let i = self.refocus.take()?;
|
||||
let (w, h) = self.size;
|
||||
self.buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|b| b.2 == Target::Player(i))
|
||||
.map(|b| b.0)
|
||||
}
|
||||
fn map(&self, w: f32, h: f32) -> split_map::Map {
|
||||
if self.idle {
|
||||
@@ -270,8 +393,18 @@ impl SplitUi {
|
||||
Target::Switch,
|
||||
!self.on || self.names.len() <= 1,
|
||||
);
|
||||
// P1's controller handed to a friend: it's offered to join.
|
||||
let give_up = (
|
||||
[90.0, 6.0, 80.0, 20.0],
|
||||
tr("give_up_pad", &[]),
|
||||
Target::GiveUpPad,
|
||||
true,
|
||||
);
|
||||
if !self.on || self.idle {
|
||||
let mut out = vec![switch];
|
||||
if self.on && self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
row(&mut out, h - 27.0, vec![done]);
|
||||
return out;
|
||||
}
|
||||
@@ -329,6 +462,9 @@ impl SplitUi {
|
||||
true,
|
||||
);
|
||||
out.extend([switch, names]);
|
||||
if self.p1_pad {
|
||||
out.push(give_up);
|
||||
}
|
||||
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
|
||||
// as the layout; the label is for the control socket and the tests).
|
||||
let mut footer = Vec::new();
|
||||
@@ -373,6 +509,7 @@ impl SplitUi {
|
||||
Target::Switch => tr("help.switch", &[]),
|
||||
Target::Identify => tr("help.identify", &[]),
|
||||
Target::Names => tr("help.names", &[]),
|
||||
Target::GiveUpPad => tr("help.give_up_pad", &[]),
|
||||
Target::Revert if *active => tr("help.revert", &[]),
|
||||
Target::Apply if *active => tr("help.apply", &[]),
|
||||
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
|
||||
@@ -542,7 +679,9 @@ impl SplitUi {
|
||||
([x, y, bw, bh], label, target, active): &Button,
|
||||
) {
|
||||
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
|
||||
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
|
||||
// While a player is held, only its tile is lit: the controller's pointer stays where
|
||||
// the pick-up was, over whichever tile is there now.
|
||||
let hover = *active && self.held.is_none() && ui::hovered(cursor, x, y, bw, bh);
|
||||
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
|
||||
if matches!(target, Target::Player(_)) {
|
||||
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
|
||||
@@ -571,6 +710,16 @@ impl SplitUi {
|
||||
*active,
|
||||
hover || selected,
|
||||
);
|
||||
// The held player: "< Name >", the arrows lit, for left and right.
|
||||
if matches!(target, Target::Player(i) if self.held.as_ref().is_some_and(|h| h.player == *i))
|
||||
{
|
||||
let (half, cx) = (HudBatch::text_width(atlas, label) / 2.0, x + bw / 2.0);
|
||||
let ty = (y + (bh - 8.0) / 2.0).floor();
|
||||
let lit = rgba(255, 255, 85, 255);
|
||||
let lw = HudBatch::text_width(atlas, "<");
|
||||
batch.text(atlas, "<", (cx - half - 4.0 - lw).floor(), ty, lit, true);
|
||||
batch.text(atlas, ">", (cx + half + 4.0).ceil(), ty, lit, true);
|
||||
}
|
||||
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
|
||||
// control socket, which lists widgets by what they say.
|
||||
if glyph.is_some() {
|
||||
@@ -601,7 +750,19 @@ impl SplitUi {
|
||||
buttons: &[Button],
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let held = self.held.as_ref().map(|h| {
|
||||
let who = self
|
||||
.names
|
||||
.get(h.player)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tr("this_player", &[]));
|
||||
match h.at {
|
||||
Some(k) => tr("note.held_at", &[&who, &h.spots[k].0]),
|
||||
None => tr("note.held", &[&who]),
|
||||
}
|
||||
});
|
||||
let note = match self.selected {
|
||||
_ if held.is_some() => held.unwrap_or_default(),
|
||||
None => tr("note.select", &[]),
|
||||
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
|
||||
Some(i) => tr(
|
||||
@@ -651,6 +812,10 @@ impl SplitUi {
|
||||
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
|
||||
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
|
||||
self.size = (w, h);
|
||||
// The mouse takes over from a player held with the controller.
|
||||
if !drag {
|
||||
self.held = None;
|
||||
}
|
||||
if drag {
|
||||
if let Some((_, at)) = &mut self.drag {
|
||||
*at = Some((x, y));
|
||||
@@ -690,6 +855,7 @@ impl SplitUi {
|
||||
}
|
||||
Target::Identify => self.identify = true,
|
||||
Target::Names => self.toggle_names = true,
|
||||
Target::GiveUpPad => self.give_up_pad = true,
|
||||
Target::Revert => self.edit = None,
|
||||
Target::Apply => {
|
||||
// Windows emptied in the draft close now, not as players moved.
|
||||
@@ -1237,6 +1403,133 @@ mod tests {
|
||||
// Alone: neither.
|
||||
assert_eq!(labels(&screen(1)).count(), 0);
|
||||
}
|
||||
|
||||
fn centre(ui: &SplitUi, label: &str) -> (f32, f32) {
|
||||
let [x, y, w, h] = rect(ui, label);
|
||||
(x + w / 2.0, y + h / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_picks_a_player_up_moves_it_and_drops_it() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
// Nothing moves until the bumpers do; then the map shows it there: the places in Window 1
|
||||
// first, then a new window.
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
for _ in 0..3 {
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 2]);
|
||||
assert_eq!(ui.edit, None);
|
||||
// Right again: filling the TV; A drops it there, into the draft.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
let draft = ui.edit.as_ref().unwrap();
|
||||
assert_eq!(draft.players, [1, 2]);
|
||||
assert_eq!(draft.windows[1].placement, Placement::Fill("TV".into()));
|
||||
// Not held any more: the buttons go back to the menu.
|
||||
assert!(!ui.pad_button(PadButton::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_moves_a_player_to_a_place_among_the_others() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
let stops: Vec<String> = ui
|
||||
.held
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.spots
|
||||
.iter()
|
||||
.map(|s| s.0.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&stops[..3],
|
||||
["Window 1 (1 of 2)", "Window 1 (2 of 2)", "New window"]
|
||||
);
|
||||
assert!(
|
||||
!stops.iter().any(|s| s.starts_with("Swap")),
|
||||
"no swaps: it joins"
|
||||
);
|
||||
// RB to the first place: before P1.
|
||||
assert!(ui.pad_button(PadButton::RightBumper));
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
assert_eq!(ui.edit.as_ref().unwrap().order, [1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_replaces_preview_but_keeps_the_switch_and_pad_handoff() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
ui.idle = true;
|
||||
ui.p1_pad = true;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen idle");
|
||||
assert!(ui.map(SIZE.0, SIZE.1).2.is_empty());
|
||||
let buttons = ui.buttons(SIZE.0, SIZE.1);
|
||||
assert!(buttons.iter().any(|b| b.2 == Target::GiveUpPad));
|
||||
assert!(
|
||||
!buttons
|
||||
.iter()
|
||||
.any(|b| matches!(b.2, Target::Player(_) | Target::Apply | Target::Revert))
|
||||
);
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
ui.idle = false;
|
||||
assert!(ui.status_lines().is_none());
|
||||
assert_eq!(ui.map(SIZE.0, SIZE.1).2.len(), 1);
|
||||
ui.on = false;
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen is off.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn give_up_pad_shows_only_while_p1_has_one() {
|
||||
let mut ui = screen(1);
|
||||
ui.on = true;
|
||||
let shown = |ui: &SplitUi| {
|
||||
ui.buttons(SIZE.0, SIZE.1)
|
||||
.iter()
|
||||
.any(|b| b.2 == Target::GiveUpPad)
|
||||
};
|
||||
assert!(!shown(&ui));
|
||||
ui.p1_pad = true;
|
||||
assert!(shown(&ui));
|
||||
press(&mut ui, "Give Up Pad");
|
||||
assert!(ui.give_up_pad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_puts_a_held_player_back() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
ui.pad_button(PadButton::RightBumper);
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(ui.edit, None);
|
||||
assert_eq!(ui.draft().unwrap().players, [1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_or_put_back_one_highlight_and_the_focus_follows() {
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::RightBumper)); // to a new window
|
||||
assert!(ui.pad_button(PadButton::South));
|
||||
// Not selected (the focus is its highlight), and the focus goes to its new tile.
|
||||
assert_eq!(ui.selected, None);
|
||||
assert_eq!(ui.take_refocus(), Some(rect(&ui, "P2")));
|
||||
assert_eq!(ui.take_refocus(), None);
|
||||
// Put back: the same.
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(
|
||||
(ui.selected, ui.take_refocus()),
|
||||
(None, Some(rect(&ui, "P2")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
|
||||
// The language table is process-wide. Run this test alone in a child so
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
//! Shared nearest-sampled sprite array for block and item textures.
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpriteAnimation {
|
||||
@@ -427,7 +421,9 @@ impl Sprites {
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -536,7 +532,9 @@ impl Sprites {
|
||||
Self::decode_file(&path, name)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
@@ -694,9 +692,12 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
/// A PNG's width, height and pixels as RGBA. From the prepared data file
|
||||
/// when it holds the texture: no file opened per texture, nor for its absent
|
||||
/// LabPBR maps.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
let bytes = horizon_core::prepared_files::read(path)?;
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
@@ -792,6 +793,7 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Static block-entity meshes use section-local positions and the shared
|
||||
// entity vertex/fragment helpers. The shader subtracts integer block origins
|
||||
// before converting to f32; CPU supplies the camera's fractional block offset.
|
||||
struct StaticEntityUniform {
|
||||
camera_block: vec4<i32>,
|
||||
camera_fraction: vec4<f32>,
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> static_entity: StaticEntityUniform;
|
||||
|
||||
@vertex fn vs_static_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
@@ -173,6 +173,8 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A loaded chunk's summary, made on the far-terrain worker.
|
||||
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
@@ -183,6 +185,13 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// Whether a column since the last call was left empty because its data
|
||||
/// is still on its way (a BlueMap tile downloading, nothing coarser to
|
||||
/// stand in): that sample isn't drawn, what it would replace is
|
||||
/// (`Lod::finish_any_sample`), and it is taken again later.
|
||||
fn waited(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
@@ -201,6 +210,11 @@ pub trait LodSampler: Send {
|
||||
}
|
||||
pub trait LodSource: Send + Sync {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
|
||||
/// Whether its samplers' `observed` chunks become its own (a server's
|
||||
/// kept terrain): tiles sampled without one are sampled again.
|
||||
fn keeps_observed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
struct GeneratorSampler<G: ChunkGenerator> {
|
||||
generator: Arc<G>,
|
||||
@@ -234,6 +248,7 @@ impl<G: ChunkGenerator> Saves for G {
|
||||
struct MeshSettings {
|
||||
nether: bool,
|
||||
shader_pack_lighting: bool,
|
||||
separate_ao: bool,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
@@ -424,8 +439,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the loading screen, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -457,14 +472,15 @@ pub struct Streaming {
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
|
||||
pub loading: bool,
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
pub sky_light: bool,
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces: sections mesh without face shading
|
||||
/// (`Mesher::shader_pack_lighting`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
|
||||
/// (`Mesher::separate_ao`).
|
||||
pub separate_ao: bool,
|
||||
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
|
||||
pub biome_zoom: BiomeZoom,
|
||||
pub biome_blend: i32,
|
||||
@@ -526,9 +542,9 @@ struct GenerationScan {
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
/// What `plan` was made for: the ranges and decoration; the chunk
|
||||
/// map's revision is `chunk_revision`.
|
||||
plan_basis: Option<bool>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
@@ -552,9 +568,7 @@ impl GenerationScan {
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
if self.plan_basis != Some(input.decorates) || !self.same_ranges(&input.ranges) {
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
@@ -588,7 +602,6 @@ struct GenerationInputs<'a> {
|
||||
finishing: &'a FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
remote: bool,
|
||||
loading: bool,
|
||||
}
|
||||
impl GenerationScan {
|
||||
/// None means the scan was skipped; Some preserves the original candidate
|
||||
@@ -663,7 +676,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
@@ -685,7 +698,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,7 +718,7 @@ impl GenerationScan {
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
self.plan_basis = Some(input.decorates);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
@@ -878,6 +891,7 @@ impl<G: ChunkGenerator> Jobs<G> {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
separate_ao: settings.separate_ao,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
@@ -975,10 +989,10 @@ impl Streaming {
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
paused: false,
|
||||
loading: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
biome_zoom: BiomeZoom::new(0),
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
@@ -1151,7 +1165,7 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.loading, self.band);
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
return;
|
||||
}
|
||||
@@ -1289,7 +1303,7 @@ impl Streaming {
|
||||
}
|
||||
/// The order jobs around the player run in, lowest first.
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.ranges().priority(x, z, self.loading)
|
||||
self.ranges().priority(x, z)
|
||||
}
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
@@ -1920,6 +1934,7 @@ impl Streaming {
|
||||
MeshSettings {
|
||||
nether: self.nether_shading,
|
||||
shader_pack_lighting: self.shader_pack_lighting,
|
||||
separate_ao: self.separate_ao,
|
||||
zoom: self.biome_zoom,
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
@@ -1949,7 +1964,6 @@ impl Streaming {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: self.loading,
|
||||
};
|
||||
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
|
||||
for (_, x, z) in wanted {
|
||||
@@ -2436,33 +2450,14 @@ impl ExtraCover {
|
||||
}
|
||||
}
|
||||
impl Ranges<'_> {
|
||||
/// `distance`, or `rings` while the level loading screen is up.
|
||||
fn priority(&self, x: i32, z: i32, loading: bool) -> i64 {
|
||||
let own = if loading {
|
||||
self.rings(x, z)
|
||||
} else {
|
||||
self.distance(x, z)
|
||||
};
|
||||
/// `distance`, or the band's nearer priority close to it.
|
||||
fn priority(&self, x: i32, z: i32) -> i64 {
|
||||
let own = self.distance(x, z);
|
||||
match self.band {
|
||||
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
/// Ring by ring: the Chebyshev distance to the nearest centre first, then
|
||||
/// the squared distance. Vanilla's chunk tasks run by ticket level, which
|
||||
/// spreads one level per ring of chunks (`ChunkTaskPriorityQueue`), so
|
||||
/// `LevelLoadingScreen`'s chunk map fills squares around the spawn.
|
||||
fn rings(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
let ring = dx.abs().max(dz.abs());
|
||||
(ring * ring) << 20 | (dx * dx + dz * dz)
|
||||
};
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(c, _)| d(*c))
|
||||
.fold(d(self.center), i64::min)
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
@@ -2846,7 +2841,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
let ranges = |center| Ranges {
|
||||
center,
|
||||
@@ -2863,13 +2858,13 @@ mod tests {
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let loading = ranges((0, 0));
|
||||
// Both queued around an earlier centre, where the farther job came first.
|
||||
let earlier = ranges((40, 0));
|
||||
let current = ranges((0, 0));
|
||||
let queued = || {
|
||||
BinaryHeap::from(vec![
|
||||
// Queued while the loading screen was up: ring order.
|
||||
generate(3, 1, loading.priority(3, 1, true) * 4, 1),
|
||||
// Queued after it closed: plain distances, far below any ring past the first.
|
||||
generate(20, 0, loading.priority(20, 0, false) * 4, 2),
|
||||
generate(3, 1, earlier.priority(3, 1) * 4, 1),
|
||||
generate(20, 0, earlier.priority(20, 0) * 4, 2),
|
||||
Queued {
|
||||
priority: i64::MIN,
|
||||
at: None,
|
||||
@@ -2890,12 +2885,12 @@ mod tests {
|
||||
"the stale order"
|
||||
);
|
||||
let mut queue = queued();
|
||||
rank_again(&mut queue, |x, z| loading.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| current.priority(x, z));
|
||||
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
|
||||
// The player moved: the job near the new centre goes first.
|
||||
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
|
||||
let moved = ranges((19, 0));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z));
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
@@ -3079,7 +3074,6 @@ mod tests {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
fn fill(&mut self) {
|
||||
@@ -3499,33 +3493,6 @@ mod tests {
|
||||
/// from the save) and is not unloaded right after: either way it would be
|
||||
/// asked for and loaded again on the next frame, forever (8 such columns
|
||||
/// after a teleport at render distance 16 held 8 of the in-flight slots).
|
||||
/// #770: while the loading screen is up, chunks go ring by ring (a
|
||||
/// square front, as vanilla's ticket levels), nearest first in a ring.
|
||||
#[test]
|
||||
fn loading_goes_ring_by_ring() {
|
||||
let forced = HashSet::new();
|
||||
let r = Ranges {
|
||||
center: (2, -1),
|
||||
radius: 12,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let at = |dx: i32, dz: i32| (2 + dx, -1 + dz);
|
||||
let key = |(x, z): (i32, i32), loading| r.priority(x, z, loading);
|
||||
// The corner of ring 3 comes before the edge of ring 4 only while loading.
|
||||
assert!(key(at(3, 3), true) < key(at(0, 4), true));
|
||||
assert!(key(at(3, 3), false) > key(at(0, 4), false));
|
||||
// Inside a ring, nearest first; rings never interleave.
|
||||
assert!(key(at(0, 3), true) < key(at(2, -3), true));
|
||||
assert!(key(at(-3, -3), true) < key(at(4, 0), true));
|
||||
for dx in -20..=20 {
|
||||
for dz in -20..=20 {
|
||||
assert_eq!(key(at(dx, dz), false), r.distance(2 + dx, -1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn requested_columns_stay_loaded() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -176,7 +176,7 @@ impl TrimTexture {
|
||||
let Some(palette) = &self.palette else {
|
||||
return Some((w, h, pixels));
|
||||
};
|
||||
let base = fs::read(path.with_extension("png.mcmeta"))
|
||||
let base = horizon_core::prepared_files::read(&path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|m| {
|
||||
@@ -241,9 +241,10 @@ pub fn slot_size(root: &Path) -> (u32, u32) {
|
||||
|
||||
/// The width and height in a PNG's IHDR chunk.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
|
||||
let head: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
@@ -868,7 +868,77 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
tooltip_box(batch, atlas, lines, x, y, w, h);
|
||||
}
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
|
||||
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
|
||||
/// pointer where it is, and its tooltip stays against the widget at `rect`.
|
||||
pub fn tooltip_menu(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
cursor: (f32, f32),
|
||||
rect: [f32; 4],
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = menu_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
(cursor.0 as i32, cursor.1 as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
|
||||
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
|
||||
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 and `MAX_DISTANCE_TO_WIDGET` 5 of the vertical
|
||||
/// offset from the widget's top or bottom edge.
|
||||
pub fn menu_tooltip_position(
|
||||
screen: (i32, i32),
|
||||
cursor: (i32, i32),
|
||||
rect: [i32; 4],
|
||||
size: (i32, i32),
|
||||
) -> (i32, i32) {
|
||||
let [_, y, _, height] = rect;
|
||||
let height = height.max(1);
|
||||
let mut pos = (cursor.0 + 12, cursor.1);
|
||||
if pos.0 + size.0 > screen.0 - 5 {
|
||||
pos.0 = (cursor.0 - 12 - size.0).max(9);
|
||||
}
|
||||
pos.1 += 3;
|
||||
// `bottom() + 3 + getOffset(0, 0, height)` is the widget's edge, `height`
|
||||
// further down; below that the tooltip goes under it, else over it.
|
||||
let below = size.1 + 6;
|
||||
if y + 2 * height + below <= screen.1 - 5 {
|
||||
pos.1 += widget_offset(pos.1, y, height);
|
||||
} else {
|
||||
pos.1 -= below + widget_offset(pos.1, y + height, height);
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
|
||||
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
|
||||
/// The distance over the height is an int division in vanilla, so the lerp
|
||||
/// is `height - 3` until the pointer is a whole height away, then 5.
|
||||
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
|
||||
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
|
||||
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
|
||||
}
|
||||
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
@@ -878,14 +948,18 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
} else {
|
||||
10.0 * lines.len() as f32
|
||||
};
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
fn tooltip_box(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -2086,4 +2160,35 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
|
||||
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
|
||||
/// widget's top or bottom edge.
|
||||
#[test]
|
||||
fn a_menu_tooltip_stays_by_its_widget() {
|
||||
let screen = (427, 240);
|
||||
let widget = [100, 100, 150, 20];
|
||||
let size = (80, 10);
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px, then
|
||||
// `getOffset`'s int division gives `height - 3` = 17 more.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 100), widget, size),
|
||||
(112, 120)
|
||||
);
|
||||
// Near the right edge its left side mirrors to the pointer's left.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (400, 100), widget, size),
|
||||
(308, 120)
|
||||
);
|
||||
// A whole widget height from the edge, the offset is 5.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 117), widget, size),
|
||||
(112, 125)
|
||||
);
|
||||
// A widget too close to the bottom: the tooltip goes above it,
|
||||
// 203 - (10 + 6) - 17.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
|
||||
(112, 170)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ path = "src/main.rs"
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides entities behind opaque blocks.": "Versteckt Entitäten hinter undurchsichtigen Blöcken.",
|
||||
"No entity occlusion culling.": "Keine Verdeckungserkennung für Entitäten.",
|
||||
"CPU occlusion test runs this frame.": "Die CPU prüft die Verdeckung in diesem Frame.",
|
||||
"Occlusion checks run on a worker thread.": "Verdeckungsprüfungen laufen in einem Worker-Thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
@@ -203,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.split.leave": "Leave",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.split_setup": "Splitscreen Setup",
|
||||
@@ -170,6 +171,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
|
||||
"No entity occlusion culling.": "No entity occlusion culling.",
|
||||
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
|
||||
"Occlusion checks run on a worker thread.": "Occlusion checks run on a worker thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
@@ -207,8 +212,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
@@ -221,6 +224,7 @@
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.header.menus": "Menus",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
@@ -237,6 +241,7 @@
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
@@ -305,6 +310,7 @@
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
@@ -355,8 +361,38 @@
|
||||
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
|
||||
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Give Up Pad",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Controller connected",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Controller disconnected",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnect it, or press a button on another",
|
||||
"horizon.splitscreen.prompt.join.one": "%s wants to join",
|
||||
"horizon.splitscreen.prompt.join.many": "%s controllers want to join",
|
||||
"horizon.splitscreen.prompt.join.yes": "Join",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Play as P1",
|
||||
"horizon.splitscreen.prompt.join.no": "No",
|
||||
"horizon.splitscreen.prompt.lost": "%s: controller disconnected",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Use another",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Leave",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
|
||||
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Block Entities": "Block Entities",
|
||||
"Shulker Boxes": "Shulker Boxes",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Reuses settled boxes; lid movement stays animated.",
|
||||
"Signs": "Signs",
|
||||
"Reuses sign text until text, light, or font changes.": "Reuses sign text until text, light, or font changes.",
|
||||
"Decorated Pots": "Decorated Pots",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Reuses still pots; break wobble and sherd changes redraw.",
|
||||
"Bells": "Bells",
|
||||
"Reuses still bells; ringing stays animated.": "Reuses still bells; ringing stays animated.",
|
||||
"Chests": "Chests",
|
||||
"Reuses closed chests; moving lids stay animated.": "Reuses closed chests; moving lids stay animated.",
|
||||
"Banners": "Banners",
|
||||
"Reuses poles; flags and pattern layers still update.": "Reuses poles; flags and pattern layers still update.",
|
||||
"Copper Golem Statues": "Copper Golem Statues",
|
||||
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
|
||||
"Distant Plant Dither": "Distant Plant Dither",
|
||||
@@ -367,6 +403,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Find BlueMap": "Find BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
"BlueMap Radius": "BlueMap Radius",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "How far around you BlueMap tiles are downloaded. Next join.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
@@ -385,5 +425,20 @@
|
||||
"horizon.agent.off": "Off: agents can't reach this game.",
|
||||
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
|
||||
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"horizon.extras.repro_keys": "Recording shortcuts",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
|
||||
"horizon.agent.record_session": "Start Recording Session",
|
||||
"horizon.agent.record_stop": "Stop Recording Session",
|
||||
"horizon.agent.export_repro": "Export Repro Bundle",
|
||||
"horizon.repro.started": "Recording started; F3+R stops it and writes the bundle.",
|
||||
"horizon.repro.stopped": "Recording stopped: %s",
|
||||
"horizon.repro.bundle": "Repro bundle: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s"
|
||||
}
|
||||
@@ -158,6 +158,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
|
||||
"No entity occlusion culling.": "Sin oclusión de entidades.",
|
||||
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
|
||||
"Occlusion checks run on a worker thread.": "Las comprobaciones de oclusión se ejecutan en un hilo de trabajo.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
|
||||
@@ -194,8 +198,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides entities behind opaque blocks.": "Masque les entités derrière les blocs opaques.",
|
||||
"No entity occlusion culling.": "Aucun masquage des entités occultées.",
|
||||
"CPU occlusion test runs this frame.": "Le CPU teste l’occlusion sur cette image.",
|
||||
"Occlusion checks run on a worker thread.": "Les tests d’occlusion s’exécutent sur un thread de travail.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
@@ -203,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
@@ -343,6 +345,21 @@
|
||||
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
|
||||
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Céder la manette",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Manette connectée",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Manette déconnectée",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnectez-la, ou appuyez sur un bouton d'une autre",
|
||||
"horizon.splitscreen.prompt.join.one": "%s veut rejoindre",
|
||||
"horizon.splitscreen.prompt.join.many": "%s manettes veulent rejoindre",
|
||||
"horizon.splitscreen.prompt.join.yes": "Rejoindre",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Jouer en J1",
|
||||
"horizon.splitscreen.prompt.join.no": "Non",
|
||||
"horizon.splitscreen.prompt.lost": "%s : manette déconnectée",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Utiliser une autre",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Quitter",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Votre manette ne vous contrôle plus et peut rejoindre la partie (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s vers %s : A le dépose ici, B le remet en place.",
|
||||
"horizon.splitscreen.setup.note.held": "%s est pris : gauche/droite choisit où, A dépose, B remet en place.",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
@@ -355,6 +372,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Find BlueMap": "Trouver BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Sur les serveurs, trouve leur carte web BlueMap pour le terrain lointain. À la prochaine connexion.",
|
||||
"BlueMap Radius": "Rayon BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Distance autour de vous jusqu'où les tuiles BlueMap sont téléchargées. À la prochaine connexion.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides entities behind opaque blocks.": "Ukrywa byty za nieprzezroczystymi blokami.",
|
||||
"No entity occlusion culling.": "Bez odrzucania zasłoniętych bytów.",
|
||||
"CPU occlusion test runs this frame.": "Test zasłonięcia wykonuje CPU w tej klatce.",
|
||||
"Occlusion checks run on a worker thread.": "Testy zasłonięcia działają w wątku roboczym.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
@@ -203,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
|
||||
@@ -157,6 +157,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides entities behind opaque blocks.": "Скрывает сущности за непрозрачными блоками.",
|
||||
"No entity occlusion culling.": "Без отсечения сущностей по перекрытию.",
|
||||
"CPU occlusion test runs this frame.": "В этом кадре проверку перекрытия выполняет CPU.",
|
||||
"Occlusion checks run on a worker thread.": "Проверки перекрытия выполняются в рабочем потоке.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
@@ -193,8 +197,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -158,6 +158,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
|
||||
"Hides entities behind opaque blocks.": "Opak blokların arkasındaki varlıkları gizler.",
|
||||
"No entity occlusion culling.": "Varlık oklüzyon ayıklaması kapalı.",
|
||||
"CPU occlusion test runs this frame.": "Oklüzyon denetimini bu karede CPU yapar.",
|
||||
"Occlusion checks run on a worker thread.": "Oklüzyon denetimleri bir iş parçacığında çalışır.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides entities behind opaque blocks.": "Ховає сутності за непрозорими блоками.",
|
||||
"No entity occlusion culling.": "Відсікання закритих сутностей вимкнено.",
|
||||
"CPU occlusion test runs this frame.": "Цього кадру перекриття перевіряє CPU.",
|
||||
"Occlusion checks run on a worker thread.": "Перевірки перекриття виконуються в робочому потоці.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
@@ -203,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
@@ -327,6 +329,9 @@
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.header.menus": "Меню",
|
||||
"horizon.extras.wheel_cycles_settings": "Коліщатко змінює налаштування",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "Коліщатко миші над кнопкою в налаштуваннях змінює її значення, як у ванільній грі. Вимкнено — коліщатко лише гортає список.",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
@@ -336,5 +341,104 @@
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу.",
|
||||
"horizon.split.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.monitor": "Монітор %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "монітор",
|
||||
"horizon.splitscreen.setup.window": "Вікно %s",
|
||||
"horizon.splitscreen.setup.new_window": "Нове вікно",
|
||||
"horizon.splitscreen.setup.fill_monitor": "На весь %s",
|
||||
"horizon.splitscreen.setup.done": "Готово",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Увімк.",
|
||||
"horizon.splitscreen.setup.off": "Вимк.",
|
||||
"horizon.splitscreen.setup.player": "Гравець %s",
|
||||
"horizon.splitscreen.setup.revert": "Скасувати",
|
||||
"horizon.splitscreen.setup.apply": "Застосувати",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "У вікні 1 ніхто не грає: воно сховається",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "У вікні %s ніхто не грає: воно закриється після «Застосувати»",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "У вікні %s ніхто не грає: воно не відкриється",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Вікно %s займає монітор вікна 1: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Монітор вікна %s не під’єднано: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.status.off": "Розділений екран вимкнено.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Увімкніть його, а потім у світі натисніть кнопку на іншому контролері.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Натисніть A, щоб приєднатися, і виберіть профіль.",
|
||||
"horizon.splitscreen.setup.status.idle": "Розділений екран очікує",
|
||||
"horizon.splitscreen.setup.status.alone": "Інших локальних гравців немає.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Керування й віддача контролера для одного гравця не змінюються.",
|
||||
"horizon.splitscreen.setup.window_empty": "Вікно %s: порожнє",
|
||||
"horizon.splitscreen.setup.note.select": "Клацніть гравця або перетягніть його на вікно чи монітор.",
|
||||
"horizon.splitscreen.setup.note.apply": "«Застосувати» — грати так, «Скасувати» — повернути як було.",
|
||||
"horizon.splitscreen.setup.note.place": "Де має грати %s?",
|
||||
"horizon.splitscreen.setup.this_player": "цей гравець",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Розділений екран вимкнеться, щойно інші вийдуть (через їхнє меню паузи).",
|
||||
"horizon.splitscreen.setup.window_place": "Вікно %s (%s із %s)",
|
||||
"horizon.splitscreen.setup.names": "Імена",
|
||||
"horizon.splitscreen.setup.identify": "Позначити",
|
||||
"horizon.splitscreen.setup.layout.two": "Удвох: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "Утрьох: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Авто",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Поруч",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Згори й знизу",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Один широкий згори",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Один високий ліворуч",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Поруч",
|
||||
"horizon.splitscreen.setup.help.player": "Клацніть або перетягніть %s, щоб перемістити.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (поруч, коли вікно широке). Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.switch": "Розділений екран на цьому комп’ютері: увімкнено чи вимкнено.",
|
||||
"horizon.splitscreen.setup.help.identify": "Кожен гравець бачить у своєму виді, в якому він вікні.",
|
||||
"horizon.splitscreen.setup.help.names": "Ім’я кожного гравця в кутку його виду.",
|
||||
"horizon.splitscreen.setup.help.revert": "Скасувати ще не застосовані зміни.",
|
||||
"horizon.splitscreen.setup.help.apply": "Грати так, як показує схема.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Поки нічого не змінено.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Віддати контролер",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Контролер під’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Під’єднайте його знову або натисніть кнопку на іншому",
|
||||
"horizon.splitscreen.prompt.join.one": "%s хоче приєднатися",
|
||||
"horizon.splitscreen.prompt.join.many": "Контролерів, що хочуть приєднатися: %s",
|
||||
"horizon.splitscreen.prompt.join.yes": "Приєднатися",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Грати як Г1",
|
||||
"horizon.splitscreen.prompt.join.no": "Ні",
|
||||
"horizon.splitscreen.prompt.lost": "%s: контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Узяти інший",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Ваш контролер більше не керує вами, і його можна взяти, щоб приєднатися (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s → %s: A — поставити тут, B — повернути назад.",
|
||||
"horizon.splitscreen.setup.note.held": "%s узято: ліворуч/праворуч — вибрати місце, A — поставити, B — повернути назад.",
|
||||
"Block Entities": "Блокові сутності",
|
||||
"Shulker Boxes": "Шалкерові коробки",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Повторно використовує нерухомі коробки; рух кришки лишається анімованим.",
|
||||
"Signs": "Таблички",
|
||||
"Reuses sign text until text, light, or font changes.": "Повторно використовує текст табличок, доки не зміняться текст, світло чи шрифт.",
|
||||
"Decorated Pots": "Оздоблені горщики",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Повторно використовує нерухомі горщики; хитання від удару й зміна черепків перемальовуються.",
|
||||
"Bells": "Дзвони",
|
||||
"Reuses still bells; ringing stays animated.": "Повторно використовує нерухомі дзвони; дзвін лишається анімованим.",
|
||||
"Chests": "Скрині",
|
||||
"Reuses closed chests; moving lids stay animated.": "Повторно використовує закриті скрині; рухомі кришки лишаються анімованими.",
|
||||
"Banners": "Стяги",
|
||||
"Reuses poles; flags and pattern layers still update.": "Повторно використовує держаки; полотнища й шари візерунків оновлюються.",
|
||||
"Copper Golem Statues": "Статуї мідних ґолемів",
|
||||
"Reuses statue geometry until its block state changes.": "Повторно використовує геометрію статуї, доки не зміниться стан її блока.",
|
||||
"Find BlueMap": "Шукати BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "На серверах шукає їхню вебмапу BlueMap для далекого рельєфу. З наступного входу.",
|
||||
"BlueMap Radius": "Радіус BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Як далеко навколо вас завантажуються плитки BlueMap. З наступного входу.",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Приховує моделі за непрозорими блоками. Сутності оновлюються. Вимкнено з власними наборами ресурсів.",
|
||||
"horizon.extras.repro_keys": "Клавіші запису",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R починає або зупиняє запис для відтворення помилки, F3+E зберігає його пакет. Вимкнено за замовчуванням: запис копіює світ.",
|
||||
"horizon.agent.record_session": "Почати запис сесії",
|
||||
"horizon.agent.record_stop": "Зупинити запис сесії",
|
||||
"horizon.agent.export_repro": "Експортувати пакет відтворення",
|
||||
"horizon.repro.started": "Запис почато; F3+R зупиняє його й зберігає пакет.",
|
||||
"horizon.repro.stopped": "Запис зупинено: %s",
|
||||
"horizon.repro.bundle": "Пакет відтворення: %s",
|
||||
"horizon.repro.error": "Відтворення: %s",
|
||||
"horizon.repro.no_recording": "Поки немає запису для експорту.",
|
||||
"horizon.repro.replay_started": "Відтворення почато: %s"
|
||||
}
|
||||
@@ -160,6 +160,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides entities behind opaque blocks.": "隐藏不透明方块后的实体。",
|
||||
"No entity occlusion culling.": "不进行实体遮挡剔除。",
|
||||
"CPU occlusion test runs this frame.": "本帧由 CPU 执行遮挡检测。",
|
||||
"Occlusion checks run on a worker thread.": "遮挡检测在工作线程上运行。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
@@ -197,8 +201,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
|
||||
@@ -219,6 +219,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--repeat 1] [--json]
|
||||
//!
|
||||
//! For each run it prints the wall time until every column within the radius
|
||||
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
|
||||
@@ -16,8 +16,7 @@
|
||||
//! covers the blocks and light of the columns within the radius: an A/B pair of
|
||||
//! builds, and every worker count, must print the same one.
|
||||
//! `--period-ms` is how long the coordinator sleeps between updates (the
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
|
||||
//! orders work ring by ring, as under the level loading screen.
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks).
|
||||
//!
|
||||
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
|
||||
//! one whose jobs sleep that long and make empty columns: what is left is the
|
||||
@@ -117,13 +116,11 @@ fn run<G: ChunkGenerator>(
|
||||
radius: i32,
|
||||
threads: usize,
|
||||
period: Duration,
|
||||
loading: bool,
|
||||
) -> Result<Run, Error> {
|
||||
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
streaming.loading = loading;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
|
||||
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
|
||||
@@ -215,7 +212,6 @@ fn main() -> Result<(), Error> {
|
||||
let radius: i32 = flag(&args, "--radius", 12);
|
||||
let repeat: usize = flag(&args, "--repeat", 1);
|
||||
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
|
||||
let loading = args.iter().any(|a| a == "--loading");
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let simulate: Option<Vec<f64>> = args
|
||||
.iter()
|
||||
@@ -291,7 +287,7 @@ fn main() -> Result<(), Error> {
|
||||
render,
|
||||
});
|
||||
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
|
||||
let go = |center, radius, n, loading| -> Result<Run, Error> {
|
||||
let go = |center, radius, n| -> Result<Run, Error> {
|
||||
match &simulate {
|
||||
Some(ms) => {
|
||||
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
|
||||
@@ -300,18 +296,18 @@ fn main() -> Result<(), Error> {
|
||||
decorate: ms(1),
|
||||
finish: ms(2),
|
||||
};
|
||||
run(generator, &context, center, radius, n, period, loading)
|
||||
run(generator, &context, center, radius, n, period)
|
||||
}
|
||||
None => run(terrain()?, &context, center, radius, n, period, loading),
|
||||
None => run(terrain()?, &context, center, radius, n, period),
|
||||
}
|
||||
};
|
||||
// Warm the shared generator data and the code away from the measured area.
|
||||
go((center.0 + 4000, center.1), 2, n, false)?;
|
||||
go((center.0 + 4000, center.1), 2, n)?;
|
||||
let pool = horizon_threads::jobs::pool().threads();
|
||||
if pool != n {
|
||||
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
|
||||
}
|
||||
let r = go(center, radius, n, loading)?;
|
||||
let r = go(center, radius, n)?;
|
||||
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
|
||||
println!(
|
||||
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn main() -> Result<()> {
|
||||
));
|
||||
}
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
ArrowEvent::Block { pos, reaction, .. } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
// TntBlock.prime: PrimedTnt draws its push from the
|
||||
// level random, then removeBlock(pos, false).
|
||||
|
||||
@@ -21,7 +21,7 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
@@ -364,6 +364,8 @@ pub fn main() -> Result<()> {
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
translucent_sprites: &HashSet::new(),
|
||||
atlas_revision: 1,
|
||||
nether: false,
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
|
||||
@@ -116,7 +116,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
if flag("@batServerTick") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
|
||||
@@ -1171,6 +1171,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
@@ -1196,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
border: None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ fn scene(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: row["nether"].as_bool().unwrap_or(false),
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
|
||||
@@ -104,22 +104,20 @@ impl Tree {
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
for ns in namespaces.flatten() {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
// One JSON per id, the packs' over the game's own. Through `json_folder`: from
|
||||
// the prepared data file, and read once while a load holds the folder.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("advancement"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
let ns = ns.file_name().to_string_lossy().into_owned();
|
||||
for file in &folder.files {
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
|
||||
json.insert(format!("{ns}:{}", file.name), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
@@ -249,21 +247,6 @@ impl InventoryIndex {
|
||||
out
|
||||
}
|
||||
}
|
||||
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect(root, &p, ns, out);
|
||||
} else if p.extension().is_some_and(|x| x == "json") {
|
||||
let rel = p.strip_prefix(root).unwrap_or(&p).with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
out.push((format!("{ns}:{rel}"), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
let criteria: Vec<Criterion> = v["criteria"]
|
||||
.as_object()?
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// `AmbientMoodSettings`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -54,17 +54,19 @@ pub struct AmbientTables {
|
||||
impl AmbientTables {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let read = |dir: &str| -> HashMap<String, AmbientSounds> {
|
||||
let Ok(entries) = fs::read_dir(root.join("data/minecraft").join(dir)) else {
|
||||
let Ok(folder) =
|
||||
horizon_world::json_folder::read(&root.join("data/minecraft").join(dir))
|
||||
else {
|
||||
return HashMap::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let name = path.file_stem()?.to_str()?.to_owned();
|
||||
let json: Value = serde_json::from_slice(&fs::read(&path).ok()?).ok()?;
|
||||
folder
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
let json: Value = serde_json::from_slice(file.bytes().ok()?).ok()?;
|
||||
let v = &json["attributes"]["minecraft:audio/ambient_sounds"];
|
||||
(!v.is_null()).then(|| (format!("minecraft:{name}"), AmbientSounds::parse(v)))
|
||||
(!v.is_null())
|
||||
.then(|| (format!("minecraft:{}", file.name), AmbientSounds::parse(v)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! when the block goes, and despawning. The tick stops at an entity it hits:
|
||||
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
|
||||
//! spends the arrow or turns it back and ends the tick.
|
||||
use crate::entity_rules::EntityRules;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
collisions::SceneCollisions,
|
||||
@@ -117,6 +118,8 @@ pub enum ArrowEvent {
|
||||
Block {
|
||||
pos: BlockPos,
|
||||
reaction: BlockReaction,
|
||||
/// The arrow's owner (`Projectile.getOwner`), who owns a TNT it primes.
|
||||
owner: Option<Who>,
|
||||
},
|
||||
/// The SPLASH and PROJECTILE_SHOOT game events.
|
||||
SplashEvent,
|
||||
@@ -159,6 +162,7 @@ pub enum BlockReaction {
|
||||
#[derive(Clone)]
|
||||
pub struct ArrowState {
|
||||
pub in_ground: bool,
|
||||
pub canceled_impact_inside: bool,
|
||||
pub in_ground_time: i32,
|
||||
/// Ticks stuck (`life`): gone at 1200.
|
||||
pub life: i32,
|
||||
@@ -217,6 +221,7 @@ impl ArrowState {
|
||||
pub fn new(random: LegacyRandom) -> Self {
|
||||
Self {
|
||||
in_ground: false,
|
||||
canceled_impact_inside: false,
|
||||
in_ground_time: 0,
|
||||
life: 0,
|
||||
shake: 0,
|
||||
@@ -346,13 +351,21 @@ impl ArrowScene<'_, '_> {
|
||||
.map(String::as_str)
|
||||
}
|
||||
/// `ClipContext.Block.COLLIDER` with `Fluid.NONE`: the first block hit.
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
fn clip(
|
||||
&self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
ignored: Option<BlockPos>,
|
||||
) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
if Some(p) == ignored {
|
||||
return None;
|
||||
}
|
||||
let state = self.block(p);
|
||||
if self.is_air(state) {
|
||||
return None;
|
||||
@@ -521,8 +534,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
|
||||
tick_with(p, scene, targets, true)
|
||||
}
|
||||
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
|
||||
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
|
||||
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
|
||||
/// flies through its full movement: not stuck, nothing hurt, no hit.
|
||||
pub fn tick_through(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
@@ -647,15 +659,18 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
f64::from(block_pos.z),
|
||||
);
|
||||
let at = self.position();
|
||||
if self
|
||||
let inside = self
|
||||
.scene
|
||||
.collision_boxes(state, block_pos)
|
||||
.iter()
|
||||
.any(|b| b.move_by(origin).contains(at))
|
||||
{
|
||||
.any(|b| b.move_by(origin).contains(at));
|
||||
if inside && !self.st.canceled_impact_inside {
|
||||
self.p.velocity = Vec3::ZERO;
|
||||
self.st.in_ground = true;
|
||||
}
|
||||
self.st.canceled_impact_inside &= inside;
|
||||
} else {
|
||||
self.st.canceled_impact_inside = false;
|
||||
}
|
||||
if self.st.shake > 0 {
|
||||
self.st.shake -= 1;
|
||||
@@ -741,7 +756,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
original.y + movement.y,
|
||||
original.z + movement.z,
|
||||
);
|
||||
if physics {
|
||||
if physics && impact {
|
||||
hit = self.step_move_and_hit(original, end, targets, impact);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
@@ -749,6 +764,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
} else {
|
||||
self.p.position = end;
|
||||
self.apply_effects_from_blocks(original, end);
|
||||
if physics {
|
||||
self.st.canceled_impact_inside = true;
|
||||
}
|
||||
}
|
||||
self.finish();
|
||||
hit
|
||||
@@ -760,9 +778,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.was_touching_water {
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
|
||||
}
|
||||
if physics && !self.st.in_ground && !self.p.no_gravity {
|
||||
if physics && !self.st.in_ground {
|
||||
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
|
||||
self.add_velocity(-0.05);
|
||||
self.add_velocity(-self.p.get_gravity());
|
||||
}
|
||||
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
|
||||
if !self.st.has_been_shot {
|
||||
@@ -833,7 +851,11 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let ignored = self
|
||||
.st
|
||||
.canceled_impact_inside
|
||||
.then(|| self.block_position());
|
||||
let block = self.scene.clip(from, to, ignored);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
let margin = f64::from(((self.p.age as i32 - 2) as f32 / 20.0).clamp(0.0, 0.3));
|
||||
@@ -914,7 +936,10 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
let centre = b.center();
|
||||
let towards = self.scene.clip(outside, centre).map_or(centre, |h| h.0);
|
||||
let towards = self
|
||||
.scene
|
||||
.clip(outside, centre, None)
|
||||
.map_or(centre, |h| h.0);
|
||||
b.clip(outside, towards).map(|_| outside)
|
||||
}
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
@@ -982,7 +1007,11 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if self.st.on_fire()
|
||||
&& let Some(reaction) = self.block_reaction(state)
|
||||
{
|
||||
self.st.events.push(ArrowEvent::Block { pos, reaction });
|
||||
self.st.events.push(ArrowEvent::Block {
|
||||
pos,
|
||||
reaction,
|
||||
owner: self.p.owner,
|
||||
});
|
||||
}
|
||||
let v = self.p.velocity;
|
||||
let offset = f64::from(0.05f32);
|
||||
|
||||
@@ -204,6 +204,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
|
||||
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
|
||||
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
@@ -454,6 +457,23 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
// Repro recordings
|
||||
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
|
||||
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
|
||||
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
|
||||
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
|
||||
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
|
||||
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
|
||||
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
|
||||
["horizon-ctl record-status"], &[], mcp: true, local: false),
|
||||
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
|
||||
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]", "replay a recording, best effort",
|
||||
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. Only input and movement commands replay (never world, network or account ones). --in-place feeds the input into the world already open (a server recording needs it); --keep-world leaves the copied world behind when it ends. --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
|
||||
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
|
||||
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("keep-world", Ty::Bool, "leave the copied world in saves/"), flag("timeout", Ty::Int, "give up after this many seconds (default 300)")], mcp: true, local: false),
|
||||
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
|
||||
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
|
||||
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
|
||||
@@ -314,13 +314,26 @@ fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
/// Paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return;
|
||||
};
|
||||
match words.first().map(String::as_str) {
|
||||
Some("screenshot") => {
|
||||
if let Some(path) = words.get_mut(1) {
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
// `repro-export [NAME] [PATH]`: the second positional is the zip.
|
||||
Some("repro-export") => {
|
||||
if let Some(path) = words.get_mut(2)
|
||||
&& (path.contains('/') || path.ends_with(".zip"))
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,14 @@
|
||||
//! shared table, run through the same `Session` as the CLI.
|
||||
use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
/// Revisions this server implements. Older ones are not claimed: only the revision actually
|
||||
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
|
||||
const SUPPORTED: &[&str] = &["2025-06-18"];
|
||||
/// What an agent reads before its first call.
|
||||
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
|
||||
|
||||
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
|
||||
fn negotiate_version(params: &Value) -> &'static str {
|
||||
let wanted = params.get("protocolVersion").and_then(Value::as_str);
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| Some(*v) == wanted)
|
||||
.unwrap_or(SUPPORTED[0])
|
||||
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
|
||||
/// `2025-06-18` for the clients that still open with `initialize`).
|
||||
fn server() -> horizon_mcp::Server<'static> {
|
||||
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
@@ -143,15 +137,10 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
|
||||
/// `server/discover`) and frames these results for the revision the request speaks.
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": negotiate_version(params),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({ "tools": tools() })),
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
@@ -171,52 +160,13 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Err(e) => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
Ok(msg) => {
|
||||
let method = msg
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// Notifications (no id) get no reply.
|
||||
msg.get("id").cloned().map(|id| match session.handle(method, ¶ms) {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
writeln!(stdout, "{reply}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
server().serve(|method, params| session.handle(method, params))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn version_negotiation() {
|
||||
let v = |p: Value| negotiate_version(&p);
|
||||
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
|
||||
assert_eq!(v(json!({})), "2025-06-18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
@@ -255,28 +205,66 @@ mod tests {
|
||||
#[test]
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-06-18");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
"tools/call",
|
||||
&json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
let mut app = |method: &str, params: &Value| s.handle(method, params);
|
||||
let server = server();
|
||||
let line = |method: &str, params: Value| {
|
||||
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
|
||||
};
|
||||
|
||||
// A client that opens with the handshake revision.
|
||||
let init = server
|
||||
.handle_line(
|
||||
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
|
||||
&mut app,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
|
||||
|
||||
// A modern client: stateless, and the same tools.
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
horizon_mcp::VERSION_KEY.to_owned(),
|
||||
json!(horizon_mcp::MODERN),
|
||||
);
|
||||
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
|
||||
let params = json!({ "_meta": Value::Object(meta) });
|
||||
let list = server
|
||||
.handle_line(&line("tools/list", params), &mut app)
|
||||
.unwrap();
|
||||
assert_eq!(list["result"]["resultType"], "complete");
|
||||
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
|
||||
|
||||
let r = server
|
||||
.handle_line(
|
||||
&line(
|
||||
"tools/call",
|
||||
json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
),
|
||||
&mut app,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
assert!(
|
||||
r["content"][0]["text"]
|
||||
r["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("y is required")
|
||||
);
|
||||
// No game: a connection error, reported as a tool error.
|
||||
let r = s
|
||||
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
|
||||
let r = server
|
||||
.handle_line(
|
||||
&line("tools/call", json!({ "name": "state", "arguments": {} })),
|
||||
&mut app,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
// Methods this server does not have are the protocol's to refuse.
|
||||
assert_eq!(
|
||||
server
|
||||
.handle_line(&line("nope/nope", json!({})), &mut app)
|
||||
.unwrap()["error"]["code"],
|
||||
json!(-32601)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,17 @@ pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
|
||||
Some((block_entity_nbt::id(data)?, data))
|
||||
}
|
||||
|
||||
/// `JukeboxBlock.setPlacedBy`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `RecordItem`, so the placed jukebox holds a record and its
|
||||
/// `has_record` state has to say so. Vanilla reads the item's data, not the
|
||||
/// block entity the placement just filled.
|
||||
pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
@@ -602,6 +613,40 @@ mod tests {
|
||||
assert!(!op_warning(&chest));
|
||||
}
|
||||
|
||||
/// `JukeboxBlock.setPlacedBy` looks for a `RecordItem` in the placed
|
||||
/// item's block entity data, whatever the type it names: only that key
|
||||
/// turns the box's `has_record` on.
|
||||
#[test]
|
||||
fn a_jukebox_item_with_a_record_in_its_data_says_so() {
|
||||
let jukebox = |text: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:jukebox", 1)
|
||||
}
|
||||
};
|
||||
// A plain jukebox, and one whose data is only the type id: no record.
|
||||
assert!(!carries_record(&crate::items::ItemStack::new(
|
||||
"minecraft:jukebox",
|
||||
1
|
||||
)));
|
||||
assert!(!carries_record(&jukebox("{id:\"minecraft:jukebox\"}")));
|
||||
assert!(!carries_record(&jukebox(
|
||||
"{id:\"minecraft:jukebox\",Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}"
|
||||
)));
|
||||
// Creative's Ctrl + Pick Block on a jukebox holding a disc.
|
||||
assert!(carries_record(&jukebox(
|
||||
"{id:\"minecraft:jukebox\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
|
||||
)));
|
||||
// The key alone counts: `TypedEntityData.contains` never checks the
|
||||
// type, and the state it sets is the one the block is placed as.
|
||||
assert!(carries_record(&jukebox(
|
||||
"{id:\"minecraft:chest\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_sign_gives_its_text_and_wax() {
|
||||
let sign = saved(
|
||||
|
||||
@@ -9,18 +9,19 @@ pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
|
||||
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
|
||||
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
|
||||
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
|
||||
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -58,6 +59,7 @@ pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod entity_rules;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
|
||||
@@ -311,10 +311,6 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
last_overload_warning: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -332,8 +328,6 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,41 +442,6 @@ impl FixedStep {
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
/// Whether a frame that ran its world in the frame, and whose ticks so
|
||||
/// far took `spent`, may run another one. Vanilla's world ticks on the
|
||||
/// integrated server's thread, so a slow tick costs its frames nothing:
|
||||
/// the server falls behind and says so (`MinecraftServer.runServer`'s
|
||||
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
|
||||
/// their length falls behind every frame, so every frame ran the client
|
||||
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
|
||||
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
|
||||
/// full ten, and a world that cannot keep up runs one tick a frame,
|
||||
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
|
||||
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
|
||||
self.sprinting() || spent < self.tick_duration()
|
||||
}
|
||||
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
|
||||
/// slower than its tick rate. Returns vanilla's overload warning when one
|
||||
/// is due: a second's worth of ticks behind, at most every ten seconds
|
||||
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
|
||||
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
|
||||
self.dropped = self.dropped.saturating_add(ticks);
|
||||
let tick = self.tick_duration();
|
||||
let behind = tick * self.dropped;
|
||||
let threshold = std::time::Duration::from_secs(1) + tick * 20;
|
||||
let interval = std::time::Duration::from_secs(10) + tick * 100;
|
||||
if behind <= threshold || self.now < self.last_overload_warning + interval {
|
||||
return None;
|
||||
}
|
||||
let warning = format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
self.dropped
|
||||
);
|
||||
self.dropped = 0;
|
||||
self.last_overload_warning = self.now;
|
||||
Some(warning)
|
||||
}
|
||||
/// `requestGameToSprint`: true when a sprint was already running.
|
||||
pub fn start_sprint(&mut self, ticks: u32) -> bool {
|
||||
self.manager
|
||||
@@ -554,37 +513,6 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_stops_ticking_after_a_ticks_length() {
|
||||
let clock = FixedStep::default();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
|
||||
assert!(clock.tick_fits(ms(4 * 9)));
|
||||
// Ticks of 40 ms: the second one runs, not a third.
|
||||
assert!(clock.tick_fits(ms(40)));
|
||||
assert!(!clock.tick_fits(ms(80)));
|
||||
// One tick longer than its length still runs, once a frame.
|
||||
assert!(clock.tick_fits(ms(0)));
|
||||
assert!(!clock.tick_fits(ms(120)));
|
||||
}
|
||||
#[test]
|
||||
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
|
||||
let mut clock = FixedStep::default();
|
||||
let s = std::time::Duration::from_secs;
|
||||
clock.now = s(20);
|
||||
assert_eq!(
|
||||
clock.drop_ticks(40),
|
||||
None,
|
||||
"two seconds: not past 1 s + 20 ticks"
|
||||
);
|
||||
let warning = clock.drop_ticks(1).expect("past the threshold");
|
||||
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
|
||||
clock.now = s(25);
|
||||
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
|
||||
clock.now = s(36);
|
||||
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! path ends in `.json`, the game listens on a random localhost TCP port and
|
||||
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
|
||||
//! only by its user; a client's first line must then be `auth TOKEN`.
|
||||
use crate::block_entity_render::StaticCacheFrameStats;
|
||||
use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
@@ -411,6 +412,30 @@ mod tests {
|
||||
assert_eq!(random_token().len(), 32);
|
||||
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_summary_reports_block_entity_cache_work() {
|
||||
let mut stats = FrameStats::default();
|
||||
stats.record(10.0);
|
||||
stats.record_cpu(5.0);
|
||||
stats.record_block_entities(StaticCacheFrameStats {
|
||||
snapshot_rebuilds: 2,
|
||||
snapshot_bytes_built: 128,
|
||||
static_bytes_uploaded: 96,
|
||||
static_entities_drawn: 4,
|
||||
dynamic_entities_drawn: 3,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let summary = stats.summary();
|
||||
assert_eq!(summary["static_snapshot_rebuilds"], 2);
|
||||
assert_eq!(summary["static_snapshot_bytes_built"], 128);
|
||||
assert_eq!(summary["static_bytes_uploaded"], 96);
|
||||
assert_eq!(summary["static_entities_drawn"], 4);
|
||||
assert_eq!(summary["dynamic_entities_drawn"], 3);
|
||||
assert_eq!(summary["static_entities_drawn_avg_per_frame"], 4.0);
|
||||
assert_eq!(summary["dynamic_entities_drawn_avg_per_frame"], 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder,
|
||||
@@ -422,6 +447,11 @@ pub struct FrameStats {
|
||||
gpu: Vec<f32>,
|
||||
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
|
||||
phases: Vec<[f32; PHASES.len()]>,
|
||||
static_snapshot_rebuilds: u64,
|
||||
static_snapshot_bytes_built: u64,
|
||||
static_bytes_uploaded: u64,
|
||||
static_entities_drawn: u64,
|
||||
dynamic_entities_drawn: u64,
|
||||
}
|
||||
|
||||
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
|
||||
@@ -490,6 +520,14 @@ impl FrameStats {
|
||||
self.gpu.extend_from_slice(millis);
|
||||
}
|
||||
}
|
||||
/// Block-entity cache counters for the frame just rendered.
|
||||
pub fn record_block_entities(&mut self, stats: StaticCacheFrameStats) {
|
||||
self.static_snapshot_rebuilds += stats.snapshot_rebuilds;
|
||||
self.static_snapshot_bytes_built += stats.snapshot_bytes_built;
|
||||
self.static_bytes_uploaded += stats.static_bytes_uploaded;
|
||||
self.static_entities_drawn += stats.static_entities_drawn;
|
||||
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
|
||||
}
|
||||
/// Every recorded frame time, in milliseconds, in order.
|
||||
pub fn frame_millis(&self) -> &[f32] {
|
||||
&self.millis
|
||||
@@ -507,6 +545,11 @@ impl FrameStats {
|
||||
self.cpu.clear();
|
||||
self.gpu.clear();
|
||||
self.phases.clear();
|
||||
self.static_snapshot_rebuilds = 0;
|
||||
self.static_snapshot_bytes_built = 0;
|
||||
self.static_bytes_uploaded = 0;
|
||||
self.static_entities_drawn = 0;
|
||||
self.dynamic_entities_drawn = 0;
|
||||
}
|
||||
pub fn summary(&self) -> serde_json::Value {
|
||||
let mut sorted = self.millis.clone();
|
||||
@@ -550,6 +593,13 @@ impl FrameStats {
|
||||
"gpu": percentiles(&self.gpu),
|
||||
"phases_avg_ms": self.phase_average(0.0),
|
||||
"slow_phases_avg_ms": slow_phases,
|
||||
"static_snapshot_rebuilds": self.static_snapshot_rebuilds,
|
||||
"static_snapshot_bytes_built": self.static_snapshot_bytes_built,
|
||||
"static_bytes_uploaded": self.static_bytes_uploaded,
|
||||
"static_entities_drawn": self.static_entities_drawn,
|
||||
"dynamic_entities_drawn": self.dynamic_entities_drawn,
|
||||
"static_entities_drawn_avg_per_frame": self.static_entities_drawn as f64 / n as f64,
|
||||
"dynamic_entities_drawn_avg_per_frame": self.dynamic_entities_drawn as f64 / n as f64,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ pub enum DebugAction {
|
||||
NetworkCharts,
|
||||
LightmapTexture,
|
||||
ImprovedTransparency,
|
||||
/// F3+R: start a repro recording, or stop it and write its bundle.
|
||||
RecordSession,
|
||||
/// F3+E: write the bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
|
||||
/// The mappings `handleDebugKeys` tests, in its order.
|
||||
@@ -69,6 +73,14 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// Horizon's own debug keys: fixed, so they never enter the options table that
|
||||
/// vanilla parity compares (`MAPPINGS`). Off until the AI Agent page enables
|
||||
/// them; `Game` decides that in `debug_action`.
|
||||
const HORIZON_ACTIONS: [(&str, DebugAction); 2] = [
|
||||
("key.keyboard.r", DebugAction::RecordSession),
|
||||
("key.keyboard.e", DebugAction::ExportRepro),
|
||||
];
|
||||
|
||||
/// GLFW's key actions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeyAction {
|
||||
@@ -215,6 +227,11 @@ impl DebugKeys {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
for (name, action) in HORIZON_ACTIONS {
|
||||
if key == name {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
did
|
||||
}
|
||||
/// `KeyboardHandler.tick`.
|
||||
|
||||
@@ -5,9 +5,33 @@
|
||||
use crate::{
|
||||
dialog::{Body, Control, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
text::{Style, Text},
|
||||
};
|
||||
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
|
||||
/// message glyph, using the same visual order and advances as rendering.
|
||||
pub fn line_style_at(
|
||||
font: &Font,
|
||||
text: &Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cursor: (f32, f32),
|
||||
) -> Option<Style> {
|
||||
let mut left = cursor.0 - x;
|
||||
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
|
||||
return None;
|
||||
}
|
||||
let text = text.resolve_legacy();
|
||||
let visual = crate::bidi::visual(&text);
|
||||
for (c, style) in visual.chars() {
|
||||
left -= font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `Checkbox.getBoxSize`.
|
||||
@@ -433,6 +457,52 @@ pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_hit_testing_respects_sprite_advances_and_glyph_bounds() {
|
||||
use crate::{component, hud::Rect, text::ClickEvent};
|
||||
let font = Font::load(
|
||||
std::path::Path::new("/nonexistent-horizon-dialog-test-resources"),
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let text = component::to_text(
|
||||
&serde_json::json!([
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion"},
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion", "click_event": {
|
||||
"action": "custom", "id": "test:shop"
|
||||
}},
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion"},
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
|
||||
assert!(style(9.9, 20.0).is_none());
|
||||
assert_eq!(style(17.9, 20.0).unwrap().click, None);
|
||||
for x in [18.0, 21.0, 25.9] {
|
||||
assert_eq!(
|
||||
style(x, 24.0).unwrap().click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
assert_eq!(style(26.0, 20.0).unwrap().click, None);
|
||||
assert!(style(34.0, 20.0).is_none());
|
||||
assert!(style(21.0, 19.9).is_none());
|
||||
assert!(style(21.0, 29.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
|
||||
//! `BubbleColumnBlock.entityInside` (lift / drag, including the surface jump).
|
||||
use crate::entity_rules::EntityRules;
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -21,18 +22,44 @@ use horizon_world::{
|
||||
|
||||
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
|
||||
const ITEM_GRAVITY: f64 = 0.04;
|
||||
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
|
||||
/// Non-living slime `bounceRestitution` factor (`0.8f`) from
|
||||
/// `Entity.restituteMovementAfterCollisions`.
|
||||
const ITEM_SLIME_BOUNCE: f64 = 0.8;
|
||||
const ITEM_SLIME_BOUNCE: f32 = 0.8;
|
||||
|
||||
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
|
||||
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
|
||||
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
|
||||
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
|
||||
-wanted_y * ITEM_SLIME_BOUNCE
|
||||
} else {
|
||||
0.0
|
||||
/// `Entity.restituteMovementAfterCollisions` for the vertical axis of a
|
||||
/// non-living entity: `current_y` is its velocity before the move, `moved_y`
|
||||
/// how far the move got. Landing on slime (`below`) bounces by the block's
|
||||
/// restitution `1.0f * 0.8f`, with the gravity and air drag the part of the tick
|
||||
/// after the hit would have added back; any other collision zeroes Y.
|
||||
pub fn vertical_collision_velocity(
|
||||
current_y: f64,
|
||||
moved_y: f64,
|
||||
gravity: f64,
|
||||
below: bool,
|
||||
slime: bool,
|
||||
) -> f64 {
|
||||
if !(below && slime && -current_y > gravity) {
|
||||
return 0.0;
|
||||
}
|
||||
let restitution = f64::from(1.0_f32 * ITEM_SLIME_BOUNCE);
|
||||
let portion = moved_y / current_y;
|
||||
let drag = 1.0 + portion * (f64::from(0.98_f32) - 1.0);
|
||||
(portion * gravity - current_y) * drag * restitution
|
||||
}
|
||||
|
||||
/// `Entity.move`: the position only changes when the collided step is longer
|
||||
/// than `sqrt(1e-7)` or nothing was cut off; a tiny step against a wall or floor
|
||||
/// is dropped (its collision effects still run).
|
||||
pub fn move_applies(wanted: Vec3, moved: Vec3) -> bool {
|
||||
let moved_sq = moved.length_squared();
|
||||
moved_sq > 1.0e-7 || wanted.length_squared() - moved_sq < 1.0e-7
|
||||
}
|
||||
|
||||
/// `SlimeBlock.stepOn`: a slow walk or slide on slime drags the horizontal
|
||||
/// speed (`0.4 + |dy| * 0.2`); a fast vertical speed leaves it alone.
|
||||
pub fn slime_step_on_scale(velocity_y: f64) -> Option<f64> {
|
||||
let abs_delta_y = velocity_y.abs();
|
||||
(abs_delta_y < 0.1).then_some(0.4 + abs_delta_y * 0.2)
|
||||
}
|
||||
|
||||
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
|
||||
@@ -91,6 +118,12 @@ pub struct ItemEntity {
|
||||
pub stuck: Vec3,
|
||||
/// `Entity.noGravity` (`NoGravity` in its save): `applyGravity` does nothing.
|
||||
pub no_gravity: bool,
|
||||
/// `Entity.verticalCollision`: the last move was stopped vertically.
|
||||
/// `ServerEntity.isFullPrecisionEncodingRequired` reads it for items, whose
|
||||
/// resting position is exact while a 1/4096 delta is not.
|
||||
pub vertical_collision: bool,
|
||||
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
|
||||
pub horizontal_collision: bool,
|
||||
}
|
||||
/// Environment results needed by the entity owner, including lethal lava damage.
|
||||
#[derive(Debug)]
|
||||
@@ -103,6 +136,26 @@ impl ItemEnvironmentTick {
|
||||
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
|
||||
}
|
||||
}
|
||||
impl crate::entity_rules::EntityRules for ItemEntity {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `ItemEntity.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
impl crate::entity_rules::EntityRules for ExperienceOrb {
|
||||
fn is_no_gravity(&self) -> bool {
|
||||
self.no_gravity
|
||||
}
|
||||
/// `ExperienceOrb.getDefaultGravity`.
|
||||
fn get_default_gravity(&self) -> f64 {
|
||||
0.03
|
||||
}
|
||||
}
|
||||
/// An armor stand keeps no `NoGravity` flag yet, so it takes the defaults.
|
||||
impl crate::entity_rules::EntityRules for ArmorStand {}
|
||||
impl ItemEntity {
|
||||
pub const WIDTH: f64 = 0.25;
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -231,9 +284,7 @@ impl ItemEntity {
|
||||
self.velocity.z * 0.95,
|
||||
);
|
||||
} else {
|
||||
if !self.no_gravity {
|
||||
self.velocity.y -= 0.04;
|
||||
}
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
|
||||
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
|
||||
@@ -272,14 +323,23 @@ impl ItemEntity {
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
if move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
// Entity.move: what the move was stopped on. They outlive the
|
||||
// tick, as vanilla's fields do, for the tracker that sends the
|
||||
// position at the type's update interval.
|
||||
self.vertical_collision = moved.y != wanted.y;
|
||||
// `Mth.equal` on x and z (1e-5 as a float), exact on y.
|
||||
let differs = |a: f64, b: f64| (b - a).abs() >= 9.999_999_747_378_752e-6;
|
||||
self.horizontal_collision = differs(moved.x, wanted.x) || differs(moved.z, wanted.z);
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
@@ -291,7 +351,13 @@ impl ItemEntity {
|
||||
// (or a moving piston carrying one) bounces a dropped item;
|
||||
// MovingPistonBlock itself has no bounceRestitution.
|
||||
let slime = item_support_is_slime(world, facts, self.position);
|
||||
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
|
||||
self.velocity.y = vertical_collision_velocity(
|
||||
wanted.y,
|
||||
moved.y,
|
||||
ITEM_GRAVITY,
|
||||
wanted.y < 0.0,
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
@@ -305,6 +371,11 @@ impl ItemEntity {
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
|
||||
// vertical speed the bounce just left (before the air drag).
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -324,6 +395,9 @@ impl ItemEntity {
|
||||
}
|
||||
} else {
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
@@ -398,6 +472,26 @@ impl ItemEntity {
|
||||
}
|
||||
stuck
|
||||
}
|
||||
/// `SlimeBlock.stepOn` for an item on the ground: slow vertical speed
|
||||
/// drags the horizontal speed, on idle ticks too.
|
||||
fn slime_step_on(&mut self, world: &World, facts: &BlockFacts) {
|
||||
if !self.on_ground {
|
||||
return;
|
||||
}
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
if &*facts.get(world.block(p.x, p.y, p.z)).name != "minecraft:slime_block" {
|
||||
return;
|
||||
}
|
||||
if let Some(scale) = slime_step_on_scale(self.velocity.y) {
|
||||
self.velocity.x *= scale;
|
||||
self.velocity.z *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
|
||||
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
|
||||
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
|
||||
@@ -781,8 +875,8 @@ impl ExperienceOrb {
|
||||
(self.velocity.y + 5.0e-4).min(0.06),
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if !colliding && !self.no_gravity {
|
||||
self.velocity.y -= 0.03;
|
||||
} else if !colliding {
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
// ExperienceOrb.tick: lava throws the orb back out.
|
||||
@@ -1160,6 +1254,10 @@ pub struct ArmorStand {
|
||||
pub no_base_plate: bool,
|
||||
pub invisible: bool,
|
||||
pub marker: bool,
|
||||
/// `Entity.isNoGravity` (`NoGravity` in the save). `ArmorStand.hasPhysics`
|
||||
/// is false with it, as with `Marker`: `travel` does nothing, so the stand
|
||||
/// never moves, and the blocks it stands in do not touch it.
|
||||
pub no_gravity: bool,
|
||||
/// `CustomName`: the text of a hologram.
|
||||
pub custom_name: Option<crate::text::Text>,
|
||||
/// `CustomNameVisible`.
|
||||
@@ -1182,6 +1280,8 @@ pub struct ArmorStand {
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
/// `Entity.mainSupportingBlockPos` after the last move.
|
||||
pub support: horizon_world::ground_support::GroundState,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1247,6 +1347,7 @@ impl ArmorStand {
|
||||
no_base_plate: false,
|
||||
invisible: false,
|
||||
marker: false,
|
||||
no_gravity: false,
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
@@ -1256,6 +1357,22 @@ impl ArmorStand {
|
||||
enchantment_block: None,
|
||||
enchantment_fall_distance: 0.0,
|
||||
enchantment_equipment: Default::default(),
|
||||
support: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
|
||||
/// column 0.2 below the feet, the supporting block itself when `tall`
|
||||
/// says it is a fence, wall or fence gate, else the cell 0.2 below.
|
||||
pub fn on_pos_legacy(&self, tall: impl Fn(BlockPos) -> bool) -> BlockPos {
|
||||
let y = (self.position.y - f64::from(0.2_f32)).floor() as i32;
|
||||
match self.support.supporting_block {
|
||||
Some(p) if tall(p) => p,
|
||||
Some(p) => BlockPos::new(p.x, y, p.z),
|
||||
None => BlockPos::new(
|
||||
self.position.x.floor() as i32,
|
||||
y,
|
||||
self.position.z.floor() as i32,
|
||||
),
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
@@ -1316,7 +1433,9 @@ impl ArmorStand {
|
||||
self.fire_ticks = self.fire_ticks.max(300);
|
||||
}
|
||||
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
|
||||
if fire && !self.invisible && !self.marker {
|
||||
// The fire block reaches a stand through `applyEffectsFromBlocks`,
|
||||
// which a NoGravity stand does not feel (measured beside the server).
|
||||
if fire && !self.invisible && !self.marker && !self.no_gravity {
|
||||
if self.fire_ticks > 0 {
|
||||
self.health -= 0.15;
|
||||
} else {
|
||||
@@ -1373,6 +1492,10 @@ impl ArmorStand {
|
||||
if v.z.abs() < 0.003 { 0.0 } else { v.z },
|
||||
);
|
||||
self.velocity = wanted;
|
||||
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
|
||||
if self.no_gravity {
|
||||
return;
|
||||
}
|
||||
// `travelInFluid` reads `isFalling` before the move and any push;
|
||||
// `travelInAir` reads ground friction before the move.
|
||||
let falling = wanted.y <= 0.0;
|
||||
@@ -1394,6 +1517,24 @@ impl ArmorStand {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
// Entity.setOnGroundWithMovement: the block under the feet.
|
||||
let context = horizon_world::collision_context::CollisionContext::for_entity(
|
||||
horizon_world::collision_context::CollisionEntity {
|
||||
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
|
||||
y: self.position.y,
|
||||
descending: false,
|
||||
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let position = self.position;
|
||||
let _ =
|
||||
self.support
|
||||
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
scene, context, position, area, 1_000_000,
|
||||
)
|
||||
});
|
||||
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.x = 0.0;
|
||||
}
|
||||
@@ -1504,6 +1645,8 @@ mod fire_environment_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -1651,6 +1794,8 @@ mod merge_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -1990,6 +2135,8 @@ mod honey_slide_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::new(vx, vy, vz),
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -2061,22 +2208,38 @@ mod slime_bounce_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landing_on_slime_bounces_a_dropped_item() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
|
||||
fn landing_on_slime_bounces_a_dropped_item_like_vanilla() {
|
||||
// Measured on a vanilla 26.3 server: an item falling at -0.6097 that
|
||||
// is stopped 0.4865 down leaves with 0.4950 / 0.98 after the bounce.
|
||||
let y = vertical_collision_velocity(-0.6097, -0.4865, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.4950 / 0.98).abs() < 1e-3, "{y}");
|
||||
// A tiny landing: -0.0792 stopped after 0.06 gives 0.0862 (vanilla).
|
||||
let y = vertical_collision_velocity(-0.0792, -0.06, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.0862).abs() < 1e-3, "{y}");
|
||||
assert_eq!(
|
||||
item_vertical_collision_velocity(-0.04, true),
|
||||
vertical_collision_velocity(-0.04, -0.01, ITEM_GRAVITY, true, true),
|
||||
0.0,
|
||||
"gravity alone is not a bounce"
|
||||
);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, false),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(1.0, 0.5, ITEM_GRAVITY, false, true),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slime_step_on_drags_slow_vertical_speed_only() {
|
||||
let scale = slime_step_on_scale(0.0862).unwrap();
|
||||
assert!((scale - 0.41724).abs() < 1e-5);
|
||||
assert_eq!(slime_step_on_scale(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2120,6 +2283,8 @@ mod slime_bounce_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -2147,9 +2312,8 @@ mod slime_bounce_tests {
|
||||
fn a_slime_landing_stays_upward_after_item_friction() {
|
||||
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
|
||||
// negative on the ground. A slime bounce must remain positive.
|
||||
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
|
||||
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
|
||||
assert!(bounced > 0.0);
|
||||
assert_eq!(bounced, 0.8 * 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2491,6 +2655,36 @@ mod armor_stand_motion_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_gravity_stand_stays_put() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
stand.no_gravity = true;
|
||||
// ArmorStand.travel does nothing without physics: measured on the real
|
||||
// server, Pos and Motion stay exactly as summoned for every tick.
|
||||
for _ in 0..5 {
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert_eq!(stand.velocity, Vec3::ZERO);
|
||||
}
|
||||
stand.no_gravity = false;
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert!(stand.position.y < start.y, "gravity again once cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leather_boots_walk_on_powder_snow() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -245,6 +245,8 @@ mod tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -2053,12 +2053,23 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
|
||||
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power`, 4 by default and
|
||||
/// clamped to 0..128.
|
||||
pub fn tnt_power(tag: &Tag) -> f32 {
|
||||
match tag.get("explosion_power") {
|
||||
Some(Tag::Float(bits)) => f32::from_bits(*bits).clamp(0.0, 128.0),
|
||||
_ => 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse` and, when it is not the
|
||||
/// default, its `explosion_power`.
|
||||
pub fn tnt_to_nbt(
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
on_ground: bool,
|
||||
fuse: i32,
|
||||
power: f32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
@@ -2073,6 +2084,9 @@ pub fn tnt_to_nbt(
|
||||
}
|
||||
.tag(raw);
|
||||
put(&mut tag, "fuse", Tag::Short(fuse as i16));
|
||||
if power != 4.0 {
|
||||
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
|
||||
}
|
||||
// PrimedTnt writes its block state (a summoned `block_state` is kept).
|
||||
if tag.get("block_state").is_none() {
|
||||
put(&mut tag, "block_state", string("minecraft:tnt"));
|
||||
@@ -2094,6 +2108,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
// Age -32768 marks items that never despawn; Horizon counts upwards.
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
|
||||
@@ -2384,26 +2400,37 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
const THROWABLE_ITEMS: [&str; 5] = [
|
||||
"minecraft:snowball",
|
||||
"minecraft:egg",
|
||||
"minecraft:ender_pearl",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:lingering_potion",
|
||||
];
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
|
||||
if let ProjectileKind::Thrown(t) = kind {
|
||||
return Some(t.item);
|
||||
/// The item a throwable flies as: its stack (a potion's), the mod type's
|
||||
/// item, or the item its entity type is named after.
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
|
||||
match kind {
|
||||
ProjectileKind::Thrown(t) => Some(t.item),
|
||||
ProjectileKind::Potion(stack) => Some(&stack.item),
|
||||
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
|
||||
}
|
||||
THROWABLES
|
||||
.iter()
|
||||
.find(|(k, _)| k == kind)
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
|
||||
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
|
||||
/// A potion flies as the item its entity type names, which is the item a
|
||||
/// summoned one has when it names none (`getDefaultItem`).
|
||||
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
match THROWABLES.iter().find(|(_, i)| *i == id) {
|
||||
Some((kind, _)) => Some(kind.clone()),
|
||||
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
|
||||
if !THROWABLE_ITEMS.contains(&id) {
|
||||
return crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown);
|
||||
}
|
||||
match id {
|
||||
"minecraft:snowball" => Some(ProjectileKind::Snowball),
|
||||
"minecraft:egg" => Some(ProjectileKind::Egg),
|
||||
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
|
||||
_ => Some(ProjectileKind::Potion(Box::new(
|
||||
crate::items::ItemStack::new(id, 1),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2441,13 +2468,14 @@ pub fn throwable_to_nbt(
|
||||
if p.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
// the loaded item stays (a blue egg's, a renamed one's).
|
||||
// the loaded item stays (a blue egg's, a renamed one's, a potion's
|
||||
// effects); when there is none, `getItem`'s default is written.
|
||||
if tag.get("Item").is_none() {
|
||||
put(
|
||||
&mut tag,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
|
||||
);
|
||||
let stack = match &p.kind {
|
||||
ProjectileKind::Potion(stack) => (**stack).clone(),
|
||||
_ => crate::items::ItemStack::new(item, 1),
|
||||
};
|
||||
put(&mut tag, "Item", item_to_nbt(&stack));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
@@ -2455,7 +2483,14 @@ pub fn throwable_to_nbt(
|
||||
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
|
||||
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
|
||||
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = throwable_kind(&kind(tag)?)?;
|
||||
let mut kind = throwable_kind(&kind(tag)?)?;
|
||||
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
|
||||
// and all; a tag without one keeps `getItem`'s default.
|
||||
if let ProjectileKind::Potion(stack) = &mut kind
|
||||
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
|
||||
{
|
||||
**stack = item;
|
||||
}
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
@@ -2884,6 +2919,12 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Marker".into());
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if a.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoGravity".into());
|
||||
}
|
||||
if a.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
@@ -2949,6 +2990,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.no_gravity = flag(tag, "NoGravity");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
@@ -3205,6 +3247,35 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tnt_power_tests {
|
||||
use super::*;
|
||||
|
||||
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power` defaults to 4 and
|
||||
/// is clamped to 0..128 both ways, so a summoned `explosion_power:200` is
|
||||
/// 128 and one that is absent is ordinary TNT.
|
||||
#[test]
|
||||
fn explosion_power_is_read_clamped_and_defaulted() {
|
||||
let with = |power: Option<f32>| {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
if let Some(p) = power {
|
||||
map.insert("explosion_power".into(), Tag::Float(p.to_bits()));
|
||||
}
|
||||
Tag::Compound(map)
|
||||
};
|
||||
assert_eq!(tnt_power(&with(None)), 4.0);
|
||||
assert_eq!(tnt_power(&with(Some(10.0))), 10.0);
|
||||
assert_eq!(tnt_power(&with(Some(200.0))), 128.0);
|
||||
assert_eq!(tnt_power(&with(Some(-3.0))), 0.0);
|
||||
// And it round-trips through the save, which writes it only when it is
|
||||
// not the default.
|
||||
let saved = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 10.0, [1, 2, 3, 4], None);
|
||||
assert_eq!(tnt_power(&saved), 10.0);
|
||||
let plain = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 4.0, [1, 2, 3, 4], None);
|
||||
assert!(plain.get("explosion_power").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3323,6 +3394,32 @@ mod tests {
|
||||
assert!(restored.name_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_keeps_no_gravity_through_a_save() {
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:armor_stand")),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let mut a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
assert!(a.no_gravity, "summon NBT sets the flag");
|
||||
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
|
||||
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
|
||||
assert!(armor_stand_from_nbt(&saved, 7).unwrap().no_gravity);
|
||||
// `data merge entity @s {NoGravity:0b}` clears it again.
|
||||
a.no_gravity = false;
|
||||
let cleared = armor_stand_to_nbt(&a, [0; 4], Some(&saved));
|
||||
assert!(cleared.get("NoGravity").is_none());
|
||||
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
@@ -3372,6 +3469,8 @@ mod tests {
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(0.0, -0.04, 0.0),
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 120,
|
||||
pickup_delay: 10,
|
||||
ticks: 0,
|
||||
@@ -4789,6 +4888,66 @@ mod fireball_nbt_tests {
|
||||
assert!(back.get("Item").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summoned_potion_flies_as_the_item_its_compound_names() {
|
||||
// `ThrowableItemProjectile.getDefaultItem`: the item its entity type is
|
||||
// named after, so a bare summon is a splash potion with no effects and
|
||||
// the chunk saves it (ThrowableProjectile is not transient).
|
||||
for id in ["minecraft:splash_potion", "minecraft:lingering_potion"] {
|
||||
let bare = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[24.5, 216.0, 24.5])),
|
||||
]);
|
||||
let p = throwable_from_nbt(&bare, None).unwrap();
|
||||
assert_eq!(p.kind.id(), id);
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
|
||||
);
|
||||
assert!(saved_with_chunk(&p));
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!((&*item.item, item.count), (id, 1));
|
||||
assert_eq!(item.potion(), None);
|
||||
}
|
||||
// `readAdditionalSaveData`: the `Item` it flies as, `potion_contents`
|
||||
// and all, so what it splashes is the potion's.
|
||||
let harming = Tag::compound([
|
||||
("id", Tag::String("minecraft:splash_potion".into())),
|
||||
("Pos", doubles(&[24.5, 216.0, 24.5])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(
|
||||
&crate::items::ItemStack::new("minecraft:splash_potion", 1)
|
||||
.with_potion("minecraft:harming"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&harming, None).unwrap();
|
||||
let ProjectileKind::Potion(stack) = &p.kind else {
|
||||
panic!("a potion");
|
||||
};
|
||||
assert_eq!(stack.potion(), Some("minecraft:harming"));
|
||||
// The stack stays in the tag it was loaded from.
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, Some(&harming)).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(item.potion(), Some("minecraft:harming"));
|
||||
// A lingering potion is its own entity type.
|
||||
let lingering = Tag::compound([
|
||||
("id", Tag::String("minecraft:lingering_potion".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(
|
||||
&crate::items::ItemStack::new("minecraft:lingering_potion", 1)
|
||||
.with_potion("minecraft:poison"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&lingering, None).unwrap();
|
||||
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
|
||||
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user