Merge remote-tracking branch 'origin/feat/world-probe-binary' into feat/client-probe-binary

This commit is contained in:
Ludovic Marie committed 2026-10-23 21:09:59 +02:00
commit b9501fc826
153 files changed
+2783 -2407

No files matched your search

+5 -1
View File
@@ -64,9 +64,13 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --workspace --doc
parity:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
+175
View File
@@ -0,0 +1,175 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
## Vanilla reference
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
([docs/decompilation-status.md](docs/decompilation-status.md)).
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| 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`, `window.rs`, `spans.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` |
| 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` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- 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
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
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `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. Not at parity yet:
open `[server-thread]` issues list the gaps.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
## Docs worth knowing
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1 -7
View File
@@ -2,7 +2,7 @@
//! `banner_patterns` layers carried by banner items, shields and placed
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
})
.collect()
}
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
let data: &mut item_data::StackData = stack.data_mut();
data.banner_patterns = layers;
stack.tidy();
}
#[cfg(test)]
mod tests {
@@ -144,9 +144,6 @@ impl BlockEntities {
pub fn set_states(&mut self, states: Vec<Ticking>) {
self.states = states;
}
pub fn take_states(&mut self) -> Vec<Ticking> {
std::mem::take(&mut self.states)
}
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
@@ -382,10 +382,6 @@ impl Shulker {
}
out
}
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
pub fn get_progress(&self, a: f32) -> f32 {
self.progress_old + a * (self.progress - self.progress_old)
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
@@ -668,14 +664,6 @@ impl Skull {
self.animating = false;
}
}
/// `getAnimation(a)`.
pub fn get_animation(&self, a: f32) -> f32 {
if self.animating {
self.tick_count as f32 + a
} else {
self.tick_count as f32
}
}
}
/// `DecoratedPotBlockEntity.WobbleStyle`.
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
visibility: None,
}
}
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
Some((
self.models.get("standing_banner")?,
self.models.get("standing_banner_flag")?,
))
}
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
+1 -1
View File
@@ -46,7 +46,7 @@ impl BossBar {
style: "progress".into(),
value: 0,
max: 100,
visible: false,
visible: true,
player: false,
}
}
-4
View File
@@ -169,10 +169,6 @@ impl TickRateManager {
self.frozen_ticks_to_run -= 1;
}
}
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
!self.runs_normally() && !is_player_or_carries_player
}
}
/// What `/tick sprint` reports when it ends.
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
}
s.parse().ok()
}
/// Long.parseLong.
pub fn java_parse_long(s: &str) -> Option<i64> {
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Double.parseDouble over the characters StringReader reads as a number.
pub fn java_parse_double(s: &str) -> Option<f64> {
let t = s.trim_matches(|c: char| c <= ' ');
@@ -1,7 +1,7 @@
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
//! SNBT text components, with the data-fixer-upper error messages
//! (ComponentArgument, StyleArgument and component-valued item data).
use super::codec::{get, no_key, not_list, not_map, snbt};
use super::codec::{get, no_key, not_list, not_map};
use horizon_world::nbt::{Compound, Tag};
/// A DataResult error: its message and whether it carries a partial result.
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
_ => Err(not_map(t)),
}
}
#[allow(unused)]
fn _keep(t: &Tag) -> String {
snbt(t)
}
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
}
#[allow(unused)]
fn _unused(t: &Tag) -> String {
snbt(t)
}
#[cfg(test)]
mod tests {
-30
View File
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
pub fn read_int(&mut self) -> CmdResult<i32> {
self.number("int")
}
pub fn read_long(&mut self) -> CmdResult<i64> {
self.number("long")
}
pub fn read_double(&mut self) -> CmdResult<f64> {
self.number("double")
}
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
}
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
}
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
if !self.can_read() {
return Ok(String::new());
}
let next = self.peek();
if next != '"' && next != '\'' {
return Err(CommandError::tr_at(
"parsing.quote.expected.start",
&[],
self,
));
}
self.skip();
self.read_string_until(next)
}
pub fn read_string(&mut self) -> CmdResult<String> {
if !self.can_read() {
return Ok(String::new());
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
}
}
}
/// The same with a tooltip per candidate.
pub fn suggest_matching_tooltip<'b>(
&mut self,
candidates: impl IntoIterator<Item = (&'b str, String)>,
) {
let lower = self.remaining_lower();
for (c, tip) in candidates {
if args::matches_sub_str(&lower, &c.to_lowercase()) {
self.suggest_tooltip(c, tip);
}
}
}
}
/// String.compareToIgnoreCase.
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
+1 -5
View File
@@ -1,8 +1,6 @@
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
//! error messages and completion suggestions, producing NBT tags.
use super::packrat::{
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
};
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
use horizon_world::nbt::{Compound, JavaString, Tag};
use std::rc::Rc;
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
}
}
}
#[allow(unused)]
fn _rule_kind(_: Rule<SV>) {}
+1 -7
View File
@@ -3,7 +3,7 @@
//! mode keywords are read back from the parsed literals.
use super::{
Dispatcher,
args::{ArgType, GameMode, Resource, integer},
args::{ArgType, Resource, integer},
};
use crate::gamerules::{RULES, Value};
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
d.executes(l, "clone");
}
}
/// Game mode names for selector `gamemode=` and the argument.
pub fn game_modes() -> [&'static str; 4] {
GameMode::ALL.map(GameMode::name)
}
/// The shared vanilla tree.
/// Reorder node children as vanilla registers them (Brigadier keeps
/// children in registration order; smart usage lists them that way).
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
TREE.get_or_init(|| build_with(false))
}
#[allow(unused)]
const _: (u8, u8) = (ALL, GAMEMASTERS);
-18
View File
@@ -252,24 +252,6 @@ impl BlockEntity {
};
Some(Self::Chest(vec![None; size]))
}
/// The vanilla block entity type id of a container block.
pub fn id_for_block(name: &str) -> Option<&'static str> {
Some(match name {
"minecraft:chest" => "minecraft:chest",
n if n.ends_with("copper_chest") => "minecraft:chest",
"minecraft:trapped_chest" => "minecraft:trapped_chest",
"minecraft:barrel" => "minecraft:barrel",
"minecraft:hopper" => "minecraft:hopper",
"minecraft:dispenser" => "minecraft:dispenser",
"minecraft:dropper" => "minecraft:dropper",
"minecraft:furnace" => "minecraft:furnace",
"minecraft:blast_furnace" => "minecraft:blast_furnace",
"minecraft:smoker" => "minecraft:smoker",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:crafter" => "minecraft:crafter",
_ => return None,
})
}
pub fn id(&self) -> &'static str {
match self {
Self::Chest(items) => match items.len() {
-6
View File
@@ -17,8 +17,6 @@ use std::{
pub const CAPACITY: usize = 240;
/// `AbstractDebugChart.CHART_HEIGHT`.
pub const CHART_HEIGHT: i32 = 60;
/// `TpsDebugDimensions`: a tick sample's values.
pub const FULL_TICK: usize = 0;
pub const TICK_SERVER_METHOD: usize = 1;
pub const SCHEDULED_TASKS: usize = 2;
pub const IDLE: usize = 3;
@@ -252,10 +250,6 @@ impl Chart {
pub fn width(max_width: i32) -> i32 {
(CAPACITY as i32 + 2).min(max_width)
}
/// `getFullHeight`: the box and the min/avg/max line above it.
pub fn full_height() -> i32 {
CHART_HEIGHT + 9
}
/// `AbstractDebugChart.extractRenderState`.
pub fn draw(
self,
-17
View File
@@ -359,10 +359,6 @@ impl Enchantments {
}
out
}
/// Whether a stack draws the enchantment glint.
pub fn has_glint(&self, stack: &ItemStack) -> bool {
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
}
// ---- Enchanting table (EnchantmentHelper) ----
@@ -514,19 +510,6 @@ impl Enchantments {
.is_some_and(|e| e.effects.get(component).is_some())
})
}
/// The highest level among enchantments on `stack` that have `component`.
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
stack
.enchantments()
.iter()
.filter(|(id, _)| {
self.get(id)
.is_some_and(|e| e.effects.get(component).is_some())
})
.map(|(_, l)| *l)
.max()
.unwrap_or(0)
}
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
let mut out = Vec::new();
+48 -1
View File
@@ -89,6 +89,47 @@ impl Base<'_> {
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
fn is_animal(id: &str) -> bool {
matches!(
id.strip_prefix("minecraft:").unwrap_or(id),
"pig"
| "cow"
| "mooshroom"
| "sheep"
| "chicken"
| "rabbit"
| "horse"
| "donkey"
| "mule"
| "zombie_horse"
| "skeleton_horse"
| "llama"
| "trader_llama"
| "goat"
| "panda"
| "polar_bear"
| "fox"
| "wolf"
| "cat"
| "ocelot"
| "parrot"
| "bee"
| "turtle"
| "armadillo"
| "frog"
| "axolotl"
| "hoglin"
| "strider"
| "camel"
| "camel_husk"
| "sniffer"
| "happy_ghast"
| "nautilus"
| "zombie_nautilus"
)
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -175,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("InLove", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
match id {
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
@@ -621,6 +665,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
m.no_gravity = flag(tag, "NoGravity");
@@ -289,15 +289,6 @@ impl Default for HandHeights {
}
}
}
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandChange {
/// The visible item is not the hand's item after the instant replacement
/// (`mainHandItem != nextMainHand`): the hand lowers.
pub changed: bool,
/// The visible item becomes the hand's item this tick.
pub replace: bool,
}
impl HandHeights {
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
/// whether the visible item differs from the held one once
+4 -1
View File
@@ -271,7 +271,10 @@ fn load_bitmap(
.flatten();
// The sheet: its size, and its pixels when they are not in the atlas.
let (sw, sh, sheet) = match in_atlas {
Some(rect) => (rect.w, rect.h, None),
Some(rect) => {
let (w, h) = rect.texels(atlas);
(w, h, None)
}
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
+79 -42
View File
@@ -1021,9 +1021,11 @@ pub struct UiResources {
pub panorama: Arc<horizon_client::gfx::panorama::Faces>,
}
impl UiResources {
pub fn load(root: &std::path::Path) -> Result<Self> {
/// From a resource root layering packs over `defaults`, the game's own
/// resources (`root` itself when no pack is on).
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
Ok(Self {
atlas: HudAtlas::build(root)?,
atlas: HudAtlas::build_over(root, defaults)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
})
}
@@ -2575,6 +2577,17 @@ impl Game<'_> {
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
self.spawner_burst([pos.x, pos.y, pos.z])
}
// LevelEventHandler 1505: the growth particles, then the sound.
LevelEvent::LevelEvent {
id: 1505,
pos,
data,
} => {
self.growth_particles(pos, data);
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
self.audio
.play("minecraft:item.bone_meal.use", Some(at), 1.0, 1.0);
}
LevelEvent::LevelEvent {
id: 2015,
pos,
@@ -2618,7 +2631,6 @@ impl Game<'_> {
1030 => Some(("minecraft:block.anvil.use", 1.0)),
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
1031 => Some(("minecraft:block.anvil.land", 0.3)),
1505 => Some(("minecraft:item.bone_meal.use", 1.0)),
1042 => Some(("minecraft:block.grindstone.use", 1.0)),
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
@@ -8100,13 +8112,15 @@ impl Game<'_> {
}
Ok(true)
}
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
fn use_block_transformer(
&mut self,
/// What `BlockTransformer.transformBlock` would do with a held axe, hoe
/// or shovel on the block at `pos`, without doing it: the new state, its
/// sound, loot table, the durability it costs and its particle.
pub(super) fn block_transform(
&self,
stack: &ItemStack,
pos: [i32; 3],
face: [i32; 3],
) -> Result<bool> {
) -> Option<(BlockStateId, String, Option<String>, u32, String)> {
use horizon_client::block_transform::Around;
struct Column<'w> {
world: &'w World,
@@ -8126,14 +8140,11 @@ impl Game<'_> {
})
}
}
let Some(id) = self
let id = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.block_transformer.clone())
else {
return Ok(false);
};
.and_then(|i| i.block_transformer.clone())?;
// playerHasBlockingItemUseIntent: a shield in the off hand is raised
// instead, unless sneaking.
let shield = self
@@ -8142,7 +8153,7 @@ impl Game<'_> {
.and_then(|s| self.res.items().get(&s.item))
.is_some_and(|i| i.blocks_attacks.is_some());
if shield && !self.keys.contains(&KeyCode::ShiftLeft) {
return Ok(false);
return None;
}
let face_name = match face {
[0, -1, 0] => "down",
@@ -8153,38 +8164,47 @@ impl Game<'_> {
_ => "east",
};
let registry = self.pack.assets.registry;
let (state, sound, loot, damage, particle) = {
let around = Column {
world: &self.world,
registry,
pos,
};
let Some((_, t, state)) =
self.res
.transformers()
.apply(self.res.items(), registry, &id, face_name, &around)
else {
return Ok(false);
};
(
state,
t.sound.clone(),
t.loot.clone(),
t.damage,
t.particle.clone(),
)
let around = Column {
world: &self.world,
registry,
pos,
};
let Ok(new) = registry.resolve(&state) else {
return Ok(false);
};
self.level_set(pos, new)?;
// BlockTransformer.TransformParticle.send.
match particle.as_str() {
let (_, t, state) =
self.res
.transformers()
.apply(self.res.items(), registry, &id, face_name, &around)?;
let new = registry.resolve(&state).ok()?;
Some((
new,
t.sound.clone(),
t.loot.clone(),
t.damage,
t.particle.clone(),
))
}
/// `BlockTransformer.TransformParticle.send`: its level event.
pub(super) fn transform_particle(&mut self, particle: &str, pos: [i32; 3]) {
match particle {
"scrape" => self.level_event_at(3005, pos, 0),
"wax_on" => self.level_event_at(3003, pos, 0),
"wax_off" => self.level_event_at(3004, pos, 0),
_ => {}
}
}
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
fn use_block_transformer(
&mut self,
stack: &ItemStack,
pos: [i32; 3],
face: [i32; 3],
) -> Result<bool> {
let Some((new, sound, loot, damage, particle)) = self.block_transform(stack, pos, face)
else {
return Ok(false);
};
self.level_set(pos, new)?;
// BlockTransformer.TransformParticle.send.
self.transform_particle(&particle, pos);
if loot.as_deref() == Some("minecraft:till/rooted_dirt") {
self.pop_resource(
[pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]],
@@ -9570,6 +9590,7 @@ impl Game<'_> {
);
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
}
level.fall_distance = p.fall_distance;
level.food = p.health.food.food;
level.saturation = p.health.food.saturation;
@@ -12108,7 +12129,20 @@ fn load_resources(
// loads; the synchronous launch instead carries it in `Loaded`.
let mut loaded_gui = None;
let mut ui_ready = false;
if let Ok(ui) = UiResources::load(&root) {
let mut pack_error = None;
let ui = match UiResources::load(&root, vanilla_root) {
Ok(ui) => Some(ui),
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
// with the game's own resources.
Err(e) if root != vanilla_root => {
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
pack_error = Some(e.to_string());
root = vanilla_root.to_path_buf();
None
}
Err(_) => None,
};
if let Some(ui) = ui {
if menu {
horizon_client::telemetry::mark_ui_ready();
log::info!(
@@ -12122,7 +12156,6 @@ fn load_resources(
ui_ready = true;
}
progress.set(0);
let mut pack_error = None;
let (assets, render) = match Assets::load(&root).and_then(|assets| {
let render = assets.reload(&root)?;
Ok((assets, render))
@@ -12149,7 +12182,7 @@ fn load_resources(
let render = Arc::new(render);
// The menu bundle must come from the same root as the world bundle.
if !ui_ready || root != requested_root {
let ui = UiResources::load(&root)?;
let ui = UiResources::load(&root, vanilla_root)?;
if menu {
horizon_client::telemetry::mark_ui_ready();
log::info!(
@@ -12560,6 +12593,10 @@ fn start_game<'a>(
game.rest_loader = rest_loader;
game.ui.vanilla_root = vanilla_root;
if let Some(error) = pack_error {
// Minecraft.clearResourcePacksOnError: the selection is cleared and
// saved, so the next launch starts without the packs.
game.settings.resource_packs.clear();
game.settings.save(&Game::options_path());
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
game.ui.options.resource_error = Some(error);
}
+1 -5
View File
@@ -5,7 +5,7 @@ use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
command::Registries,
gamerules::GameRules,
hud::{HudBatch, rgba},
hud::HudBatch,
screen_draw::Measure,
text::{ClickEvent, Style, Text},
};
@@ -883,10 +883,6 @@ impl Game<'_> {
.chat
.add(&self.gui.atlas, message, None, self.ticks, focused);
}
#[allow(dead_code)]
fn chat_debug_fill(&self, batch: &mut HudBatch) {
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
/// approximated as UTC).
+10
View File
@@ -834,6 +834,12 @@ impl Game<'_> {
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
"on_ground": p.ground.on_ground,
"health": p.health.health(),
// The maximum the HUD's hearts last read (its cached value).
"max_health": p
.attributes
.lookup(p.attributes.key("minecraft:max_health").as_ref())
.and_then(|h| p.attributes.instance(&h).ok())
.map(|i| i.cached_value()),
"food": p.health.food.food,
})
});
@@ -971,6 +977,10 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
status
}
}
+51
View File
@@ -97,8 +97,58 @@ impl EndState {
}
}
impl EndState {
/// The eyes' entity ids, for a server to number them.
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
self.eyes.iter_mut().map(|e| &mut e.id)
}
/// The numbered eyes as a server's clients track them: (id, position, motion).
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
self.eyes
.iter()
.filter(|e| e.id != 0)
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
}
/// `handleAddEntity` for an eye of ender on a server's client: it flies
/// where the server says.
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
self.eyes.retain(|e| e.id != id);
let at = glam::DVec3::from_array(at);
self.eyes.push(EyeOfEnder {
id,
position: at,
previous: at,
velocity: glam::DVec3::from_array(velocity),
target: at,
life: 0,
survive: false,
});
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
for e in &mut self.eyes {
e.previous = e.position;
e.life += 1;
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
}
}
pub(super) fn remove_remote_eye(&mut self, id: u32) {
self.eyes.retain(|e| e.id != id);
}
pub(super) fn clear_remote_eyes(&mut self) {
self.eyes.clear();
}
/// Where each eye is, for `status`.
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
}
}
/// The EyeOfEnder entity.
struct EyeOfEnder {
/// Its entity id once a server's clients track it (0 until then).
id: u32,
position: glam::DVec3,
previous: glam::DVec3,
velocity: glam::DVec3,
@@ -1023,6 +1073,7 @@ impl Game<'_> {
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.end.eyes.push(EyeOfEnder {
id: 0,
position: start,
previous: start,
velocity: glam::DVec3::ZERO,
+9 -2
View File
@@ -997,6 +997,7 @@ impl Game<'_> {
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
self.lan_sync_scoreboard(&mut host);
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
@@ -1087,8 +1088,14 @@ impl Game<'_> {
}
for e in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
// The acting guest does not predict the egg's teleport particles.
let except = if *id == 2015 { None } else { self.lan_acting };
// The acting guest does not predict the egg's teleport
// particles, nor bone meal's growth (BoneMealItem.useOn's
// levelEvent names no player).
let except = if matches!(*id, 2015 | 1505) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
}
@@ -220,6 +220,12 @@ impl Game<'_> {
host.hub.guests[i].position[2],
);
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
// The first body after joining burns, breathes and falls on as saved.
if let Some(saved) = host.hub.guests[i].loaded_body.take()
&& let Some(m) = host.hub.guests[i].motion.as_mut()
{
saved.apply(m);
}
// The saved golden hearts go on the first body of a player who
// has been here before.
let saved = host.hub.guests[i].saved_absorption.take();
@@ -277,6 +277,8 @@ impl Game<'_> {
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -359,6 +361,26 @@ impl Game<'_> {
}
out.push(e);
}
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
// (the potion's colour), which a plain bottle's type already has.
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
let mut e = state(id, kind, at(position), 0.0, false);
e.velocity = at(velocity);
if stack.extra.is_some() || stack.count != 1 {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(stack.clone()))),
));
}
out.push(e);
}
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
for (id, position, velocity) in self.end.eyes_tracked() {
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
e.velocity = velocity;
out.push(e);
}
for t in self.tridents.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
@@ -129,23 +129,6 @@ impl Game<'_> {
}
/// The screen title: the custom name, else the profession (the client
/// adds the level itself).
#[allow(dead_code)]
fn merchant_title(&self, state: &MerchantState) -> String {
if let Some(name) = &state.name {
return name.clone();
}
let e = &self.res.stations().enchantments;
let profession = state.session.profession.trim_start_matches("minecraft:");
if state.session.villager {
e.translate(&format!("entity.minecraft.villager.{profession}"))
.unwrap_or(profession)
.to_owned()
} else {
e.translate("entity.minecraft.wandering_trader")
.unwrap_or("Wandering Trader")
.to_owned()
}
}
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
fn lan_send_offers(
&mut self,
@@ -1,10 +1,10 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: dragon egg teleportation (2015), a consumable's teleport
//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and
//! scraping (3005).
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::position::BlockPos;
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
impl Game<'_> {
@@ -42,6 +42,65 @@ impl Game<'_> {
self.particles.add(&options, p, v);
}
}
/// `BoneMealItem.addGrowthParticles` (event 1505, `count` the data):
/// happy villager sparks over a spreading block's neighbourhood (grass,
/// nylium, netherrack, moss) or water, or inside a growing block.
pub(super) fn growth_particles(&mut self, pos: BlockPos, count: i32) {
let p = [pos.x, pos.y, pos.z];
let state = self.world.block(p[0], p[1], p[2]);
let facts = self.res.facts();
// ParticleUtils.spawnParticles(level, pos, count, xzSpread, ySpread,
// allowFloatingParticles, particle).
let (at, count, xz, height, floating) = match self.res.behaviours().bone_meal_spreads(state)
{
Some(spreads) => {
// BonemealableBlock.getParticlePos: below for rooted dirt
// (its hanging roots) and mangrove leaves (their propagule).
let at = match &*facts.get(state).name {
"minecraft:rooted_dirt" | "minecraft:mangrove_leaves" => [p[0], p[1] - 1, p[2]],
_ => p,
};
if spreads {
(at, count * 3, 3.0, 1.0, false)
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
let options = ParticleOptions::simple(ParticleType::HappyVillager);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
let start = 0.5 - xz;
let x = f64::from(at[0]) + start + r.next_double() * xz * 2.0;
let y = f64::from(at[1]) + r.next_double() * height;
let z = f64::from(at[2]) + start + r.next_double() * xz * 2.0;
let below = [x.floor() as i32, y.floor() as i32 - 1, z.floor() as i32];
if floating
|| !self
.res
.facts()
.is_air(self.world.block(below[0], below[1], below[2]))
{
self.particles.add(&options, [x, y, z], v);
}
}
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+1 -1
View File
@@ -133,7 +133,7 @@ impl Game<'_> {
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build(root)?;
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let gui = super::UiResources {
+70 -8
View File
@@ -18,6 +18,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
pub(super) struct ThrownPotion {
/// Its entity id once a server's clients track it (0 until then).
id: u32,
stack: ItemStack,
/// The position before the last tick, for drawing between ticks.
previous: Vec3,
@@ -47,6 +49,7 @@ impl Game<'_> {
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
self.potions.thrown.push(ThrownPotion {
id: 0,
stack,
previous: position,
position,
@@ -90,19 +93,78 @@ impl PotionState {
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
self.thrown
.iter()
.map(|t| {
let kind = match &*t.stack.item {
"minecraft:lingering_potion" => "minecraft:lingering_potion",
"minecraft:experience_bottle" => "minecraft:experience_bottle",
_ => "minecraft:splash_potion",
};
(kind, t.position, t.velocity)
})
.map(|t| (thrown_kind(&t.stack), t.position, t.velocity))
.collect()
}
pub(super) fn clear_thrown(&mut self) {
self.thrown.clear();
}
/// Their entity ids, for a server to number them.
pub(super) fn thrown_ids(&mut self) -> impl Iterator<Item = &mut u32> {
self.thrown.iter_mut().map(|t| &mut t.id)
}
/// The numbered ones as a server's clients track them: (id, entity
/// type, item, position, motion).
pub(super) fn thrown_tracked(
&self,
) -> impl Iterator<Item = (u32, &'static str, &ItemStack, Vec3, Vec3)> {
self.thrown.iter().filter(|t| t.id != 0).map(|t| {
(
t.id,
thrown_kind(&t.stack),
&t.stack,
t.position,
t.velocity,
)
})
}
/// `handleAddEntity` for a thrown potion or bottle on a server's client:
/// drawn where the server says (its item comes in its entity data).
pub(super) fn add_remote_thrown(&mut self, id: u32, kind: &str, at: Vec3, velocity: Vec3) {
self.thrown.retain(|t| t.id != id);
let item = match kind {
"minecraft:lingering_potion" | "minecraft:experience_bottle" => kind,
_ => "minecraft:splash_potion",
};
self.thrown.push(ThrownPotion {
id,
stack: ItemStack::new(item, 1),
previous: at,
position: at,
velocity,
age: 0,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
pub(super) fn remote_thrown_item(&mut self, id: u32, stack: ItemStack) {
if let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) {
t.stack = stack;
}
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
for t in &mut self.thrown {
t.previous = t.position;
t.age += 1;
t.position = step(t.id, t.position);
}
}
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
self.thrown.retain(|t| t.id != id);
}
pub(super) fn has_thrown(&self, id: u32) -> bool {
self.thrown.iter().any(|t| t.id == id)
}
}
/// The entity type of a thrown potion or bottle.
fn thrown_kind(stack: &ItemStack) -> &'static str {
match &*stack.item {
"minecraft:lingering_potion" => "minecraft:lingering_potion",
"minecraft:experience_bottle" => "minecraft:experience_bottle",
_ => "minecraft:splash_potion",
}
}
impl Game<'_> {
+51 -23
View File
@@ -164,6 +164,8 @@ pub(super) struct Remote {
level_players: HashMap<i32, u128>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
pub(super) profile_name: String,
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
@@ -411,6 +413,7 @@ impl Game<'_> {
online_mode: false,
level_players: HashMap::new(),
profile: 0,
profile_name: String::new(),
destroying: None,
destroy_delay: 0,
input: Default::default(),
@@ -795,6 +798,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.joined = true;
r.profile = uuid;
r.profile_name = name.clone();
r.status = format!("Joined as {name}; loading terrain...");
}
}
@@ -1725,29 +1729,10 @@ impl Game<'_> {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
}
}
C::LevelEvent(p) if p.kind == 2015 => {
self.dragon_egg_particles(p.pos, p.data);
}
C::LevelEvent(p) if p.kind == 2017 => {
self.consume_teleport_particles(p.pos, p.data);
}
C::LevelEvent(p) if p.kind == 2001 => {
// LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block.
let state = BlockStateId(p.data.max(0) as u32);
let s = self.res.facts().get(state).sounds.clone();
let at = [
f64::from(p.pos[0]) + 0.5,
f64::from(p.pos[1]) + 0.5,
f64::from(p.pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(p.pos, state);
}
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
// level plays them (particles and sounds: a spawner's burst, bone
// meal, waxing, a block broken by someone else).
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
C::Sound(p) => {
let name = match &p.sound.1 {
Some((location, _)) => Some(location.clone()),
@@ -2147,6 +2132,15 @@ impl Game<'_> {
m.swing();
}
}
// The player's own instances: its maximum health (the hearts the
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
}
self.apply_remote_health();
}
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -3821,6 +3815,8 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
} else if let Some(stack) = held.as_ref() {
self.predict_item_on_block(stack, &hit);
}
if held_block {
if reacts
@@ -3839,6 +3835,38 @@ impl Game<'_> {
false
}
/// `Item.useOn` on this client's level for the items whose effects are
/// the clicker's own (`Level.levelEvent(player, ...)` and
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
f64::from(hit.pos[2]) + 0.5,
];
if &*stack.item == "minecraft:honeycomb" {
// HoneycombItem.useOn.
let registry = self.pack.assets.registry;
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let waxes = registry
.state(clicked)
.and_then(|old| self.res.transformers().waxed(registry, old))
.is_some();
if waxes {
self.level_event_at(3003, hit.pos, 0);
self.audio
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
}
} else if let Some((_, sound, _, _, particle)) =
self.block_transform(stack, hit.pos, hit.face)
{
self.transform_particle(&particle, hit.pos);
self.audio.play(&sound, Some(at), 1.0, 1.0);
}
}
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
/// use changed on this client's level at once, each kept as predicted
/// under the use's sequence until the server acknowledges it (a use the
@@ -79,6 +79,15 @@ impl Game<'_> {
self.rockets
.push(super::fireworks::Rocket::remote(id, at, velocity));
}
"minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:experience_bottle" => {
self.potions.add_remote_thrown(id, kind, at, velocity);
}
"minecraft:eye_of_ender" => {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
}
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
id,
position: at,
@@ -105,6 +114,17 @@ impl Game<'_> {
/// `handleSetEntityData` for these types: what changes how they look.
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
if self.potions.has_thrown(id) {
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
for (index, _, value) in values {
if let (8, Value::Item(stack)) = (index, value)
&& let Some(stack) = self.to_stack(stack)
{
self.potions.remote_thrown_item(id, stack);
}
}
return;
}
for (index, _, value) in values {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// PrimedTnt.DATA_FUSE_ID.
@@ -184,6 +204,11 @@ impl Game<'_> {
c.time += 1;
c.position = step(c.id, c.position);
}
self.potions.step_remote_thrown(&mut step);
self.end.step_remote_eyes(|id, at| {
let at = step(id, Vec3::new(at[0], at[1], at[2]));
[at.x, at.y, at.z]
});
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -193,6 +218,8 @@ impl Game<'_> {
self.tridents.retain(|t| t.id != id);
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.end.remove_remote_eye(id);
}
/// Every one a server sent (a new connection, level or dimension).
@@ -203,6 +230,8 @@ impl Game<'_> {
self.tridents.clear();
self.rockets.clear();
self.crystals.clear();
self.potions.clear_thrown();
self.end.clear_remote_eyes();
}
/// Their kind and place, for `status`.
@@ -225,6 +254,12 @@ impl Game<'_> {
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
}
for (kind, p, _) in self.potions.thrown_entities() {
out.push(json!({"kind": kind, "position": at(p)}));
}
for (id, p) in self.end.eye_positions() {
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
}
out
}
}
+45 -6
View File
@@ -324,6 +324,22 @@ impl Game<'_> {
}
}
}
/// `ServerScoreboard`'s packets: each placed player's client is sent
/// what changed on the board since it was last sent it (all of it on
/// joining), lan/scoreboard.rs.
pub(super) fn lan_sync_scoreboard(&mut self, host: &mut super::lan::LanHost) {
if host.hub.guests.iter().all(|g| !g.placed) {
return;
}
self.load_scoreboard();
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
if g.scoreboard != view {
g.send_all(&g.scoreboard.changes(&view));
g.scoreboard = view.clone();
}
}
}
/// `ServerPlayer.doTick` score updates and the statistic criteria.
pub(super) fn tick_scoreboard(&mut self) {
self.load_scoreboard();
@@ -563,19 +579,42 @@ impl Game<'_> {
// ---- the sidebar -----------------------------------------------------------
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
/// The objective in the player's team's sidebar slot, else the sidebar's.
fn sidebar_objective(&self) -> Option<&sb::Objective> {
let board = &self.score.board;
if board.display.is_empty() {
return;
return None;
}
// A server's client is in its team by its profile name.
let name = self
.remote
.as_ref()
.map_or(PLAYER_NAME, |r| r.profile_name.as_str());
let team_slot = board
.players_team(PLAYER_NAME)
.players_team(name)
.and_then(|t| t.color.as_ref())
.map(|c| format!("sidebar.team.{c}"))
.and_then(|slot| board.display_objective(&slot));
let Some(objective) = team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
else {
team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
}
/// The sidebar the HUD shows: its objective and its lines, for `status`.
pub(super) fn sidebar_status(&self) -> serde_json::Value {
let board = &self.score.board;
let Some(objective) = self.sidebar_objective() else {
return serde_json::Value::Null;
};
let scores: serde_json::Map<String, serde_json::Value> = board
.scores
.iter()
.filter_map(|(holder, s)| Some((holder.clone(), s.get(&objective.name)?.value.into())))
.collect();
serde_json::json!({ "objective": objective.name, "title": objective.display_text().plain(), "scores": scores })
}
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
let board = &self.score.board;
let Some(objective) = self.sidebar_objective() else {
return;
};
let draws = hud_overlays::sidebar(
+7 -4
View File
@@ -1491,6 +1491,13 @@ impl Renderer {
mapped_at_creation: false,
});
let (atlas_pixels, atlas_width, atlas_height) = hud_atlas;
let limit = device.limits().max_texture_dimension_2d;
if atlas_width.max(atlas_height) > limit {
return Err(format!(
"the shared atlas is {atlas_width}x{atlas_height}; this GPU allows {limit}x{limit}"
)
.into());
}
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
@@ -2456,10 +2463,6 @@ impl Renderer {
view_formats: &config.view_formats,
})
}
/// Rendering to a texture instead of the window (`--offscreen`).
pub fn is_offscreen(&self) -> bool {
self.surface.is_none()
}
/// Release the window surface before another renderer uses the same window.
pub fn release_surface(&mut self) {
self.surface = None;
-4
View File
@@ -119,10 +119,6 @@ impl Tile {
}
impl Renderer {
pub fn resident_lods(&self) -> usize {
self.lods.len()
}
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
self.remove_lod(key);
if let Some(bounds) = Bounds::mesh(mesh)
-1
View File
@@ -7,7 +7,6 @@ use std::collections::BTreeMap;
/// `ReputationEventType` amounts.
pub const REPUTATION_CHANGE_PER_EVENT: i32 = 25;
pub const REPUTATION_CHANGE_PER_EVERLASTING_MEMORY: i32 = 20;
pub const REPUTATION_CHANGE_PER_TRADE: i32 = 2;
/// `GossipContainer.DISCARD_THRESHOLD`: values below this are forgotten.
const DISCARD_THRESHOLD: i32 = 2;
-8
View File
@@ -486,14 +486,6 @@ impl Item {
_ => return None,
})
}
/// Caption and value text, split from the label ("Caption: Value").
pub fn caption_value(self, s: &Settings) -> (String, String) {
let label = self.label(s).plain();
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (label, String::new()),
}
}
pub fn slider(self, s: &Settings) -> Option<f32> {
match self {
Item::Vanilla(o) => o.slider_position(s),
+193 -51
View File
@@ -9,6 +9,11 @@ use std::{
};
use wgpu::util::DeviceExt;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
/// default resources' texture of that name, whatever the resolution of the
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
/// divides by the texture size it is given, sprites by their `gui.scaling`
/// size). Textures the default resources lack keep their own size.
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub u0: f32,
@@ -18,6 +23,15 @@ pub struct Rect {
pub w: u32,
pub h: u32,
}
impl Rect {
/// The region's size in texels of an atlas this large.
pub fn texels(&self, (width, height): (u32, u32)) -> (u32, u32) {
(
((self.u1 - self.u0) * width as f32).round() as u32,
((self.v1 - self.v0) * height as f32).round() as u32,
)
}
}
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
/// nearest filtering, clamp-to-edge rather than repeat addressing).
@@ -54,6 +68,9 @@ pub struct HudAtlas {
}
/// Atlas rows reserved for glyphs rasterized at run time.
pub const GLYPH_ROWS: u32 = 512;
/// The largest atlas side: the 2D texture limit of the GPUs Horizon runs on
/// (Apple silicon, RADV and Intel allow 16384; NVIDIA 32768).
pub const MAX_ATLAS_SIZE: u32 = 16384;
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
/// framed maps, so the pool holds a wall; too few slots re-upload every map
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
@@ -89,6 +106,15 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
}
Ok((info.width, info.height, rgba))
}
/// 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 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)))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
@@ -201,8 +227,14 @@ impl HudAtlas {
(g.rect, g.advance)
}
pub fn build(root: &Path) -> Result<Self> {
Self::build_over(root, root)
}
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
Self::pack(
root,
defaults,
&[
"gui",
"item",
@@ -265,14 +297,53 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
return Err(format!(
"the textures ({} images, {texels} texels) do not fit a {max}x{max} atlas",
images.len()
)
.into());
}
width = (width * 2).min(max);
}
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
let mut files = Vec::new();
for folder in folders {
walk(&textures.join(folder), &mut files);
@@ -281,7 +352,8 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
let decode_one = |path: &PathBuf| -> Option<(PackedImage, Option<TextureFlags>)> {
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = path
.strip_prefix(&textures)
.ok()?
@@ -289,18 +361,29 @@ impl HudAtlas {
.to_string_lossy()
.replace('\\', "/");
let (w, mut h, pixels) = decode(path).ok()?;
let is_frame_strip = (name.starts_with("block/") || name.starts_with("item/")) && h > w;
let pixels = if is_frame_strip {
// Animated strips keep their first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
let pixels = if frames && h > w {
h = w;
pixels[..(w * w * 4) as usize].to_vec()
} else {
pixels
};
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
.as_ref()
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
.filter(|&size| size != (w, h));
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
.ok()
.and_then(|json| TextureFlags::from_mcmeta(&json));
(w <= 1024 && (h <= 512 || name.starts_with("font/")))
.then_some(((name, w, h, pixels), flags))
let (gw, gh) = gui_size.unwrap_or((w, h));
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
(name, w, h, pixels),
flags,
gui_size,
))
};
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
let chunk = files.len().div_ceil(threads).max(1);
@@ -317,11 +400,15 @@ impl HudAtlas {
.collect()
});
let mut flags = HashMap::new();
let mut gui_sizes = HashMap::new();
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
for (image, image_flags) in decoded {
for (image, image_flags, gui_size) in decoded {
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
if let Some(size) = gui_size {
gui_sizes.insert(image.0.clone(), size);
}
images.push(image);
}
if full {
@@ -351,21 +438,10 @@ impl HudAtlas {
}
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in &images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let height = (glyph_region + GLYPH_ROWS).next_power_of_two();
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
let mut pixels = vec![0u8; (width * height * 4) as usize];
let mut sprites = HashMap::new();
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
@@ -376,6 +452,7 @@ impl HudAtlas {
}
let b = u32::from(bordered.contains(name));
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
let (gw, gh) = gui_sizes.get(name).copied().unwrap_or((w, h));
sprites.insert(
name.clone(),
Rect {
@@ -383,8 +460,8 @@ impl HudAtlas {
v0: py as f32 / height as f32,
u1: (px + w) as f32 / width as f32,
v1: (py + h) as f32 / height as f32,
w,
h,
w: gw,
h: gh,
},
);
}
@@ -404,14 +481,15 @@ impl HudAtlas {
.find(|a| a.layer == layer)
.cloned()
{
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
let (tw, th) = rect.texels((width, height));
let image = animation.resized_pixels(animation.frame(0), tw, th);
let x = (rect.u0 * width as f32).round() as usize;
let y = (rect.v0 * height as f32).round() as usize;
for row in 0..rect.h as usize {
let row_bytes = tw as usize * 4;
for row in 0..th as usize {
let dst = ((y + row) * width as usize + x) * 4;
let src = row * rect.w as usize * 4;
pixels[dst..dst + rect.w as usize * 4]
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
let src = row * row_bytes;
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
}
fire_animations.push((rect, animation));
}
@@ -783,24 +861,6 @@ impl HudBatch {
};
self.text_styled(atlas, &t, x, y, color, shadow)
}
/// `text_styled` under a pose: GUI positions scaled by `scale`, then
/// moved to `origin` (Matrix3x2fStack translate + scale).
pub fn text_posed(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
at: [f32; 2],
color: u32,
scale: f32,
origin: [f32; 2],
) -> f32 {
let start = self.vertices.len();
let advance = self.text_styled(atlas, text, at[0], at[1], color, true);
for v in &mut self.vertices[start..] {
v.pos = [origin[0] + v.pos[0] * scale, origin[1] + v.pos[1] * scale];
}
advance
}
/// Font.drawInternal: styled glyphs, shadows first, with bold, italic,
/// underline and strikethrough. `color` is the default colour and alpha.
pub fn text_styled(
@@ -1162,7 +1222,8 @@ impl HudRenderer {
if frame == *previous {
continue;
}
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = animation.resized_pixels(frame, w, h);
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
@@ -1175,8 +1236,8 @@ impl HudRenderer {
},
aspect: wgpu::TextureAspect::All,
},
rect.w,
rect.h,
w,
h,
&pixels,
);
*previous = frame;
@@ -1340,6 +1401,87 @@ impl HudRenderer {
mod tests {
use super::*;
/// `count` images of `w x h`; layout reads only their sizes.
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
(0..count)
.map(|i| (format!("i{i}"), w, h, Vec::new()))
.collect()
}
fn assert_inside(images: &[PackedImage], width: u32, placements: &[(u32, u32)], free: u32) {
for ((_, w, h, _), (x, y)) in images.iter().zip(placements) {
assert!(
x + w <= width && y + h < free,
"{x},{y} {w}x{h} outside {width}x{free}"
);
}
}
fn write_png(root: &Path, name: &str, w: u32, h: u32) {
let path = root.join(format!("assets/minecraft/textures/{name}.png"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels = vec![255; (w * h * 4) as usize];
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
}
#[test]
fn a_pack_texture_keeps_the_default_textures_gui_size() {
let temp = std::env::temp_dir().join(format!("horizon-gui-size-{}", std::process::id()));
let (defaults, pack) = (temp.join("defaults"), temp.join("pack"));
write_png(&defaults, "gui/container/inventory", 256, 256);
write_png(&defaults, "gui/sprites/widget/button", 200, 20);
write_png(&defaults, "block/stone", 16, 16);
write_png(&defaults, "item/compass", 16, 512);
// Faithful 64x's inventory (past the 512 rows a texture may have),
// 32x's button and stone, an animated strip, and a texture of its own.
write_png(&pack, "gui/container/inventory", 1024, 1024);
write_png(&pack, "gui/sprites/widget/button", 400, 40);
write_png(&pack, "block/stone", 32, 32);
write_png(&pack, "item/compass", 32, 1024);
write_png(&pack, "gui/sprites/pack_only", 30, 10);
let atlas = HudAtlas::build_over(&pack, &defaults).unwrap();
let _ = fs::remove_dir_all(&temp);
let size = (atlas.width, atlas.height);
for (name, gui, texels) in [
("gui/container/inventory", (256, 256), (1024, 1024)),
("gui/sprites/widget/button", (200, 20), (400, 40)),
("block/stone", (16, 16), (32, 32)),
("item/compass", (16, 16), (32, 32)),
("gui/sprites/pack_only", (30, 10), (30, 10)),
] {
let r = atlas.sprites[name];
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
#[test]
fn the_default_textures_keep_a_4096_wide_atlas() {
let images = sized(400, 64, 64);
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
assert_eq!(width, 4096);
assert_inside(&images, width, &placements, free);
}
#[test]
fn a_high_resolution_pack_widens_the_atlas_to_fit_the_gpu() {
// 34 rows of three 1024x512 GUI textures: 17442 rows at 4096 wide,
// past the 16384 GPUs allow (Faithful 64x needed 32768).
let images = sized(100, 1024, 512);
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
assert_eq!(width, 8192);
assert!(free + GLYPH_ROWS <= MAX_ATLAS_SIZE);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
// One image wider than the largest atlas.
assert!(HudAtlas::layout(&sized(1, 2048, 16), 2048).is_err());
}
#[test]
fn a_wrapped_border_repeats_the_opposite_edges() {
// 2x2 texels a b / c d, one byte each in red.
-11
View File
@@ -124,17 +124,6 @@ impl ItemRenderState {
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// `pickParticleMaterial`: a random layer's particle sprite.
pub fn pick_particle(
&self,
random: &mut impl horizon_core::random::RandomSource,
) -> Option<String> {
if self.layers.is_empty() {
return None;
}
let i = random.next_int_bound(self.layers.len() as i32).unwrap_or(0) as usize;
self.layers.get(i).map(|l| l.particle.clone())
}
/// `usesBlockLight` of the first layer (GUI lighting choice).
pub fn uses_block_light(&self) -> bool {
self.layers.first().is_some_and(|l| l.uses_block_light)
+22
View File
@@ -236,6 +236,24 @@ mod tests {
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
}
/// wgpu is given the WGSL text: it must parse again.
fn reparse_error(pack: &JavaPasses) -> Option<String> {
let passes = pack
.folders
.iter()
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
passes
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
.find_map(|(p, t, ext)| {
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
Some(format!(
"{}.{ext}: {}",
p.pass.program,
e.emit_to_string(&t.wgsl)
))
})
}
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
/// game loads them, with their default options: the compatibility table
/// of docs/shader-packs.md. Skipped when the variable isn't set.
@@ -255,6 +273,10 @@ mod tests {
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
Ok(pack) if let Some(e) = reparse_error(&pack) => {
println!("{name}: {e}");
failures.push(name);
}
Ok(pack) => {
let folders: Vec<String> = pack
.folders
-9
View File
@@ -295,15 +295,6 @@ impl KeyBinds {
self.keys.insert(n, key.to_owned());
}
}
/// Load `key_<name>` from options.txt; false for unknown mappings.
pub fn set_saved(&mut self, name: &str, key: &str) -> bool {
if MAPPINGS.iter().any(|m| m.0 == name) {
self.set(name, key);
true
} else {
false
}
}
pub fn saved(&self) -> Vec<(&'static str, String)> {
MAPPINGS
.iter()
-2
View File
@@ -104,8 +104,6 @@ pub const SNEAK: usize = 0;
pub const SPRINT: usize = 1;
/// Attack (mouse).
pub const ATTACK: usize = 2;
/// Use (mouse).
pub const USE: usize = 3;
/// The `ToggleKeyMapping`s `Options` builds: sneak and sprint (keyboard,
/// restored after a screen), attack (mouse, restored) and use (mouse, not
+43 -1
View File
@@ -5,7 +5,7 @@
//! changed since (`getAttributesToSync`), to the trackers and the player itself.
use horizon_protocol::packets::play::{AttributeModifier, AttributeSnapshot};
use horizon_world::attributes::{
Instance, Operation,
Instance, Modifier, Operation,
entity::{AttributeId, EntityAttributes},
live::LiveAttributes,
};
@@ -70,6 +70,48 @@ pub fn take_dirty<G: FnMut() -> i32>(map: &mut LiveAttributes<G>) -> Vec<Attribu
.collect()
}
/// `ClientPacketListener.handleUpdateAttributes`: each instance the server
/// synced takes its base and exactly its modifiers (`removeModifiers`, then
/// `addTransientModifier` for each). An attribute the map doesn't know is
/// skipped, as vanilla warns and skips it.
pub fn apply<G: FnMut() -> i32>(map: &mut LiveAttributes<G>, values: &[AttributeSnapshot]) {
for s in values {
let Some(key) = map
.keys()
.iter()
.find(|k| k.attribute().registry_id() == s.attribute)
.cloned()
else {
continue;
};
let Ok(Some(handle)) = map.get_instance(Some(key)) else {
continue;
};
let modifiers: Vec<_> = s
.modifiers
.iter()
.filter_map(|m| Modifier::new(&m.id, m.amount, operation(m.operation)).ok())
.collect();
let _ = map.edit(&handle, |instance, observer| {
instance.set_base_value(s.base, observer)?;
instance.remove_all(observer)?;
for m in modifiers {
instance.add_transient(m, observer)?;
}
Ok(())
});
}
}
/// `AttributeModifier.Operation.BY_ID`.
fn operation(id: i32) -> Operation {
match id {
1 => Operation::AddMultipliedBase,
2 => Operation::AddMultipliedTotal,
_ => Operation::AddValue,
}
}
/// The client-syncable instances of a generic attribute map.
pub fn generic_syncable(map: &EntityAttributes) -> impl Iterator<Item = AttributeSnapshot> + '_ {
let catalog = map.catalog();
+32 -1
View File
@@ -15,9 +15,33 @@ use std::{
/// GameType ids.
pub const SURVIVAL: i32 = 0;
pub const CREATIVE: i32 = 1;
pub const ADVENTURE: i32 = 2;
pub const SPECTATOR: i32 = 3;
/// The body state a player's file keeps that lives on `PlayerMotion`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SavedBody {
pub fire_ticks: i32,
pub air: i32,
pub fall_distance: f64,
}
impl SavedBody {
/// The body state of `motion`, to save.
pub fn of(motion: &PlayerMotion) -> Self {
Self {
fire_ticks: motion.fire_ticks,
air: motion.air,
fall_distance: motion.fall_distance,
}
}
/// Put it on a new body.
pub fn apply(self, motion: &mut PlayerMotion) {
motion.fire_ticks = self.fire_ticks;
motion.air = self.air;
motion.fall_distance = self.fall_distance;
}
}
/// An effect as last sent in ClientboundUpdateMobEffectPacket.
#[derive(Clone, Debug, PartialEq)]
pub struct SentEffect {
@@ -133,6 +157,9 @@ pub struct Guest {
pub tracker: Tracker,
// Player state the gameplay code reads and writes.
pub motion: Option<Box<PlayerMotion>>,
/// What the player's file says of the body (`Entity.load`: the fire
/// and air ticks and the fall distance), for the first body it gets.
pub loaded_body: Option<SavedBody>,
/// The hurt and attack logs of the body it had before it respawned,
/// until the new body takes them over (the control socket's `hurtlog`
/// reads across a death).
@@ -215,6 +242,8 @@ pub struct Guest {
pub advancements_loaded: bool,
pub advancements_visible: Vec<bool>,
pub advancements_changed: std::collections::BTreeSet<usize>,
/// The server's scoreboard as this player's client has it (lan/scoreboard.rs).
pub scoreboard: super::scoreboard::View,
/// Recipes unlocked since the client was last told
/// (`ServerRecipeBook.addRecipes` sends them).
pub recipes_unlocked: Vec<String>,
@@ -282,6 +311,7 @@ impl Guest {
batch_quota: 0.0,
tracker: Tracker::default(),
motion: None,
loaded_body: None,
logs_kept: None,
inventory: vec![None; 36],
armor: [None, None, None, None],
@@ -326,6 +356,7 @@ impl Guest {
advancements_loaded: false,
advancements_visible: Vec::new(),
advancements_changed: Default::default(),
scoreboard: Default::default(),
recipes_unlocked: Vec::new(),
}
}
-22
View File
@@ -172,13 +172,6 @@ pub struct Hub {
fn string(s: &str) -> Tag {
Tag::String(JavaString::from(s))
}
pub fn yellow_translation(key: &str, args: &[&str]) -> Tag {
let mut t = translatable(key, args);
if let Tag::Compound(map) = &mut t {
map.insert(JavaString::from("color"), string("yellow"));
}
t
}
/// Player.getDisplayName for a player in no team: the name, suggesting
/// `/tell NAME `, showing the player on hover and inserting the name.
@@ -1360,21 +1353,6 @@ impl Hub {
(!batch.is_empty()).then_some(batch)
}
/// Resend a chunk to everyone who has it (a block entity changed, relight).
pub fn resend_chunk(&mut self, world: &World, x: i32, z: i32) {
let Some(chunk) = world.chunks.get(&(x, z)) else {
return;
};
let body = self.encoder.body_in(chunk, world);
for g in self
.guests
.iter()
.filter(|g| g.sent_chunks.contains(&(x, z)))
{
g.sender.send(body.clone());
}
}
/// `ChunkHolder.broadcastChanges`' light part: the light sections that
/// changed in a column go to the players for whom it is on the border of
/// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light
+1 -6
View File
@@ -20,6 +20,7 @@ pub mod hub;
pub mod menu;
pub mod playerdata;
mod recipes;
pub mod scoreboard;
pub mod tracking;
pub mod waypoints;
@@ -272,9 +273,6 @@ impl Shared {
pub fn max_players(&self) -> usize {
self.max_players.load(Ordering::Relaxed)
}
pub fn set_max_players(&self, n: usize) {
self.max_players.store(n, Ordering::Relaxed);
}
pub fn release(&self, uuid: u128) {
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
}
@@ -513,9 +511,6 @@ impl Lan {
pub fn reserve(&self, uuid: u128) {
self.shared.reserved.lock().unwrap().insert(uuid);
}
pub fn set_motd(&self, motd: &str) {
*self.shared.motd.lock().unwrap() = motd.to_owned();
}
pub fn stop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
self.stop_listening();
+79 -1
View File
@@ -1,6 +1,6 @@
//! Guests' saved state: `players/data/<uuid>.dat` in the vanilla player
//! compound, through the same code that saves the host.
use super::guest::Guest;
use super::guest::{Guest, SavedBody};
use crate::{
items::ItemStack,
save::{Level, read_dat, write_dat},
@@ -80,6 +80,17 @@ pub fn apply(guest: &mut Guest, level: &Level) {
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
.and_then(|t| crate::save::number(t.get(key)))
.unwrap_or(default) as i32
};
guest.loaded_body = Some(SavedBody {
fire_ticks: short("Fire", -20),
air: short("Air", 300),
fall_distance: level.fall_distance,
});
guest.recipe_book = level.recipe_book.clone();
guest.dimension = match level.dimension.as_str() {
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
@@ -151,6 +162,24 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
let body = guest
.motion
.as_deref()
.map(SavedBody::of)
.or(guest.loaded_body);
if let Some(body) = body {
level.fall_distance = body.fall_distance;
let extra = level
.player_extra
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
if let horizon_world::nbt::Tag::Compound(map) = extra {
let short = |v: i32| horizon_world::nbt::Tag::Short(v.clamp(-32768, 32767) as i16);
map.insert("Fire".into(), short(body.fire_ticks));
map.insert("Air".into(), short(body.air));
}
}
level.recipe_book = guest.recipe_book.clone();
level.ender_items = guest.ender_items.clone();
level.inventory.clear();
@@ -174,3 +203,52 @@ pub fn store(guest: &Guest, level: &mut Level) {
put(100, &guest.armor[3]);
put(150, &guest.offhand);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lan::{Joined, PlayerSender};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn guest() -> Guest {
let (tx, _rx) = mpsc::channel();
Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "Alex".into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
2,
[0.0, 64.0, 0.0],
8,
)
}
#[test]
fn a_saved_burn_and_fall_come_back() {
// A player saved burning in mid-fall (#1323, #1327): Entity.saveWithoutId's
// Fire, Air and fall_distance, read back for the next body.
let mut g = guest();
g.loaded_body = Some(SavedBody {
fire_ticks: 96,
air: 250,
fall_distance: 9.5,
});
let mut level = Level::default();
store(&g, &mut level);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back = guest();
apply(&mut back, &read);
assert_eq!(back.loaded_body, g.loaded_body);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! `ServerScoreboard`: what a player's client knows of the server's
//! scoreboard. Every team goes to every player; an objective goes once it
//! sits in a display slot (`startTrackingObjective`: its objective, its
//! slots, then its scores) and leaves when it sits in none
//! (`stopTrackingObjective`). A joining player gets it all
//! (`PlayerList.updateEntireScoreboard`).
//!
//! A server's commands change the board in place; each tick a player is
//! sent the packets that turn what it was last sent into what it should
//! have now, which are the ones `ServerScoreboard`'s callbacks send.
use crate::scoreboard::{NumberFormat, RenderType, Scoreboard, component_tag, display_slots};
use crate::text::COLORS;
use horizon_protocol::packets::play::{
self, Clientbound as C, ResetScore, SetDisplayObjective, SetObjective, SetPlayerTeam, SetScore,
TeamParameters,
};
use horizon_world::nbt::Tag;
use std::collections::{BTreeMap, BTreeSet};
type Format = Option<play::NumberFormat>;
/// The scoreboard as one player's client has it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct View {
/// The tracked objectives: display name, render type (1 hearts), format.
objectives: BTreeMap<String, (Tag, i32, Format)>,
/// `DisplaySlot` id to objective.
display: BTreeMap<i32, String>,
/// The tracked objectives' scores, by holder and objective.
scores: BTreeMap<(String, String), (i32, Option<Tag>, Format)>,
/// Every team: its parameters and players, in the team's order.
teams: BTreeMap<String, (TeamParameters, Vec<String>)>,
}
fn format(f: Option<&NumberFormat>) -> Format {
f.map(|f| match f {
NumberFormat::Blank => play::NumberFormat::Blank,
NumberFormat::Styled(s) => play::NumberFormat::Styled(crate::scoreboard::json_to_tag(s)),
NumberFormat::Fixed(c) => play::NumberFormat::Fixed(component_tag(c)),
})
}
impl View {
/// What the board's state gives a client.
pub fn of(board: &Scoreboard) -> Self {
let slots = display_slots();
let display: BTreeMap<i32, String> = board
.display
.iter()
.filter_map(|(slot, o)| Some((slots.iter().position(|s| s == slot)? as i32, o.clone())))
.collect();
let tracked: BTreeSet<&String> = display.values().collect();
let objectives = board
.objectives
.iter()
.filter(|o| tracked.contains(&o.name))
.map(|o| {
let render = i32::from(o.render_type == RenderType::Hearts);
(
o.name.clone(),
(
component_tag(&o.display_name),
render,
format(o.number_format.as_ref()),
),
)
})
.collect();
let scores = board
.scores
.iter()
.flat_map(|(holder, scores)| {
scores
.iter()
.filter(|(o, _)| tracked.contains(o))
.map(move |(o, s)| {
(
(holder.clone(), o.clone()),
(
s.value,
s.display.as_ref().map(component_tag),
format(s.number_format.as_ref()),
),
)
})
})
.collect();
let teams = board
.teams
.iter()
.map(|t| {
let params = TeamParameters {
display_name: component_tag(&t.display_name),
prefix: component_tag(&t.prefix),
suffix: component_tag(&t.suffix),
name_tag_visibility: t.nametag_visibility as i32,
collision_rule: t.collision_rule as i32,
color: t
.color
.as_deref()
.and_then(|c| COLORS.iter().position(|(_, n, _)| *n == c))
.map(|i| i as i32),
options: i8::from(t.allow_friendly_fire)
| (i8::from(t.see_friendly_invisibles) << 1),
};
(
t.name.clone(),
(params, t.players.iter().cloned().collect()),
)
})
.collect();
Self {
objectives,
display,
scores,
teams,
}
}
/// The packets that take a client from `self` to `next`.
pub fn changes(&self, next: &View) -> Vec<C> {
let mut out = Vec::new();
let team = |name: &str, method, parameters, players| {
C::SetPlayerTeam(SetPlayerTeam {
name: name.to_owned(),
method,
parameters,
players,
})
};
for name in self.teams.keys().filter(|n| !next.teams.contains_key(*n)) {
out.push(team(name, 1, None, Vec::new()));
}
for (name, (params, players)) in &next.teams {
let Some((was, had)) = self.teams.get(name) else {
out.push(team(name, 0, Some(params.clone()), players.clone()));
continue;
};
if was != params {
out.push(team(name, 2, Some(params.clone()), Vec::new()));
}
let joined: Vec<_> = players
.iter()
.filter(|p| !had.contains(p))
.cloned()
.collect();
if !joined.is_empty() {
out.push(team(name, 3, None, joined));
}
let left: Vec<_> = had
.iter()
.filter(|p| !players.contains(p))
.cloned()
.collect();
if !left.is_empty() {
out.push(team(name, 4, None, left));
}
}
for (name, display) in &next.objectives {
let method = match self.objectives.get(name) {
None => 0,
Some(was) if was != display => 2,
Some(_) => continue,
};
out.push(C::SetObjective(SetObjective {
name: name.clone(),
method,
display: Some(display.clone()),
}));
}
let slots: BTreeSet<i32> = self
.display
.keys()
.chain(next.display.keys())
.copied()
.collect();
for slot in slots {
let objective = next.display.get(&slot);
if self.display.get(&slot) != objective {
out.push(C::SetDisplayObjective(SetDisplayObjective {
slot,
objective: objective.cloned().unwrap_or_default(),
}));
}
}
for ((holder, objective), (value, display, number_format)) in &next.scores {
let key = (holder.clone(), objective.clone());
if self.scores.get(&key) != Some(&(*value, display.clone(), number_format.clone())) {
out.push(C::SetScore(SetScore {
owner: holder.clone(),
objective: objective.clone(),
score: *value,
display: display.clone(),
number_format: number_format.clone(),
}));
}
}
for (holder, objective) in self.scores.keys() {
let key = (holder.clone(), objective.clone());
if !next.scores.contains_key(&key) && next.objectives.contains_key(objective) {
out.push(C::ResetScore(ResetScore {
owner: holder.clone(),
objective: Some(objective.clone()),
}));
}
}
for name in self
.objectives
.keys()
.filter(|n| !next.objectives.contains_key(*n))
{
out.push(C::SetObjective(SetObjective {
name: name.clone(),
method: 1,
display: None,
}));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn methods(packets: &[C]) -> Vec<String> {
packets
.iter()
.map(|p| match p {
C::SetObjective(o) => format!("objective {} {}", o.name, o.method),
C::SetDisplayObjective(d) => format!("display {} {}", d.slot, d.objective),
C::SetScore(s) => format!("score {} {} {}", s.owner, s.objective, s.score),
C::ResetScore(r) => format!("reset {} {:?}", r.owner, r.objective),
C::SetPlayerTeam(t) => format!("team {} {} {:?}", t.name, t.method, t.players),
_ => "other".into(),
})
.collect()
}
#[test]
fn a_shown_objective_goes_with_its_scores_and_leaves_when_hidden() {
let mut board = Scoreboard::default();
board.add_objective(
"audit",
"dummy",
json!("Audit"),
RenderType::Integer,
false,
None,
);
board.score_mut("Alpha", "audit").value = 7;
let empty = View::default();
// Not displayed: not tracked, nothing sent.
let hidden = View::of(&board);
assert!(empty.changes(&hidden).is_empty());
board.set_display_objective("sidebar", Some("audit"));
let shown = View::of(&board);
assert_eq!(
methods(&hidden.changes(&shown)),
[
"objective audit 0",
"display 1 audit",
"score Alpha audit 7"
]
);
board.score_mut("Alpha", "audit").value = 12;
board.score_mut("Beta", "audit").value = 3;
board.reset_one("Beta", "audit");
let changed = View::of(&board);
assert_eq!(methods(&shown.changes(&changed)), ["score Alpha audit 12"]);
board.reset_one("Alpha", "audit");
let reset = View::of(&board);
assert_eq!(
methods(&changed.changes(&reset)),
["reset Alpha Some(\"audit\")"]
);
board.set_display_objective("sidebar", None);
assert_eq!(
methods(&reset.changes(&View::of(&board))),
["display 1 ", "objective audit 1"]
);
}
#[test]
fn teams_go_to_everyone_with_their_players() {
let mut board = Scoreboard::default();
board.add_team("red");
board.add_to_team("Alpha", "red");
let first = View::of(&board);
assert_eq!(
methods(&View::default().changes(&first)),
["team red 0 [\"Alpha\"]"]
);
board.add_to_team("Beta", "red");
board.remove_from_team("Alpha");
let second = View::of(&board);
assert_eq!(
methods(&first.changes(&second)),
["team red 3 [\"Beta\"]", "team red 4 [\"Alpha\"]"]
);
board.remove_team("red");
assert_eq!(
methods(&second.changes(&View::of(&board))),
["team red 1 []"]
);
}
}
-6
View File
@@ -77,12 +77,6 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
}
impl Language {
pub fn from_map(code: &str, map: HashMap<String, String>) -> Self {
Self {
code: code.to_owned(),
map,
}
}
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
/// from every namespace and every source in priority order (later sources
/// override).
+1 -1
View File
@@ -203,7 +203,7 @@ impl LightTables {
Ok(tables)
}
/// Assembled tables (tests and benchmarks): derives the packed light cache.
#[allow(dead_code)]
#[cfg(test)]
fn from_parts(cells: Vec<Cell>, occludes: Vec<bool>, faces: usize) -> Self {
let mut tables = Self {
cells,
-4
View File
@@ -239,10 +239,6 @@ pub enum ArmPose {
/// Raising a trident to throw it (or to launch with Riptide).
ThrowTrident,
}
/// HumanoidModel.setupAnim for piglins: `PiglinModel` poses the arms itself.
pub fn humanoid_item(p: &mut Poses, s: &State, _holding: bool) {
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
}
/// HumanoidModel.setupAnim with empty hands (endermen).
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
@@ -152,10 +152,6 @@ pub fn protector(heart: &mut CreakingHeart, host: &impl HeartHost) -> Option<u32
}
None
}
/// `isProtector`.
pub fn is_protector(heart: &mut CreakingHeart, host: &impl HeartHost, id: u32) -> bool {
protector(heart, host) == Some(id)
}
fn distance_to_creaking(heart: &mut CreakingHeart, pos: [i32; 3], host: &impl HeartHost) -> f64 {
protector(heart, host)
.and_then(|id| host.mob(id))
-37
View File
@@ -76,9 +76,6 @@ pub struct GoalSelector {
locked: [Option<usize>; 4],
pub disabled: u8,
}
fn flag_index(f: u8) -> usize {
f.trailing_zeros() as usize
}
impl GoalSelector {
pub fn add(&mut self, priority: i32, goal: impl Goal + 'static) {
self.goals.push(Wrapped {
@@ -174,10 +171,6 @@ impl GoalSelector {
}
self.locked = [None; 4];
}
#[allow(dead_code)]
fn locked_by(&self, flag: u8) -> Option<usize> {
self.locked[flag_index(flag)]
}
}
// --- random positions (DefaultRandomPos / LandRandomPos / RandomPos) -------
@@ -565,36 +558,6 @@ impl Mob {
m.land_up_out_of_solid(w, p)
})
}
/// LandRandomPos.getPosTowards
pub fn land_random_pos_towards(
&mut self,
w: &MobWorld,
h: i32,
v: i32,
to: [f64; 3],
) -> Option<[f64; 3]> {
let (xd, zd) = (to[0] - self.position.x, to[2] - self.position.z);
self.pick_best(w, |m| {
let d = m.random_direction_within(
0.0,
f64::from(h),
v,
0,
xd,
zd,
std::f64::consts::FRAC_PI_2,
)?;
let p = m.toward(f64::from(h), d);
if p[1] < crate::chunk::MIN_Y
|| p[1] >= 320
|| m.restricted(f64::from(h), p)
|| !m.stable_destination(w, p)
{
return None;
}
m.land_up_out_of_solid(w, p)
})
}
pub fn target_info(&self, w: &MobWorld) -> Option<TargetInfo> {
self.ai.target.and_then(|t| w.target_info(t))
}
@@ -17,7 +17,6 @@ use horizon_core::{
// Happy ghast state: `head` the harness, `scratch[0]` 1 once set up as a
// baby and 2 as an adult, `ai.home` the restriction, `counter`
// the end tick of the follow-an-adult range pick.
pub const BABY_SCALE: f64 = 0.2375;
/// `Attributes.FLYING_SPEED` and the speed `travel` uses (`* 5 / 3`).
pub const FLYING_SPEED: f64 = 0.05;
pub const AIR_SPEED: f32 = 0.05 * 5.0 / 3.0;
@@ -250,10 +250,6 @@ pub fn nearest_players(m: &Mob) -> Vec<Who> {
.and_then(|b| b.mem.nearest_players.value())
.unwrap_or_default()
}
/// The damage the brain remembers (`HURT_BY`).
pub fn hurt_by_present(m: &Mob) -> bool {
brain(m).is_some_and(|b| b.mem.hurt_by.present())
}
/// `HURT_BY`'s damage source entity, if a living one.
fn hurt_by_entity(m: &Mob, w: &MobWorld) -> Option<Who> {
let t = brain(m)?.mem.hurt_by.value()?;
@@ -76,14 +76,6 @@ pub fn finalize(m: &mut Mob, warm_ocean: bool) {
m.extra.variant = u8::from(warm_ocean);
}
}
/// `checkNautilusSpawnRules`: 5 to 25 blocks below sea level, water below
/// and above.
pub fn spawn_rules_ok(w: &MobWorld, x: i32, y: i32, z: i32) -> bool {
let sea = 63;
(sea - 25..=sea - 5).contains(&y)
&& w.fluid(x, y - 1, z) == Fluid::Water
&& w.block_name(x, y + 1, z) == "minecraft:water"
}
/// `getControllingPassenger`: a player on a saddled nautilus.
pub fn controlled(m: &Mob) -> bool {
m.rider.is_some() && m.extra.saddled
-22
View File
@@ -582,26 +582,4 @@ impl Mob {
NavKind::Amphibious => !w.facts.is_air(w.world.block(p[0], p[1] - 1, p[2])),
}
}
pub fn should_recompute_path(&self, p: [i32; 3]) -> bool {
if self.ai.nav.delayed_recompute {
return false;
}
let Some(path) = &self.ai.nav.path else {
return false;
};
if path.is_done() || path.nodes.is_empty() {
return false;
}
let Some(end) = path.end() else { return false };
let mid = [
(f64::from(end.x) + self.position.x) / 2.0,
(f64::from(end.y) + self.position.y) / 2.0,
(f64::from(end.z) + self.position.z) / 2.0,
];
let d = (f64::from(p[0]) + 0.5 - mid[0]).powi(2)
+ (f64::from(p[1]) + 0.5 - mid[1]).powi(2)
+ (f64::from(p[2]) + 0.5 - mid[2]).powi(2);
let r = (path.nodes.len() - path.next) as f64;
d < r * r
}
}
+12
View File
@@ -310,6 +310,18 @@ mod tests {
}
}
#[test]
fn love_ticks_survive_a_save() {
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
let mut m = rabbit();
m.extra.in_love = 570;
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None);
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!(back.extra.in_love, 570);
}
#[test]
fn landing_waits_then_real_hop_clock_moves_the_model() {
let mut m = rabbit();
@@ -769,19 +769,6 @@ pub fn weighted_pick(list: &[SpawnEntry], random: &mut impl RandomSource) -> Opt
None
}
/// `NaturalSpawner.getRandomSpawnMobAt`.
pub fn random_spawn_mob_at(
list: &[SpawnEntry],
category: &str,
reduced_water_ambient: bool,
random: &mut impl RandomSource,
) -> Option<SpawnEntry> {
if category == "water_ambient" && reduced_water_ambient && random.next_float() < 0.98 {
return None;
}
weighted_pick(list, random)
}
/// `SpawnerData.count().sample(random)`: a constant draws nothing.
pub fn sample_count(entry: &SpawnEntry, random: &mut impl RandomSource) -> u32 {
if entry.constant {
@@ -30,25 +30,6 @@ pub struct BiomeFacts {
pub fn placement_ok(species: Species, w: &MobWorld, x: i32, y: i32, z: i32) -> bool {
super::spawn_rules::is_spawn_position_ok(species, w, x, y, z)
}
/// `SpawnPlacements.checkSpawnRules` for a natural spawn.
pub fn spawn_rules_ok(
species: Species,
w: &MobWorld,
random: &mut impl RandomSource,
x: i32,
y: i32,
z: i32,
) -> bool {
super::spawn_rules::check_spawn_rules(
species,
w,
super::spawn_rules::Reason::Natural,
random,
x,
y,
z,
)
}
/// Mob.finalizeSpawn for natural, chunk-generation and summoned mobs.
/// `DifficultyInstance`: the local difficulty at a position.
#[derive(Clone, Copy, Debug)]
@@ -10,7 +10,7 @@
//! other `GateBehavior`s shuffle with a random of their own, seeded with
//! `Brain::shuffle_seed` when the brain is built.
use super::{
Mob, MobEvent, MobWorld, Seen, Species, Who,
Mob, MobEvent, MobWorld, Species, Who,
path::{Path, PathNode},
};
use horizon_core::{
@@ -4156,10 +4156,6 @@ pub fn woken(m: &mut Mob, now: i64) {
pub fn potential_job_site(m: &Mob) -> Option<[i32; 3]> {
mem_ref(m).and_then(|mm| mm.potential_job_site.value())
}
/// Whether a seen mob is a villager wanting to breed (`AgeableMob::canBreed`).
pub fn seen_can_breed(s: &Seen) -> bool {
s.species == Species::Villager && s.in_love
}
/// The memories other villagers read: the interaction target, the path's
/// nodes (while it is not done), whether a golem was seen lately and the
/// last sleep.
+1 -7
View File
@@ -1,7 +1,7 @@
//! What mobs read from the level: block solidity, ray clipping for line of
//! sight (`ClipContext.Block.COLLIDER`), sky visibility, light-dependent
//! values and the positions of other entities.
use super::{MobWorld, Seen, Species, Who, data};
use super::{MobWorld, Seen, Who, data};
use crate::{
blocks::{BlockFacts, Fluid},
collisions::SceneCollisions,
@@ -269,12 +269,6 @@ impl MobWorld<'_> {
});
v.into_iter()
}
pub fn count_species(&self, species: Species) -> usize {
self.others
.iter()
.filter(|s| s.species == species && s.alive)
.count()
}
}
/// Level.clip with VISUAL shapes and no fluids: the block the segment first
+1
View File
@@ -337,6 +337,7 @@ impl PlayerMotion {
self.air = extra
.and_then(|t| crate::save::number(t.get("Air")))
.unwrap_or(300) as i32;
self.fall_distance = level.fall_distance;
self.set_health(level.health, false)?;
// LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount.
self.internal_set_absorption(level.absorption)?;
-5
View File
@@ -804,11 +804,6 @@ impl SelectionModel {
false
}
}
/// Whether activating the entry asks first (`handlePackSelection`'s
/// ConfirmScreen for an incompatible pack).
pub fn needs_confirmation(&self, selected: bool, i: usize) -> bool {
self.can_select(selected, i) && !self.unselected[i].compatible
}
/// `moveUp` / `moveDown` when allowed.
pub fn move_by(&mut self, selected: bool, i: usize, up: bool) -> bool {
let ok = if up {
+8
View File
@@ -1158,6 +1158,8 @@ pub struct ParticleEngine {
starters: Vec<Starter>,
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
/// How many of each type were made, for `horizon-ctl status`.
made: std::collections::BTreeMap<&'static str, u64>,
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
/// and cobweb models, resolved for the ground), one per model layer.
pub item_sprites: HashMap<ParticleType, Vec<String>>,
@@ -1183,6 +1185,7 @@ impl ParticleEngine {
starters: Vec::new(),
camera: [0.0; 3],
sounds: Vec::new(),
made: Default::default(),
item_sprites: HashMap::new(),
};
let dir = root.join("assets/minecraft/particles");
@@ -1449,9 +1452,14 @@ impl ParticleEngine {
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
self.pending.push(particle);
}
}
/// How many particles of each type were made so far.
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
&self.made
}
/// Parity probes: seed the provider (`ParticleEngine.random`), level and
/// `SpellParticle.RANDOM` generators.
pub fn seed_randoms(&mut self, provider: i64, level: i64, spell: i64) {
-15
View File
@@ -384,21 +384,6 @@ impl Book {
order,
}
}
/// Which book a recipe id belongs to.
pub fn book_of(&self, id: &str) -> Option<BookType> {
let e = self.entries.iter().find(|e| e.id == id)?;
Some(match e.category {
Category::CraftingBuilding
| Category::CraftingRedstone
| Category::CraftingEquipment
| Category::CraftingMisc => BookType::Crafting,
Category::FurnaceFood | Category::FurnaceBlocks | Category::FurnaceMisc => {
BookType::Furnace
}
Category::BlastBlocks | Category::BlastMisc => BookType::BlastFurnace,
Category::SmokerFood => BookType::Smoker,
})
}
/// `RecipeDisplayEntry.canCraft`: the recipe's placement ingredients
/// (`craftingRequirements`) can all be found in `stock`.
pub fn can_craft(&self, entry: &Entry, stock: &crate::menu::place::Stacked) -> bool {
-6
View File
@@ -750,12 +750,6 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
)
}))
}
/// The heightmaps a ClientboundLevelChunkPacketData carries (the types with
/// sendToClient: WORLD_SURFACE, MOTION_BLOCKING, MOTION_BLOCKING_NO_LEAVES),
/// as (network id, packed longs) in Heightmap.Types order.
pub fn client_heightmaps(chunk: &Chunk, tables: &SaveTables) -> Vec<(i32, Vec<i64>)> {
client_heightmaps_in(&chunk.sections, tables, MIN_Y, SECTIONS as i32 * 16)
}
/// The client heightmaps of a column's sections, in a level from `min_y`,
/// `height` blocks high.
pub fn client_heightmaps_in(
-5
View File
@@ -78,7 +78,6 @@ pub const RESONANCE: [&str; 15] = [
];
pub const SHRIEK: &str = "minecraft:shriek";
pub const TENDRILS_CLICKING: &str = "minecraft:sculk_sensor_tendrils_clicking";
pub const NO_VIBRATION_FREQUENCY: i32 = 0;
/// `VibrationSystem.getGameEventFrequency`.
pub fn frequency(event: &str) -> i32 {
@@ -567,10 +566,6 @@ pub struct WardenSpawnTracker {
}
impl WardenSpawnTracker {
pub const MAX_WARNING_LEVEL: i32 = 4;
/// Blocks around a shrieker within which players are warned.
pub const PLAYER_SEARCH_RADIUS: f64 = 16.0;
/// `AABB.ofSize(centre, 48, 48, 48)` for an existing warden.
pub const WARNING_CHECK_DIAMETER: f64 = 48.0;
const DECREASE_EVERY: i32 = 12000;
const INCREASE_COOLDOWN: i32 = 200;
pub fn tick(&mut self) {
-4
View File
@@ -81,10 +81,6 @@ pub fn sound_source(event: &str) -> usize {
index("master")
}
}
/// Volume multiplier of an event's source (master applied separately).
pub fn event_volume(event: &str) -> f32 {
source_volume(sound_source(event))
}
/// Volume multiplier of a sound source by index.
pub fn source_volume(i: usize) -> f32 {
if i == 0 {
-3
View File
@@ -317,9 +317,6 @@ impl TabList {
self.visible = visible;
}
}
pub fn is_visible(&self) -> bool {
self.visible
}
/// `reset`.
pub fn reset(&mut self) {
self.header = None;
-3
View File
@@ -455,9 +455,6 @@ impl Text {
}
out
}
pub fn char_count(&self) -> usize {
self.spans.iter().map(|s| s.text.chars().count()).sum()
}
}
/// The style fields of a component's JSON.
-20
View File
@@ -1630,26 +1630,6 @@ fn caret_field<'a>(
_ => None,
}
}
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
pub fn date_label(secs: u64) -> String {
date_string(secs)
}
fn date_string(secs: u64) -> String {
// Civil date from Unix time (days-from-civil inverse), UTC.
let days = (secs / 86400) as i64;
let (h, m) = ((secs % 86400) / 3600, (secs % 3600) / 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y}-{mo:02}-{d:02} {h:02}:{m:02}")
}
#[cfg(test)]
mod classic_tests {
+257 -76
View File
@@ -7,9 +7,40 @@ use crate::{
};
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
/// Nine-slice borders (left, top, right, bottom) from `.png.mcmeta` files.
static NINE_SLICE: RwLock<Option<HashMap<String, [u32; 4]>>> = RwLock::new(None);
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
/// its texture's resolution, its borders (left, top, right, bottom) in the
/// same units, and whether the edges and centre stretch rather than tile.
#[derive(Clone, Copy, Debug, PartialEq)]
struct NineSlice {
width: u32,
height: u32,
border: [u32; 4],
stretch_inner: bool,
}
/// Nine-slice scaling from `.png.mcmeta` files.
static NINE_SLICE: RwLock<Option<HashMap<String, NineSlice>>> = RwLock::new(None);
/// A `gui.scaling` section of type `nine_slice`.
fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
let s = &v["gui"]["scaling"];
if s["type"] != "nine_slice" {
return None;
}
let size = |k: &str| s[k].as_u64().map(|n| n as u32);
let border = &s["border"];
let border = if let Some(n) = border.as_u64() {
[n as u32; 4]
} else {
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
[g("left"), g("top"), g("right"), g("bottom")]
};
Some(NineSlice {
width: size("width")?,
height: size("height")?,
border,
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
})
}
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
pub fn load_metadata(root: &Path) {
let mut map = HashMap::new();
@@ -32,47 +63,48 @@ pub fn load_metadata(root: &Path) {
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let s = &v["gui"]["scaling"];
if s["type"] != "nine_slice" {
continue;
}
let border = &s["border"];
let b = if let Some(n) = border.as_u64() {
[n as u32; 4]
} else {
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
[g("left"), g("top"), g("right"), g("bottom")]
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, b);
map.insert(key, nine_slice);
}
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
}
fn border(sprite: &str) -> [u32; 4] {
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
NINE_SLICE
.read()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|m| m.get(sprite).copied())
.unwrap_or(match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
[2, 2, 2, 3]
}
_ => [1; 4],
.unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle"
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
_ => [1; 4],
},
stretch_inner: false,
})
}
fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
let (du, dv) = ((r.u1 - r.u0) / r.w as f32, (r.v1 - r.v0) / r.h as f32);
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
/// own units (`TextureAtlasSprite.getU(x / width)`).
fn sub(r: Rect, size: [f32; 2], [x, y, w, h]: [f32; 4]) -> Rect {
let (du, dv) = ((r.u1 - r.u0) / size[0], (r.v1 - r.v0) / size[1]);
Rect {
u0: r.u0 + x * du,
v0: r.v0 + y * dv,
@@ -82,8 +114,9 @@ fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
h: h as u32,
}
}
/// Tile `src` (a sub-rectangle of a sprite, in its pixels) over a target area.
fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32) {
/// `blitTiledSprite`: `src` (a region of a sprite `size` large, in its
/// units) repeated over `dst` from its top left, the last row and column cut.
fn tile(batch: &mut HudBatch, r: Rect, size: [f32; 2], src: [f32; 4], dst: [f32; 4], color: u32) {
let [sx, sy, sw, sh] = src;
let [dx, dy, dw, dh] = dst;
if sw <= 0.0 || sh <= 0.0 || dw <= 0.0 || dh <= 0.0 {
@@ -95,13 +128,16 @@ fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32)
let mut x = 0.0;
while x < dw {
let w = sw.min(dw - x);
batch.rect_uv(dx + x, dy + y, w, h, sub(r, sx, sy, w, h), color);
batch.rect_uv(dx + x, dy + y, w, h, sub(r, size, [sx, sy, w, h]), color);
x += sw;
}
y += sh;
}
}
/// GuiGraphics.blitSprite with nine-slice scaling (inner parts tiled).
/// `GuiGraphicsExtractor.blitNineSlicedSprite`: the corners at their size,
/// the edges and centre tiled (or stretched with `stretch_inner`), all in
/// the sprite's `gui.scaling` units, so a high-resolution pack's sprite draws
/// as the default one does.
pub fn nine_slice(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -112,47 +148,73 @@ pub fn nine_slice(
h: f32,
color: u32,
) {
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
if let Some(r) = atlas.sprites.get(sprite) {
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
}
}
fn draw_nine_slice(
batch: &mut HudBatch,
r: Rect,
n: NineSlice,
[x, y, w, h]: [f32; 4],
color: u32,
) {
let size = [n.width as f32, n.height as f32];
let [sw, sh] = size;
let [l, t, rr, b] = n.border.map(|v| v as f32);
let (half_w, half_h) = ((w / 2.0).floor(), (h / 2.0).floor());
let (l, rr, t, b) = (l.min(half_w), rr.min(half_w), t.min(half_h), b.min(half_h));
// blitSprite(sprite, width, height, textureX, textureY, x, y, w, h).
let part = |batch: &mut HudBatch, tx: f32, ty: f32, dst: [f32; 4]| {
if dst[2] != 0.0 && dst[3] != 0.0 {
batch.rect_uv(
dst[0],
dst[1],
dst[2],
dst[3],
sub(r, size, [tx, ty, dst[2], dst[3]]),
color,
);
}
};
let [l, t, rr, b] = border(sprite).map(|v| v as f32);
let (sw, sh) = (r.w as f32, r.h as f32);
let l = l.min(w / 2.0);
let rr = rr.min(w / 2.0);
let t = t.min(h / 2.0);
let b = b.min(h / 2.0);
let (cw, ch) = (w - l - rr, h - t - b);
let (scw, sch) = (sw - l - rr, sh - t - b);
// Corners.
batch.rect_uv(x, y, l, t, sub(r, 0.0, 0.0, l, t), color);
batch.rect_uv(x + w - rr, y, rr, t, sub(r, sw - rr, 0.0, rr, t), color);
batch.rect_uv(x, y + h - b, l, b, sub(r, 0.0, sh - b, l, b), color);
batch.rect_uv(
x + w - rr,
y + h - b,
rr,
b,
sub(r, sw - rr, sh - b, rr, b),
color,
);
// Edges and centre.
tile(batch, r, [l, 0.0, scw, t], [x + l, y, cw, t], color);
tile(
batch,
r,
[l, sh - b, scw, b],
[x + l, y + h - b, cw, b],
color,
);
tile(batch, r, [0.0, t, l, sch], [x, y + t, l, ch], color);
tile(
batch,
r,
[sw - rr, t, rr, sch],
[x + w - rr, y + t, rr, ch],
color,
);
tile(batch, r, [l, t, scw, sch], [x + l, y + t, cw, ch], color);
// blitNineSliceInnerSegment.
let inner = |batch: &mut HudBatch, dst: [f32; 4], src: [f32; 4]| {
if dst[2] <= 0.0 || dst[3] <= 0.0 {
return;
}
if n.stretch_inner {
batch.rect_uv(dst[0], dst[1], dst[2], dst[3], sub(r, size, src), color);
} else {
tile(batch, r, size, src, dst, color);
}
};
if w == sw && h == sh {
part(batch, 0.0, 0.0, [x, y, w, h]);
return;
}
if h == sh {
part(batch, 0.0, 0.0, [x, y, l, h]);
inner(batch, [x + l, y, w - rr - l, h], [l, 0.0, sw - rr - l, sh]);
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, h]);
return;
}
if w == sw {
part(batch, 0.0, 0.0, [x, y, w, t]);
inner(batch, [x, y + t, w, h - b - t], [0.0, t, sw, sh - b - t]);
part(batch, 0.0, sh - b, [x, y + h - b, w, b]);
return;
}
let (cw, ch) = (w - rr - l, h - b - t);
let (scw, sch) = (sw - rr - l, sh - b - t);
part(batch, 0.0, 0.0, [x, y, l, t]);
inner(batch, [x + l, y, cw, t], [l, 0.0, scw, t]);
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, t]);
part(batch, 0.0, sh - b, [x, y + h - b, l, b]);
inner(batch, [x + l, y + h - b, cw, b], [l, sh - b, scw, b]);
part(batch, sw - rr, sh - b, [x + w - rr, y + h - b, rr, b]);
inner(batch, [x, y + t, l, ch], [0.0, t, l, sch]);
inner(batch, [x + l, y + t, cw, ch], [l, t, scw, sch]);
inner(batch, [x + w - rr, y + t, rr, ch], [sw - rr, t, rr, sch]);
}
/// Repeat a whole texture with each copy drawn `size` GUI pixels square.
pub fn tiled(
@@ -169,7 +231,6 @@ pub fn tiled(
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
};
let scale = r.w as f32 / size;
let mut yy = 0.0;
while yy < h {
let th = size.min(h - yy);
@@ -181,7 +242,7 @@ pub fn tiled(
y + yy,
tw,
th,
sub(r, 0.0, 0.0, tw * scale, th * scale),
sub(r, [size, size], [0.0, 0.0, tw, th]),
color,
);
xx += size;
@@ -793,10 +854,6 @@ impl EditBox {
text_color: rgba(224, 224, 224, 255),
}
}
/// When the field gained focus, for the cursor's blink.
pub fn focused_since(&mut self, at: Instant) {
self.focused_at = at;
}
pub fn inner_width(&self) -> f32 {
if self.bordered {
self.width - 8.0
@@ -1128,6 +1185,130 @@ impl EditBox {
#[cfg(test)]
mod tests {
use super::*;
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
.vertices
.chunks(6)
.map(|q| {
let (a, c) = (q[0], q[2]);
(
[a.pos[0], a.pos[1], c.pos[0], c.pos[1]],
[a.uv[0], a.uv[1], c.uv[0], c.uv[1]],
)
})
.collect()
}
/// Destination and UV rectangles equal to within f32 rounding.
fn assert_quad(actual: ([f32; 4], [f32; 4]), expected: ([f32; 4], [f32; 4])) {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5);
assert!(
close(actual.0, expected.0) && close(actual.1, expected.1),
"{actual:?} != {expected:?}"
);
}
/// A sprite filling the atlas, `w x h` texels as a 32x pack draws it.
fn sprite(w: u32, h: u32) -> Rect {
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w,
h,
}
}
fn faithful(json: &str) -> NineSlice {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn a_high_resolution_button_slices_in_its_gui_units() {
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":200,"height":20,
"border":{"left":20,"top":4,"right":20,"bottom":4}}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(400, 40),
n,
[10.0, 5.0, 150.0, 20.0],
u32::MAX,
);
let q = quads(&batch);
// Full height: left border, the centre tiled, right border
// (blitNineSlicedSprite's height == nineSlice.height case).
assert_quad(q[0], ([10.0, 5.0, 30.0, 25.0], [0.0, 0.0, 0.1, 1.0]));
assert_quad(q[1], ([30.0, 5.0, 140.0, 25.0], [0.1, 0.0, 0.65, 1.0]));
assert_quad(q[2], ([140.0, 5.0, 160.0, 25.0], [0.9, 0.0, 1.0, 1.0]));
assert_eq!(q.len(), 3);
}
#[test]
fn nine_slice_corners_and_a_stretched_centre() {
// Faithful 32x's tooltip/frame: 200x200 texels, 100x100, border 12, stretched.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":12,"stretch_inner":true}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(200, 200),
n,
[0.0, 0.0, 300.0, 40.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 9);
// Top left corner: 12 GUI pixels, 12/100 of the sprite.
assert_quad(q[0], ([0.0, 0.0, 12.0, 12.0], [0.0, 0.0, 0.12, 0.12]));
// The centre is one stretched quad (blitNineSliceInnerSegment, stretch_inner).
assert_quad(q[7], ([12.0, 12.0, 288.0, 28.0], [0.12, 0.12, 0.88, 0.88]));
// Bottom right corner.
assert_quad(q[5], ([288.0, 28.0, 300.0, 40.0], [0.88, 0.88, 1.0, 1.0]));
}
#[test]
fn a_sprite_drawn_at_its_own_size_is_one_quad() {
let n = NineSlice {
width: 50,
height: 50,
border: [10; 4],
stretch_inner: false,
};
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(100, 100),
n,
[4.0, 4.0, 50.0, 50.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 1);
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn borders_shrink_to_half_the_drawn_size() {
let n = NineSlice {
width: 20,
height: 20,
border: [8; 4],
stretch_inner: true,
};
let mut batch = HudBatch::new([640, 480]);
// 9 wide: borders of floor(9 / 2) = 4, as vanilla's integer division.
draw_nine_slice(
&mut batch,
sprite(20, 20),
n,
[0.0, 0.0, 9.0, 30.0],
u32::MAX,
);
let q = quads(&batch);
assert_quad(q[0], ([0.0, 0.0, 4.0, 8.0], [0.0, 0.0, 0.2, 0.4]));
assert_quad(q[2], ([5.0, 0.0, 9.0, 8.0], [0.8, 0.0, 1.0, 0.4]));
}
#[test]
fn append_fields_highlight_the_whole_value_and_blink_their_cursor() {
use crate::hud::Blend;
-1
View File
@@ -7,7 +7,6 @@ pub use horizon_core::geometry::border::BorderStatus;
/// `WorldBorder.MAX_SIZE` (the float 5.999997E7 as a double).
pub const MAX_SIZE: f64 = 59_999_968.0;
pub const MAX_CENTER: f64 = 2.999_998_4e7;
/// `BorderStatus.getColor`: the wall colour.
pub fn status_color(status: BorderStatus) -> u32 {
+122 -84
View File
@@ -1,75 +1,35 @@
//! Minimal read-only ZIP archive support (stored and deflated entries), used
//! for resource packs and the unifont glyph archive.
use std::{collections::BTreeMap, fs, io, path::Path};
//! Read-only ZIP archives held in memory (resource packs, data packs and the
//! unifont glyph archive), on the `zip` crate. Like Java's `ZipFile` (what
//! vanilla's `FilePackResources` reads with), entries aren't CRC-checked.
use std::{
collections::BTreeMap,
fs,
io::{self, Cursor, Read},
path::Path,
sync::Arc,
};
use zip::{CompressionMethod, ZipArchive};
/// One archive held in memory with its central directory indexed by name.
/// One archive with its entries indexed by name (`\` read as `/`, folders
/// left out).
pub struct Zip {
data: Vec<u8>,
entries: BTreeMap<String, Entry>,
archive: ZipArchive<Cursor<Arc<[u8]>>>,
entries: BTreeMap<String, usize>,
}
#[derive(Clone, Copy)]
struct Entry {
method: u16,
compressed: usize,
size: usize,
header: usize,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn u16_at(d: &[u8], at: usize) -> io::Result<u16> {
d.get(at..at + 2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.ok_or_else(|| invalid("truncated zip"))
}
fn u32_at(d: &[u8], at: usize) -> io::Result<u32> {
d.get(at..at + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.ok_or_else(|| invalid("truncated zip"))
fn invalid(e: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
}
impl Zip {
pub fn open(path: &Path) -> io::Result<Self> {
Self::from_bytes(fs::read(path)?)
}
pub fn from_bytes(data: Vec<u8>) -> io::Result<Self> {
// End of central directory: the last "PK\x05\x06" within 64 KiB of the end.
let min = data.len().saturating_sub(22 + 65_535);
let eocd = (min..=data.len().saturating_sub(22))
.rev()
.find(|&i| data[i..i + 4] == [0x50, 0x4b, 0x05, 0x06])
.ok_or_else(|| invalid("not a zip archive"))?;
let count = usize::from(u16_at(&data, eocd + 10)?);
let mut at = u32_at(&data, eocd + 16)? as usize;
let mut entries = BTreeMap::new();
for _ in 0..count {
if u32_at(&data, at)? != 0x0201_4b50 {
return Err(invalid("bad central directory entry"));
}
let method = u16_at(&data, at + 10)?;
let compressed = u32_at(&data, at + 20)? as usize;
let size = u32_at(&data, at + 24)? as usize;
let name_len = usize::from(u16_at(&data, at + 28)?);
let extra_len = usize::from(u16_at(&data, at + 30)?);
let comment_len = usize::from(u16_at(&data, at + 32)?);
let header = u32_at(&data, at + 42)? as usize;
let name = data
.get(at + 46..at + 46 + name_len)
.ok_or_else(|| invalid("truncated zip name"))?;
let name = String::from_utf8_lossy(name).replace('\\', "/");
if !name.ends_with('/') {
entries.insert(
name,
Entry {
method,
compressed,
size,
header,
},
);
}
at += 46 + name_len + extra_len + comment_len;
}
Ok(Self { data, entries })
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
let entries = (0..archive.len())
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
.filter(|(name, _)| !name.ends_with('/'))
.collect();
Ok(Self { archive, entries })
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
@@ -78,30 +38,21 @@ impl Zip {
self.entries.contains_key(name)
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
let e = *self
let &index = self
.entries
.get(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
if u32_at(&self.data, e.header)? != 0x0403_4b50 {
return Err(invalid("bad local file header"));
}
let start = e.header
+ 30
+ usize::from(u16_at(&self.data, e.header + 26)?)
+ usize::from(u16_at(&self.data, e.header + 28)?);
let raw = self
.data
.get(start..start + e.compressed)
.ok_or_else(|| invalid("truncated zip entry"))?;
match e.method {
0 => Ok(raw.to_vec()),
8 => {
let out = miniz_oxide::inflate::decompress_to_vec_with_limit(
raw,
e.size.max(1) * 2 + 1024,
)
.map_err(|_| invalid("corrupt deflate stream"))?;
Ok(out)
// A clone shares the bytes and the central directory: reading needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let (method, size) = (entry.compression(), entry.size() as usize);
let mut raw = Vec::with_capacity(entry.compressed_size() as usize);
entry.read_to_end(&mut raw)?;
match method {
CompressionMethod::Stored => Ok(raw),
CompressionMethod::Deflated => {
miniz_oxide::inflate::decompress_to_vec_with_limit(&raw, size.max(1) * 2 + 1024)
.map_err(|_| invalid("corrupt deflate stream"))
}
_ => Err(invalid("unsupported zip compression")),
}
@@ -163,10 +114,97 @@ mod tests {
}
#[test]
fn reads_stored_and_deflated_entries() {
// Its CRCs are zero: Java's ZipFile doesn't check them either.
let zip = Zip::from_bytes(archive()).unwrap();
assert_eq!(zip.names().collect::<Vec<_>>(), ["a/b.txt", "pack.mcmeta"]);
assert_eq!(zip.read("pack.mcmeta").unwrap(), b"{\"pack\":{}}");
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
assert!(zip.read("missing").is_err());
}
/// A pack as zip tools write it: folder entries, a nested pack root,
/// stored and deflated files, and a Windows-style name.
fn written() -> Vec<u8> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
w.add_directory("Pack-main/", stored).unwrap();
w.add_directory("Pack-main/assets/minecraft/", stored)
.unwrap();
w.start_file("Pack-main/pack.mcmeta", stored).unwrap();
w.write_all(b"{\"pack\":{\"pack_format\":64}}").unwrap();
w.start_file("Pack-main/assets/minecraft/lang/en_us.json", deflated)
.unwrap();
w.write_all("{\"a\":\"b\"}".repeat(100).as_bytes()).unwrap();
w.start_file(
"Pack-main\\assets\\minecraft\\texts\\splashes.txt",
deflated,
)
.unwrap();
w.write_all(b"Also try Horizon!").unwrap();
w.finish().unwrap().into_inner()
}
#[test]
fn reads_nested_folders_and_skips_folder_entries() {
let zip = Zip::from_bytes(written()).unwrap();
assert_eq!(
zip.names().collect::<Vec<_>>(),
[
"Pack-main/assets/minecraft/lang/en_us.json",
"Pack-main/assets/minecraft/texts/splashes.txt",
"Pack-main/pack.mcmeta",
]
);
assert!(zip.contains("Pack-main/pack.mcmeta"));
assert!(!zip.contains("Pack-main/"));
assert_eq!(
zip.read("Pack-main/assets/minecraft/lang/en_us.json")
.unwrap(),
"{\"a\":\"b\"}".repeat(100).as_bytes()
);
assert_eq!(
zip.read("Pack-main/assets/minecraft/texts/splashes.txt")
.unwrap(),
b"Also try Horizon!"
);
}
#[test]
fn a_bad_entry_fails_alone() {
let mut bytes = written();
// The deflated lang file's stream becomes a reserved block type.
let (start, len) = {
let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let index = archive
.index_for_name("Pack-main/assets/minecraft/lang/en_us.json")
.unwrap();
let entry = archive.by_index_raw(index).unwrap();
(
entry.data_start() as usize,
entry.compressed_size() as usize,
)
};
bytes[start..start + len].fill(0xff);
let zip = Zip::from_bytes(bytes).unwrap();
assert!(
zip.read("Pack-main/assets/minecraft/lang/en_us.json")
.is_err()
);
assert_eq!(
zip.read("Pack-main/pack.mcmeta").unwrap(),
b"{\"pack\":{\"pack_format\":64}}"
);
}
#[test]
fn refuses_what_is_not_a_zip() {
assert!(Zip::from_bytes(b"PK not really".to_vec()).is_err());
assert!(Zip::from_bytes(Vec::new()).is_err());
let mut cut = written();
cut.truncate(cut.len() / 2);
assert!(Zip::from_bytes(cut).is_err());
}
}
@@ -168,9 +168,6 @@ impl Beardifier {
self.kernel_junctions = junctions;
self
}
pub fn affected_bounds(&self) -> Option<StructureBounds> {
self.affected
}
fn sample_unchecked(&self, [x, y, z]: [i32; 3]) -> f32 {
let mut value = 0.;
// The kernel is a process-wide constant; resolving it once keeps the
@@ -13,8 +13,6 @@ impl ShapeOp {
pub const XOR: Self = Self(6);
pub const ONLY_FIRST: Self = Self(4);
pub const ONLY_SECOND: Self = Self(2);
pub const FIRST: Self = Self(12);
pub const SECOND: Self = Self(10);
pub const fn from_truth_table(bits: u8) -> Self {
Self(bits & 15)
}
-28
View File
@@ -466,34 +466,6 @@ impl PalettedContainer {
}
}
}
/// Block ids as 16-bit values (callers whose registry fits in 16 bits).
pub fn unpack_u16(&self, out: &mut [u16; 4096]) {
debug_assert_eq!(self.strategy.entries(), 4096);
match &self.palette {
Palette::Single(value) => out.fill(*value as u16),
Palette::List(list) => {
let bits = usize::from(self.bits);
let per = 64 / bits;
let m = mask(self.bits);
for (chunk, &word) in out.chunks_mut(per).zip(&self.words) {
for (slot, cell) in chunk.iter_mut().enumerate() {
let id = ((word >> (slot * bits)) & m) as usize;
*cell = list[id] as u16;
}
}
}
Palette::Global => {
let bits = usize::from(self.bits);
let per = 64 / bits;
let m = mask(self.bits);
for (chunk, &word) in out.chunks_mut(per).zip(&self.words) {
for (slot, cell) in chunk.iter_mut().enumerate() {
*cell = ((word >> (slot * bits)) & m) as u16;
}
}
}
}
}
/// A container holding `values` in the smallest configuration for their
/// distinct count, with palette ids in first-occurrence order, like a
/// container unpacked from disk.
@@ -91,9 +91,6 @@ impl<C: WorldGenTickContainers> WorldGenTickAccess<C> {
pub fn containers_mut(&mut self) -> &mut C {
&mut self.containers
}
pub fn into_containers(self) -> C {
self.containers
}
pub fn schedule(&mut self, tick: ScheduledTick) -> Result<bool, C::Error> {
Ok(self.containers.container_at(tick.pos)?.schedule(tick))
}
-3
View File
@@ -636,7 +636,4 @@ impl LevelTicks {
pub fn copy_area(&mut self, area: TickArea, offset: BlockPos) {
self.schedule_copy(self.area_ticks(area), offset);
}
pub fn copy_area_from(&mut self, source: &Self, area: TickArea, offset: BlockPos) {
self.schedule_copy(source.area_ticks(area), offset);
}
}
-10
View File
@@ -296,14 +296,4 @@ impl Tunnel {
})?;
Ok((port, thread))
}
/// The protected stream itself (tests, or a client that reads it
/// directly).
pub fn into_stream(
self,
) -> (
crypto::SecureReader<TcpStream>,
crypto::SecureWriter<TcpStream>,
) {
self.secure
}
}
+6
View File
@@ -135,7 +135,13 @@ macro_rules! export_mod {
with(|m| $crate::Mod::chat(m, &text))
}
}
// Only WebAssembly has the component's exports: a host build (cargo test or
// clippy on the workspace) checks the mod without them, which macOS
// couldn't link (its exported-symbols list cuts names at the `#`).
#[cfg(target_arch = "wasm32")]
$crate::__export_horizon_mod!(Export);
#[cfg(not(target_arch = "wasm32"))]
let _ = <Export as $crate::__Guest>::init;
};
};
}
@@ -190,14 +190,6 @@ impl UserCache {
result
}
/// `get(UUID)`: a cached player by id (expired or not).
pub fn get_by_id(&mut self, id: u128) -> Option<NameAndId> {
let index = *self.by_uuid.get(&id)?;
self.operations += 1;
self.infos[index].last_access = self.operations;
Some(self.infos[index].user.clone())
}
/// The file's text: the 1000 most recently used entries, most recent
/// first, as Gson writes them (compact, HTML-safe).
pub fn to_json(&self) -> String {
@@ -416,11 +416,6 @@ pub type WhiteList = UserList<WhiteEntry>;
pub type BanList = UserList<BanEntry<NameAndId>>;
pub type IpBanList = UserList<BanEntry<String>>;
/// IpBanList.getIpFromAddress.
pub fn ip_of(address: &std::net::SocketAddr) -> String {
address.ip().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
+24 -2
View File
@@ -199,8 +199,11 @@ pub fn preprocess(
let (start, end) = (token.location.start, token.location.end);
let at = line_of(start);
if at > line {
// Lines the preprocessor left empty stay empty lines, so a value
// continued with `\` before an `#else` (Photon's
// `moon_phase_brightness`) ends there, as in Iris.
if line > 0 {
out.push('\n');
out.extend(std::iter::repeat_n('\n', at - line));
}
line = at;
} else if last_end != Some(start) {
@@ -799,7 +802,26 @@ b = 1
};
let values = OptionValues::default();
let out = preprocess(text, &env, &set, &values).unwrap();
assert_eq!(out, "a = 0.75\nb = 1\n");
let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines, ["a = 0.75", "b = 1"]);
// Photon ends an `#if`'d value with `) \` before `#else`: the value
// stops at the empty line the directive leaves.
let text = "\
#if FOG == 0
a = if( \\
moonPhase == 0, 1.0, \\
2.0 \\
) \\
#else
a = 1.0
#endif
b = 2
";
let out = preprocess(text, &env, &set, &values).unwrap();
let parsed = Properties::parse(&out);
assert_eq!(parsed.get("a"), Some("if( moonPhase == 0, 1.0, 2.0 ) "));
assert_eq!(parsed.get("b"), Some("2"));
assert_eq!(
jcpp_condition("#elif FOG == 0.75 && X > 2.5", &set, &values),
"#elif 0 == 0 && X > 2"
+236 -23
View File
@@ -17,8 +17,9 @@
//! (`vaPosition`, `modelViewMatrix`, ...) are declared;
//! - legacy texture calls are renamed, `shadow2D` returns a `vec4` again and a
//! swizzled shadow lookup is widened first (naga has no scalar swizzle);
//! - vertex and compute `texture(s, uv)` calls without an explicit LOD become
//! `textureLod(s, uv, 0.0)` so naga doesn't reject implicit derivatives;
//! - outside fragment programs, `texture`, `textureProj` and their `Offset`
//! forms become their `Lod` forms at level 0 (no derivatives there; naga
//! refuses the implicit ones), a bias dropped;
//! - global initialisers run at the start of `main` (drivers accept
//! non-constant ones), unsized global arrays are sized from them;
//! - `isnan`, `isinf` and `modf` are helper functions (WGSL has none);
@@ -380,9 +381,10 @@ pub fn translate(
stage: naga_stage,
defines: Default::default(),
};
let module = naga::front::glsl::Frontend::default()
let mut module = naga::front::glsl::Frontend::default()
.parse(&options, &glsl)
.map_err(|e| first_lines(&e.emit_to_string(&glsl)))?;
unpad_private_arrays(&mut module);
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -508,6 +510,79 @@ fn negative_constant(e: &ast::Expr) -> bool {
}
}
/// naga's GLSL front end lays every struct out as std140, so an array field
/// of a struct that never reaches a buffer (Photon's ACES spline parameters,
/// `float[6]`) gets a stride of 16, and constructing it from a `float[6](..)`
/// (stride 4) fails validation. Give such fields their natural stride.
fn unpad_private_arrays(module: &mut naga::Module) {
use naga::TypeInner as T;
let mut shared = HashSet::new();
let mut stack: Vec<_> = module
.global_variables
.iter()
.filter(|(_, v)| {
matches!(
v.space,
naga::AddressSpace::Uniform
| naga::AddressSpace::Storage { .. }
| naga::AddressSpace::Immediate
)
})
.map(|(_, v)| v.ty)
.collect();
while let Some(ty) = stack.pop() {
if !shared.insert(ty) {
continue;
}
match &module.types[ty].inner {
T::Struct { members, .. } => stack.extend(members.iter().map(|m| m.ty)),
T::Array { base, .. } | T::BindingArray { base, .. } => stack.push(*base),
_ => {}
}
}
let mut layouter = naga::proc::Layouter::default();
if layouter.update(module.to_ctx()).is_err() {
return;
}
let structs: Vec<_> = module
.types
.iter()
.filter(|(h, t)| !shared.contains(h) && matches!(t.inner, T::Struct { .. }))
.map(|(h, _)| h)
.collect();
for ty in structs {
let mut new = module.types[ty].clone();
let T::Struct { members, .. } = &mut new.inner else {
continue;
};
let mut changed = false;
for m in members.iter_mut() {
let T::Array { base, size, stride } = module.types[m.ty].inner else {
continue;
};
let natural = layouter[base].to_stride();
if stride != natural {
let span = module.types.get_span(m.ty);
m.ty = module.types.insert(
naga::Type {
name: None,
inner: T::Array {
base,
size,
stride: natural,
},
},
span,
);
changed = true;
}
}
if changed {
module.types.replace(ty, new);
}
}
}
fn first_lines(s: &str) -> String {
s.lines().take(8).collect::<Vec<_>>().join("\n")
}
@@ -554,6 +629,8 @@ struct Legaliser<'a> {
/// their helpers go in.
modulo: bool,
nan: bool,
/// `textureGather` was used: [`GATHER`] is added.
gather: bool,
mat2_ctor: bool,
/// Constants turned into globals ([`needs_runtime`]).
runtime_constants: HashSet<String>,
@@ -571,6 +648,94 @@ fn call(name: &str, args: Vec<ast::Expr>) -> ast::Expr {
.into_node()
}
/// `textureGather(sampler2D, uv, comp)`: component `comp` of the four texels
/// bilinear filtering at `uv` would blend, in GL's order, read with
/// `texelFetch` (edges clamped).
const GATHER: &str = "\
vec4 iris_gather(texture2D t, sampler s, vec2 uv, int c) {
ivec2 hi = textureSize(sampler2D(t, s), 0) - 1;
ivec2 i = ivec2(floor(uv * vec2(hi + 1) - 0.5));
vec4 a = texelFetch(sampler2D(t, s), clamp(i + ivec2(0, 1), ivec2(0), hi), 0);
vec4 b = texelFetch(sampler2D(t, s), clamp(i + ivec2(1, 1), ivec2(0), hi), 0);
vec4 d = texelFetch(sampler2D(t, s), clamp(i + ivec2(1, 0), ivec2(0), hi), 0);
vec4 e = texelFetch(sampler2D(t, s), clamp(i, ivec2(0), hi), 0);
return vec4(a[c], b[c], d[c], e[c]);
}
";
/// GL converts a `bool` given to a numeric constructor (`vec3(r < radius)`,
/// Photon's sun disc); naga refuses. A comparison or logical expression
/// passed to one becomes `(cond ? 1.0 : 0.0)` (or `1`/`1u`).
fn bool_to_number(e: &mut ast::Expr) {
use ast::BinaryOpData as B;
let ast::ExprData::FunCall(f, args) = &mut e.content else {
return;
};
let Some(name) = callee(f) else { return };
let base = name.trim_start_matches('d');
let (one, zero) = if base.starts_with("ivec") || name == "int" {
(ast::ExprData::IntConst(1), ast::ExprData::IntConst(0))
} else if base.starts_with("uvec") || name == "uint" {
(ast::ExprData::UIntConst(1), ast::ExprData::UIntConst(0))
} else if base.starts_with("vec") || base.starts_with("mat") || name == "float" {
(
ast::ExprData::FloatConst(1.0),
ast::ExprData::FloatConst(0.0),
)
} else {
return;
};
for a in args.iter_mut() {
let boolean = match &a.content {
ast::ExprData::Binary(op, ..) => matches!(
op.content,
B::Or | B::Xor | B::And | B::Equal | B::NonEqual | B::Lt | B::Gt | B::Lte | B::Gte
),
ast::ExprData::Unary(op, _) => op.content == ast::UnaryOpData::Not,
_ => false,
};
if boolean {
let cond = std::mem::replace(a, var("_"));
*a = ast::ExprData::Ternary(
Box::new(cond),
Box::new(one.clone().into_node()),
Box::new(zero.clone().into_node()),
)
.into_node();
}
}
}
/// Outside fragment shaders there are no derivatives, so GL samples level 0
/// where `texture` and its kin would pick one; naga refuses them there (the
/// vertex programs of Chocapic13', MakeUp and Bliss). Turn them into their
/// `Lod` forms at level 0 (a bias is dropped), also inside `vec4(..)` as
/// legacy shadow lookups are wrapped.
fn explicit_lod(e: &mut ast::Expr) {
let ast::ExprData::FunCall(f, args) = &mut e.content else {
return;
};
let Some(name) = callee(f) else { return };
if name == "vec4" && args.len() == 1 {
return explicit_lod(&mut args[0]);
}
// (implicit, explicit, where the level goes, how many arguments without a bias)
let (lod, at, plain) = match name.as_str() {
"texture" => ("textureLod", 2, 2),
"textureProj" => ("textureProjLod", 2, 2),
"textureOffset" => ("textureLodOffset", 2, 3),
"textureProjOffset" => ("textureProjLodOffset", 2, 3),
_ => return,
};
if args.len() < plain {
return;
}
args.truncate(plain);
let zero = ast::ExprData::FloatConst(0.0).into_node();
args.insert(at, zero);
*f = ast::FunIdentifierData::Expr(Box::new(var(lod))).into_node();
}
/// The name a call is made by: a function, or a constructor's type.
fn callee(f: &ast::FunIdentifier) -> Option<String> {
match &f.content {
@@ -784,6 +949,7 @@ impl<'a> Legaliser<'a> {
constant: false,
modulo: false,
nan: false,
gather: false,
mat2_ctor: false,
runtime_constants: HashSet::new(),
}
@@ -1253,7 +1419,23 @@ impl<'a> Legaliser<'a> {
}
let mut locals = Vec::new();
let mut params = Vec::with_capacity(p.parameters.len());
for param in std::mem::take(&mut p.parameters) {
for mut param in std::mem::take(&mut p.parameters) {
// `const in float x` (Bliss): naga's front end takes no `const`
// on parameters, and it changes nothing for the caller.
match &mut param.content {
ast::FunctionParameterDeclarationData::Named(q, _)
| ast::FunctionParameterDeclarationData::Unnamed(q, _) => {
if let Some(list) = q {
list.qualifiers.retain(|q| {
!matches!(&q.content, ast::TypeQualifierSpecData::Storage(s)
if s.content == ast::StorageQualifierData::Const)
});
if list.qualifiers.is_empty() {
*q = None;
}
}
}
}
let (ty, name) = match &param.content {
ast::FunctionParameterDeclarationData::Named(_, d) => {
(type_text(&d.ty), Some(d.ident.ident.as_str().to_owned()))
@@ -1713,32 +1895,60 @@ impl<'a> Legaliser<'a> {
self.nan = true;
return;
}
let mut target_name = name.as_str();
if name == "textureGather"
&& (2..=3).contains(&args.len())
&& let ast::ExprData::FunCall(g, inner) = &args[0].content
&& inner.len() == 2
&& callee(g).as_deref() == Some("sampler2D")
{
// naga's GLSL front end has no `textureGather`.
let mut call_args = inner.clone();
call_args.push(args[1].clone());
call_args.push(
args.get(2)
.cloned()
.unwrap_or_else(|| ast::ExprData::IntConst(0).into_node()),
);
*e = call("iris_gather", call_args);
self.gather = true;
return;
}
if name == "modf"
&& args.len() == 2
&& !matches!(args[1].content, ast::ExprData::Variable(_))
{
// `modf(x, v.y)` (Photon): WGSL can't point at a vector
// component, so no `out` parameter: `+(v.y = trunc(x), x - trunc(x))`
// (the unary `+` has the comma printed in brackets, also in an
// initialiser).
let (x, whole) = (args[0].clone(), args[1].clone());
let assign = ast::ExprData::Assignment(
Box::new(whole),
ast::AssignmentOpData::Equal.into_node(),
Box::new(call("trunc", vec![x.clone()])),
)
.into_node();
let fraction = ast::ExprData::Binary(
ast::BinaryOpData::Sub.into_node(),
Box::new(x.clone()),
Box::new(call("trunc", vec![x])),
)
.into_node();
let comma = ast::ExprData::Comma(Box::new(assign), Box::new(fraction)).into_node();
*e = ast::ExprData::Unary(ast::UnaryOpData::Add.into_node(), Box::new(comma))
.into_node();
return;
}
if let Some((_, new)) = LEGACY_CALLS.iter().find(|(old, _)| *old == name) {
*f = ast::FunIdentifierData::Expr(Box::new(var(new))).into_node();
target_name = new;
} else if let Some((_, new)) = SHADOW_CALLS.iter().find(|(old, _)| *old == name) {
let args = std::mem::take(args);
*e = call("vec4", vec![call(new, args)]);
return;
}
// In vertex and compute shaders, `texture(sampler, coord)` uses implicit
// derivatives that WGSL/naga forbid outside fragment shaders (Bliss):
// rewrite 2-argument non-shadow `texture(s, uv)` to `textureLod(s, uv, 0.0)`.
if self.stage != Stage::Fragment && target_name == "texture" && args.len() == 2 {
let is_shadow = match &args[0].content {
ast::ExprData::FunCall(g, _) => {
callee(g).and_then(|c| opaque(&c)).is_some_and(|o| o.3)
}
_ => false,
};
if !is_shadow {
if let ast::ExprData::FunCall(f, args) = &mut e.content {
*f = ast::FunIdentifierData::Expr(Box::new(var("textureLod"))).into_node();
args.push(ast::ExprData::FloatConst(0.0).into_node());
}
}
if self.stage != Stage::Fragment {
explicit_lod(e);
}
bool_to_number(e);
}
/// The legalised source: header, the rewritten program, then a `main`
@@ -1775,6 +1985,9 @@ impl<'a> Legaliser<'a> {
if self.modulo {
out.push_str(&modulo_helpers());
}
if self.gather {
out.push_str(GATHER);
}
if self.nan {
out.push_str("float iris_sqrt(float x) { return sqrt(x); }\n");
}
@@ -368,6 +368,78 @@ void main() {
reparse(&fs.wgsl).unwrap();
}
/// What Photon, Chocapic13', MakeUp Ultra Fast and Bliss write and GL takes:
/// lookups without a level in a vertex shader, a struct passed between
/// stages, `textureGather`, a `bool` given to `vec3(..)`, `const in`
/// parameters and an array field in a struct built by its constructor.
#[test]
fn legalises_what_photon_and_chocapic_write() {
let mut link = Link::default();
let header = "uniform sampler2D colortex0;
uniform sampler2DShadow shadowtex0;
struct Clouds { float coverage; vec2 wind; mat3 turn; int layers; };
";
let vs = run(
Stage::Vertex,
&format!(
"{header}flat varying Clouds clouds;
void main() {{
clouds.coverage = texture2D(colortex0, vec2(0.5)).r + shadow2D(shadowtex0, vec3(0.5)).r;
clouds.wind = textureOffset(colortex0, vec2(0.5), ivec2(1, 0), 2.0).rg;
clouds.turn = mat3(1.0);
clouds.layers = 2;
gl_Position = ftransform();
}}
"
),
&mut link,
)
.unwrap();
for wanted in [
"textureLod(sampler2D(colortex0_t",
"vec2(0.5), 0.)",
"textureLodOffset(sampler2D(colortex0_t",
"clouds_iriscoverage = clouds.coverage;",
"clouds_iristurn_iris1 = clouds.turn[1];",
] {
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
}
reparse(&vs.wgsl).unwrap();
let fs = run(
Stage::Fragment,
&format!(
"{header}flat varying Clouds clouds;
struct Spline {{ float slope; float[3] coeffs; }};
float edge(const in float x, const float r) {{ return float(x < r); }}
void main() {{
const Spline spline = Spline(0.0, float[3](1.0, 2.0, 3.0));
vec4 four = textureGather(colortex0, vec2(0.5), 1);
vec3 disc = vec3(clouds.coverage < 0.5);
vec2 cell = vec2(0.0);
float part = modf(clouds.coverage * 4.0, cell.y);
gl_FragColor = vec4(disc * edge(four.x, spline.coeffs[1]), clouds.wind.x + float(clouds.layers) + part + cell.y);
}}
"
),
&mut link,
)
.unwrap();
for wanted in [
"iris_gather(colortex0_t",
"clouds.coverage = clouds_iriscoverage;",
"float edge(in float x, float r)",
"cell.y = trunc(",
] {
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
}
reparse(&fs.wgsl).unwrap();
// The vertex program's outputs are the fragment program's inputs.
assert_eq!(
io(&vs, true).get("clouds_iriswind"),
io(&fs, false).get("clouds_iriswind")
);
}
/// A fragment output narrower than its four-channel target (Steadfast's
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
/// GL fills the missing channels, wgpu refuses the pipeline.
@@ -62,9 +62,6 @@ impl<E> ProtoBlockEntities<E> {
pub fn active(&self, position: BlockPos) -> Option<&E> {
self.active.get(&position)
}
pub fn active_mut(&mut self, position: BlockPos) -> Option<&mut E> {
self.active.get_mut(&position)
}
pub fn pending(&self, position: BlockPos) -> Option<&Tag> {
self.pending.get(&position)
}
-12
View File
@@ -157,12 +157,6 @@ impl FaceVisibility {
}
}
}
/// Whether the state's occlusion face toward `direction` is the full square.
pub fn full_face(&self, state: BlockStateId, direction: Direction) -> bool {
self.states
.get(state.0 as usize)
.is_some_and(|s| s.faces[direction as usize] == 1)
}
/// The state's occlusion face toward `direction` (`getFaceOcclusionShape`) and
/// its shared ID: 0 is the empty singleton, 1 the full-block singleton.
pub fn occlusion_face(
@@ -173,12 +167,6 @@ impl FaceVisibility {
let id = self.states.get(state.0 as usize)?.faces[direction as usize];
Some((id, &self.faces[id as usize]))
}
/// Whether the state's occlusion face toward `direction` is empty.
pub fn empty_face(&self, state: BlockStateId, direction: Direction) -> bool {
self.states
.get(state.0 as usize)
.is_none_or(|s| s.faces[direction as usize] == 0)
}
pub fn skip_rendering(
&self,
state: BlockStateId,
-19
View File
@@ -186,25 +186,6 @@ impl<'r> BlockSupportRegistry<'r> {
pub fn registry(&self) -> &'r BlockRegistry {
self.cached.registry()
}
pub fn support_shape<W: DynamicSupportWorld>(
&self,
world: &mut W,
state: BlockStateId,
position: [i32; 3],
) -> Result<VoxelShape, SupportQueryError<W::Error>> {
if let Some(shape) = self
.cached
.cached_shape(state)
.map_err(SupportQueryError::Data)?
{
return Ok(shape.clone());
}
self.dynamic
.get(&state)
.ok_or(SupportQueryError::Unsupported(state))?
.shape(world, position)
.map_err(SupportQueryError::Dynamic)
}
pub fn is_face_sturdy<W: DynamicSupportWorld>(
&self,
world: &mut W,
@@ -524,14 +524,6 @@ impl<'r> BlockSurvivalRegistry<'r> {
bubble_down: tag_states("minecraft:enables_bubble_column_drag_down")?,
})
}
pub fn supported_states(&self) -> usize {
self.rules.iter().filter(|r| r.is_some()).count()
}
pub fn supports_state(&self, state: BlockStateId) -> bool {
self.rules
.get(state.0 as usize)
.is_some_and(Option::is_some)
}
fn read<W: SurvivalWorld>(
&self,
world: &mut W,
@@ -3,7 +3,6 @@
use crate::{
component_patch::{ComponentMap, MissingPrototype, Patch, Prototype},
item_durability::DurabilityStorage,
item_use_catalog::UseComponents,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
@@ -52,13 +51,6 @@ impl DurabilityPrototype {
}
Self(Prototype::new(entries))
}
pub fn from_defaults(defaults: &UseComponents) -> Self {
Self::new([
Change::MaxDamage(defaults.max_damage),
Change::Damage(defaults.damage),
Change::Unbreakable(defaults.unbreakable),
])
}
}
#[derive(Clone, Debug)]
pub struct DurabilityPatch(Patch<Key, i32>);
@@ -157,12 +157,6 @@ pub fn map(tag: &Tag) -> Option<&Compound> {
_ => None,
}
}
pub fn map_mut(tag: &mut Tag) -> Option<&mut Compound> {
match tag {
Tag::Compound(c) => Some(c),
_ => None,
}
}
/// `Dynamic.set`: only maps change.
pub fn set(tag: &mut Tag, field: &str, value: Tag) {
if let Tag::End = tag {
@@ -193,12 +187,6 @@ pub fn update(tag: &mut Tag, field: &str, f: impl FnOnce(Tag) -> Tag) {
set(tag, field, f(v));
}
}
/// `Dynamic.update` in place.
pub fn update_mut(tag: &mut Tag, field: &str, f: impl FnOnce(&mut Tag)) {
if let Some(v) = tag.get_mut(field) {
f(v);
}
}
/// `Dynamic.renameAndFixField`: the fixed value under the new name, removed when
/// the fix gives nothing.
pub fn rename_and_fix(tag: &mut Tag, from: &str, to: &str, f: impl FnOnce(Tag) -> Option<Tag>) {
@@ -223,15 +211,6 @@ pub fn is_empty_map(tag: &Tag) -> bool {
pub fn or_empty_map(tag: Option<&Tag>) -> Tag {
tag.cloned().unwrap_or_else(empty_map)
}
/// Apply `f` to every element of a list field (`Dynamic.createList(asStream().map(f))`).
pub fn map_list(tag: &mut Tag, field: &str, mut f: impl FnMut(Tag) -> Tag) {
if let Some(v) = tag.get(field)
&& is_list(Some(v))
{
let list = stream(Some(v)).into_iter().map(&mut f).collect();
set(tag, field, Tag::List(list));
}
}
/// `NamespacedSchema.ensureNamespaced`.
pub fn namespaced(id: &str) -> String {
match crate::datafix::identifier(id) {
-3
View File
@@ -44,9 +44,6 @@ impl MovementHistory {
}
self.records.push_back(record);
}
pub fn remove_latest(&mut self) {
self.records.pop_back();
}
pub fn clear(&mut self) {
self.records.clear();
}
@@ -73,17 +73,3 @@ pub fn is_unobstructed<W: EntityObstructionWorld>(
}
Ok(true)
}
/// Entity obstruction for the unit cube used by the vanilla block predicate.
/// Translate the voxel shape in floating point: integer MAX + 1 must not wrap.
pub fn is_block_unobstructed<W: EntityObstructionWorld>(
world: &mut W,
position: [i32; 3],
) -> Result<bool, ObstructionError<W::Error>> {
let shape = VoxelShape::block().moved(horizon_core::geometry::Vec3::new(
f64::from(position[0]),
f64::from(position[1]),
f64::from(position[2]),
));
is_unobstructed(world, None, &shape)
}
-7
View File
@@ -272,13 +272,6 @@ impl FluidStateTable {
pub fn get(&self, state: BlockStateId) -> Option<FluidState> {
self.0.get(state.0 as usize).copied()
}
pub fn matching_states(&self, mask: u8) -> impl Iterator<Item = BlockStateId> + '_ {
self.0
.iter()
.enumerate()
.filter(move |(_, state)| state.kind.mask() & mask != 0)
.map(|(i, _)| BlockStateId(i as u32))
}
}
/// Bound identities for converting fluid states into their legacy liquid blocks.
@@ -15,9 +15,6 @@ macro_rules! classes {
pub fn from_java(name: &str) -> Option<Self> {
match name { $($java => Some(Class::$name),)* _ => None }
}
pub const fn java_name(self) -> &'static str {
match self { $(Class::$name => $java,)* }
}
}
};
}
-1
View File
@@ -148,7 +148,6 @@ pub mod flags {
pub const UPDATE_NEIGHBORS: i32 = 1;
pub const UPDATE_CLIENTS: i32 = 2;
pub const UPDATE_INVISIBLE: i32 = 4;
pub const UPDATE_IMMEDIATE: i32 = 8;
pub const UPDATE_KNOWN_SHAPE: i32 = 16;
pub const UPDATE_SUPPRESS_DROPS: i32 = 32;
pub const UPDATE_MOVE_BY_PISTON: i32 = 64;
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more