Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 3be9ca142a horizon-bots: report each idle bot's first full view time
view_after_ms: from asking to join to the last chunk new to the bot.
For a bot that stands still, that is when its whole first view arrived.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:38:40 +01:00
Ludovic Marie e84a47c069 Merge pull request #3485 from ThatCakeIsAPie/feat/level-shape-6
🤖 Agent: verified on main + this head. I merged main in (through the carried #3484); save.rs keeps main's mob imports. Checks: the 52 harnesses this change can affect, dedicated_server, server_entities, server_dimension_players, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels: chunk packets to a LAN guest in each level) pass. The changes are per chunk, not per block. Queued to merge; #3486 follows.
2026-10-30 10:21:17 +01:00
Ludovic Marie ba6344a59c Merge pull request #4217 from horizon-rust/fix/keepalive-timeout
🤖 Agent: verify and parity green at c3601b74f (the merged-head parity rerun passed); approved by rdbtCVS. Queued for the merger.
2026-10-30 09:49:11 +01:00
Ludovic Marie 483d832a31 Merge pull request #3484 from ThatCakeIsAPie/feat/level-shape-5
🤖 Agent: verified on main + this head. I merged main in (through the carried #3483). save.rs keeps main's mob imports. LevelShape::for_dir, which the carried #3481 had moved beside the type for the crate split, is gone, as this PR intends: SaveDir::new takes the level's shape. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. Queued to merge; #3485 follows.
2026-10-30 09:43:13 +01:00
Ludovic Marie 45982faa5c Merge pull request #4245 from horizon-rust/perf/one-loot-data
🤖 Agent: all checks green including parity; approved by rdbtCVS; no comments. Queued for the merger.
2026-10-30 09:11:26 +01:00
Ludovic Marie 5d80bf5b89 Merge pull request #3483 from ThatCakeIsAPie/feat/level-shape-4
🤖 Agent: verified on main + this head. I merged main in (through the carried #3482). The crate split had moved gfx.rs, gfx/resident.rs, mesher.rs, streaming.rs and world.rs to horizon-client-render, so this PR's changes to them are reapplied there. World::set keeps main's narrower remesh marking (only the sections a write can reach), now with the chunk's own min_y and section count. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. chunk_load_bench gives the same meshes as main, and meshing time is within the machine's noise. Queued to merge; #3484 follows.
2026-10-30 09:04:16 +01:00
Ludovic Marie bb64067197 Merge pull request #4200 from horizon-rust/fix/suggestions-one-per-tick
🤖 Agent: parity rerun green (the earlier dedicated_server light-layers failure was a load flake); approved by rdbtCVS; no blocking comments. Queued for the merger.
2026-10-30 08:29:23 +01:00
Ludovic Marie cfab69e790 Merge pull request #4233 from horizon-rust/fix/claim-agent-prefix
🤖 Agent: /claim accepts agents' '🤖 Agent:' prefix; 480 Python tests pass.
2026-10-30 08:25:35 +01:00
Ludovic MarieandClaude Opus 5.5 2c526aa767 Load the loot data once when a world opens with the packs world generation used
World generation loads the loot data with no packs while the world is
prepared; opening the world then forgot it and loaded a second copy, which
the generator's copy kept alive beside it (8 MB for the whole run). Now
opening a world keeps the loaded copy when its root and packs are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:23:55 +01:00
Ludovic Marie d129fb7cfe Merge pull request #3482 from ThatCakeIsAPie/feat/level-shape-3
🤖 Agent: verified on main + this head. I merged main in (through the carried #3481); it merged cleanly. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (light, levels, distance, portals, chest, forced-levels) pass. worldspawn fails only when it runs after levels, on main from before this stack as well: the harness gives the guest its own spawnpoint (#4234), so that isn't this PR. The light flood fill reads the level's column instead of constants: chunk_load_bench main vs this branch gives the same best time for generating and lighting (28.95 ms per column) and the same meshes. Queued to merge; #3483 follows.
2026-10-30 08:19:07 +01:00
Ludovic MarieandClaude Opus 5.5 92e956be80 /claim: accept the "🤖 Agent:" prefix agents put on every comment
Agents start each comment with "🤖 Agent:" (CLAUDE.md), so their
"🤖 Agent: /claim" was never seen: the workflow only started on comments
beginning with /claim, and parse() read the first line as is. rdbt
noticed agents' claims missing (2026-10-30).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:16:24 +01:00
levzzz b1dfde280b Merge pull request #3948 from kacpherek/fix/split-spear-ai-golem
Mobs use spears; iron golems keep to their village
2026-10-30 12:00:15 +05:00
levzzzandClaude Opus 5.5 1bd934d3fb Merge main into fix/split-spear-ai-golem
lan.rs: keep both the spear hit event (2) and main's fox eating event (45).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:51:46 +05:00
levzzzandClaude Opus 5.5 03498ad1c7 Merge main into fix/split-spear-ai-golem
piglin.rs: keep main's piglin/hoglin brain memories and add this branch's spear memories to them; the fight activity keeps the spear behaviours before the melee one and main's NotRiding repellent goal; piglin_pose_holding takes both main's passenger flag and the spear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:50:00 +05:00
levzzz fbd7288a1d Merge pull request #3949 from kacpherek/fix/split-mob-sounds-behaviour
Mob sounds and behaviour: fox, zoglin, turtle, iron golem, sheep, cat, nautilus, pig and more as vanilla
2026-10-30 11:48:46 +05:00
levzzz 6966a69d36 Merge pull request #3946 from kacpherek/fix/split-agelock-goat-allay
Goats long jump between ledges, golden dandelions lock a baby's growth, allays remember a note block
2026-10-30 11:48:36 +05:00
Ludovic Marie e4fb44e067 Merge pull request #4185 from horizon-rust/fix/guest-inbox-cap
🤖 Agent: all checks green on 5a017c900 (inbox capped in packets and bytes; A/B in the description). Queued.
2026-10-30 07:45:06 +01:00
levzzzandClaude Opus 5.5 0c441c9009 Merge main into fix/split-mob-sounds-behaviour
ai.rs: main's piglin_ambient/hoglin_ambient now pick the angry and retreat sounds from the brain's activity; keep those and add this branch's zoglin angry sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:41:48 +05:00
Ludovic Marie c48d6773c5 Merge pull request #3481 from ThatCakeIsAPie/feat/level-shape-2
🤖 Agent: verified on main + this head. I merged main into the branch: the crate split had moved chunk.rs to horizon-client-render, so the LevelShape helpers this PR uses from save.rs are pub now and LevelShape::for_dir sits beside the type (an impl in horizon-client on a type of horizon-client-render no longer compiles). Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (light, levels, distance, worldspawn, portals, chest, forced-levels) pass. chunk_load_bench main vs this branch: same meshes (checksum), mesh time unchanged (42.0–44.2 vs 42.7–43.2 µs per section). Queued to merge; the rest of the level-shape stack follows in order.
2026-10-30 07:41:33 +01:00
levzzz 2160cd016a Merge pull request #3791 from xFedeT/fix/flyers-scan
Ghast: target position, move control timing, look angle and shot sounds as vanilla
2026-10-30 11:39:28 +05:00
levzzz a99ef86dc6 Merge pull request #3942 from kacpherek/fix/split-piglin-hoglin
Piglins and hoglins retreat, retaliate, grunt and ride like vanilla
2026-10-30 11:39:12 +05:00
Ludovic MarieandClaude Opus 5.5 c3601b74f3 Server: the game thread is the keep-alive timeout's only judge; kick, then close a second later
The writer thread's own check raced it and logged "Disconnected" instead of
vanilla's "Timed out".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:30:07 +01:00
Ludovic Marie 68f036059e Merge pull request #4181 from horizon-rust/tools/bots-abusive-client
🤖 Agent: all checks green on 6edf1ff46 (horizon-bots only, skip-release). Queued.
2026-10-30 07:22:09 +01:00
Ludovic MarieandClaude Opus 5.5 99998b9f3c Merge main (through the carried #3484) into level-shape-6
Keeps main's mobs imports in save.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:20:00 +01:00
Ludovic MarieandClaude Opus 5.5 c2db12a1a0 Merge main (through the carried #3483) into level-shape-5
Keeps main's mobs imports in save.rs. LevelShape::for_dir, which the
carried #3481 moved to chunk.rs for the crate split, goes as this
commit removes it: SaveDir::new takes the level's shape instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:19:04 +01:00
Ludovic MarieandClaude Opus 5.5 0c149c7d1c Merge main (through the carried #3482) into level-shape-4
The crate split moved gfx.rs, gfx/resident.rs, mesher.rs, streaming.rs and
world.rs to horizon-client-render: this commit's changes are reapplied there.
World::set keeps main's narrower remesh marking (only the sections a write
can reach) with the chunk's own min_y and section count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:15:27 +01:00
Ludovic MarieandClaude Opus 5.5 5fb90b440c Server: time out a client that stops answering keep-alives, even while its writer is blocked
The writer thread sent and checked the keep-alives; a client that stopped
reading blocked it in a write, so it was never timed out and held its slot.
The game thread now checks the keep-alive clock once a second and closes the
socket, which ends the blocked write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:11:58 +01:00
Ludovic Marie bd9b325872 Merge pull request #3674 from d3grando/fix-3129
🤖 Agent: Ludovic's AI agent (Claude), proof. With your check fix (one loaf each, bites read once they settle), `lan_e2e` `eating` and `eat` pass 2 runs out of 2 on the server thread and 2 out of 2 `--in-frame`. That was in a union with #3706 and #3551 merged into main at a6aa6a071: 111 of 112 affected harnesses passed, none failed, and clippy and the tests passed. Queued.
2026-10-30 07:07:53 +01:00
Ludovic Marie b51637616b Merge pull request #3706 from arismoko/upstream/native-lifecycle
🤖 Agent: Ludovic's AI agent (Claude), proof. Thanks for taking up the review points (the context now carries only authority and epoch, and the crash paths are documented). This head was in a union with #3674 and #3551 merged into main at a6aa6a071. `pr_ready`: 111 of 112 affected harnesses passed, none failed; the 112th is the LAN suite, which runs by name only. clippy passed, and 1541 tests passed. `lan_e2e` (chest, eat, eating) passes twice on the server thread and twice `--in-frame`. Your container proof covers the dedicated runs and identical saves. Queued.
2026-10-30 07:04:11 +01:00
Ludovic MarieandClaude Opus 5.5 5a017c9006 lan_bot: queue test packets with their size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:02:33 +01:00
Ludovic Marie c18e893143 Merge branch 'pt1-3481' into pt1-3482 2026-10-30 06:46:22 +01:00
Ludovic MarieandClaude Opus 5.5 b9f8ea2f27 Server: answer one command suggestion request a tick, the latest (ServerCommandSuggestionsProvider)
A client asking 100 times a tick took the server to 16 TPS: every request was parsed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:33:55 +01:00
Ludovic MarieandClaude Opus 5.5 536c3b90fd LevelShape helpers are pub, and for_dir sits beside the type
Since the crate split, chunk.rs is in horizon-client-render while save.rs
(horizon-client) reads min_section, section_count, has_section and
for_dir, so they are pub and for_dir moves out of save.rs's impl block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:33:02 +01:00
Ludovic MarieandClaude Opus 5.5 4abef77bba Server: cap a player's waiting packets in bytes too (16 MB)
400 full books a tick passed the packet count while holding hundreds of MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:31:48 +01:00
Ludovic Marie bc3383d1bf Merge remote-tracking branch 'origin/main' into pt1-3481 2026-10-30 06:31:05 +01:00
Ludovic Marie b76ae35f5b Merge pull request #3480 from ThatCakeIsAPie/feat/level-shape-1
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (eb3f03d7f, a merge commit, fast-forward). The only conflict was `lib.rs`'s re-export list: `section_mask` now sits beside main's `potent_sulfur` and `split_layout`. The full proof the orchestrator asked for, on this head merged into today's main: - the 121 vanilla harnesses this branch can reach pass, and `dedicated_server` too; - pr_ready passes; - lan_e2e passes on the server-thread host: light, levels, distance, portals, chest, forced-levels and worldspawn. In-frame is deprecated now. worldspawn failed once on timing, then passed 2/2. Speed: the masks only grow from 4 to 32 bytes, in per-section bookkeeping (dirty sections, the light log, LOD detail columns). There is no allocation and no per-block work, so frame and tick costs stay the same. Queued for merge. #3481 comes next, once this one is in.
2026-10-30 06:30:54 +01:00
Ludovic Marie c6bf75b3b1 Merge pull request #3740 from UMCEKO/mods/oracle-item-components
🤖 Agent: Ludovic's AI agent (Claude), proof. The real NeoForge pack test above covers this PR's exporter. On a 3-mod pack it wasn't developed against, the exported item facts matched 52 of 52 values read with vanilla's own commands on the same server, without the exporter. CI verify is green. Queued, first in the stack's order.
2026-10-30 06:21:42 +01:00
Ludovic Marie a6aa6a071a Merge pull request #4142 from horizon-rust/perf/idle-generator-scratch
🤖 Agent: verified by server-tick-1. A chunk worker idle for 5 s frees its generator's density scratch and lookup caches (caches only, results unchanged), and the last idle worker returns freed memory to the OS once; −71 MB idle RSS on Linux. pr_ready green.
2026-10-30 06:10:05 +01:00
levzzzandClaude Opus 5.5 c9a87963ef Merge main into fix/split-agelock-goat-allay
frog.rs: keep the goat jump cooldown and main's vanilla memory id long_jump_cooling_down.
remote_mob_data.rs: keep the tadpole AGE_LOCKED line beside main's mooshroom comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:07:25 +05:00
Ludovic MarieandClaude Opus 5.5 6edf1ff467 horizon-bots: costly floods (use, click, suggest, book, sign, recipe) and --no-read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:58:24 +01:00
levzzz 33f417fb32 Merge pull request #4051 from d3grando/fix-3199
A camel and the husk on its back no longer push each other
2026-10-30 09:55:26 +05:00
Ludovic Marie ee883db4b9 Merge pull request #4117 from horizon-rust/perf/shared-advancement-tree
🤖 Agent: verified by server-tick-1, after merging main. ServerAdvancements and the game's criteria share one advancement tree (Tree::shared, Weak-held) when the world's packs bring no advancement files; with pack advancements each loads its own, as main does. −9 MB idle RSS on Linux. pr_ready green.
2026-10-30 05:55:06 +01:00
levzzz f11bbea3b1 Merge pull request #3881 from TJKDev1/fix/own-head-pitch-follows-mouse
Third person: the player's head pitch follows the mouse between ticks
2026-10-30 09:48:58 +05:00
levzzzandClaude Opus 5.5 bb0bf9e1c8 Pass PackOverlay::NONE to Items::load in the same-vehicle push test
Main added a PackOverlay argument to Items::load after this branch was written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:44:29 +05:00
levzzz a6a734da85 Merge commit 'refs/pull/4051/head' of https://github.com/horizon-rust/horizon into HEAD 2026-10-30 09:39:36 +05:00
levzzzandClaude Opus 5.5 3b82d4770f Merge main into fix/own-head-pitch-follows-mouse
Keeps both look_at_rotation (main) and position_packet_pitch (this branch) in remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:39:17 +05:00
Ludovic MarieandClaude Opus 5.5 fa50f136fe Server: disconnect a player whose packets pile up past a second of its budget
The per-player inbox had no limit: a client sending 10 000 packets a tick
grew the server by about 1 GB a minute (#4179).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:38:59 +01:00
Ludovic MarieandClaude Opus 5.5 eb3f03d7f3 Merge main into this branch (split_layout and potent_sulfur re-exports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:38:07 +01:00
Ludovic Marie 061751a023 Merge pull request #4095 from horizon-rust/carry/fusseltv-idle-spawn-area
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Proof of this head merged into today's main (101d3b0be, which now has #3300, #2698 and #3802): - the 108 vanilla harnesses this branch can reach pass, `dispenser` and `dedicated_server` included; - pr_ready passes; - lan_e2e passes: worldspawn, forced-levels, distance, spawned, levels, light, mobs. Queued for merge.
2026-10-30 05:33:12 +01:00
Ludovic Marie 5851a80ce0 Merge pull request #3735 from xFedeT/fix/zombified-piglin-turtle-egg-goal
🤖 Agent: Ludovic's AI agent (Claude), triage. #3712 landed with a mob_ai case of its own, so I merged main into your branch again (no rebase). The only conflict was the case list in `tools/test_mob_ai_parity.py`, and both sides are kept. `parity.py mob_ai` passes on main with this and #3721/#3735 merged. Requeued.
2026-10-30 05:24:41 +01:00
Ludovic Marie bf6c0b8a24 Merge pull request #4078 from horizon-rust/perf/shared-face-visibility
🤖 Agent: the parity rerun is green (dedicated_server's ender pearl step passed; the 03:10 failure was load). Requeued, same head.
2026-10-30 05:19:39 +01:00
Ludovic Marie 05f5c2197b Merge pull request #4092 from horizon-rust/perf/shared-feature-block-tags
🤖 Agent: verified by server-tick-1. Worldgen binds the feature block tags once (GeneratorData::feature_block_bindings) and shares them through Arcs; the maps' readers are unchanged, so decoration gives the same result. −11.6 MB allocated on the dedicated server. pr_ready green; parity 152/153, the failing dispenser case comes from main (golem PRs #4022/#3959), not from this change.
2026-10-30 05:17:46 +01:00
Ludovic MarieandClaude Opus 5.5 3ffa45a5cf horizon-bots: flood-chunks climbs to cruising height first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:17:03 +01:00
Ludovic Marie 05114a3673 Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 05:14:03 +01:00
Ludovic MarieandClaude Opus 5.5 d322e068e2 horizon-bots: flood-chunks, one legal move a tick that never waits for chunks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:54:54 +01:00
Ludovic Marie df583d34af Merge pull request #4056 from DUzzL/fix/issue-4037-armadillo-campfire
🤖 Agent: merging DUzzL's campfire contact damage for mobs (tested in game on both single-player paths: armadillos, cows and sheep burn on lit campfires, Infested spawns silverfish). Thanks!
2026-10-30 04:49:27 +01:00
Ludovic Marie 737657443d Merge pull request #3712 from xFedeT/fix/villager-grows-up-brain
🤖 Agent: Ludovic's AI agent (Claude), triage. #3729 landed with mob_ai cases of its own. I merged main into your branch (no rebase). The only conflict was where the new cases go in `tools/test_mob_ai_parity.py`, and both sets are kept. The Rust merged clean, so the proof above still holds. Requeued at the merged head.
2026-10-30 04:44:09 +01:00
Ludovic Marie 101d3b0bec Merge pull request #3773 from xFedeT/fix/cat-goals
🤖 Agent: Ludovic's AI agent (Claude), proof. This PR was in a union of five mob PRs (#3712, #3721, #3729, #3735, #3773) merged into current main. `parity.py --changed origin/main`: 55 of 55 affected harnesses passed, `mob_ai` included. `horizon-client` lib tests: 726 passed. clippy `-D warnings` is clean. The only conflicts were the new cases in `tools/test_mob_ai_parity.py`, as resolved on your branch. Queued.
2026-10-30 04:32:06 +01:00
Ludovic Marie 0359138d94 Merge pull request #2698 from horizon-rust/cg20/fusseltv-worldgen-mobs
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Since sirrayi's review, I merged main into this branch twice; the last merge is 655b331b1. In the merges: - `VanillaTerrain` keeps main's world presets (2607d06f9). This branch only adds `region_random` and `spawn_generated`, and its tests call `for_dimension` without the preset argument. - Main's goat and frog code (`goat_init_memories`, the goat ram cooldown, the frog's long jump) used main's memory names, while this branch saves vanilla's ids. Both now use vanilla's: `long_jump_cooling_down` and `ram_cooldown_ticks`. - main's mooshroom kind test sits beside #2544's baby villager test. Tested on today's main: - With #3802, #4095 and #3300 together: 123 of the 125 harnesses this branch can reach pass, along with pr_ready and lan_e2e (mobs, spawned, worldspawn, drops). The 2 failures don't come from this branch: `dispenser` passes on main plus this branch alone, and `command_exec` failed because the vanilla server was under load. - On this head alone: `dispenser`, `finalize_spawn`, `natural_spawn` and `mob_ai` pass, and the 48 harnesses that cover goat.rs, frog.rs and finalize.rs pass 48/48. Queued for merge. When it merges, GitHub marks @FusselTV's #2544 and #2586 as merged.
2026-10-30 04:32:02 +01:00
Ludovic Marie b5ec91342e Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:32:00 +01:00
Ludovic Marie 6883b74274 Merge remote-tracking branch 'origin/main' into upd-3712
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:31:58 +01:00
Ludovic MarieandClaude Opus 5.5 d9e5234fa3 horizon-bots: abusive client behaviours (flood-move, flood-acks, flood-packets) to test a server's limits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:27:58 +01:00
d3grandoandClaude Opus 5.5 9aeb985305 lan_e2e eating: one loaf each, and read the bites once they settle
The eater held the use for 2.2 s with two loaves: the held use started
the second loaf and let go of it a few ticks in, around its second bite
(Consumable.shouldEmitParticlesAndSounds: every 4 ticks once 7 of 32 are
past). That bite reaches the watcher from the server, whose use runs on
its own ticks behind the eater's client, so read 10 ticks after the hold
the watcher could still be one short (9 of 10, only when the eater drew
that bite).

Each player now eats a single loaf, so the count is the loaf's 6 bites
plus the last bite and FoodProperties' sound, and both sides' counts
are read once neither has changed for 20 ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:33 +07:00
Ludovic MarieandClaude Opus 5.5 0ec337f17e Merge main into this branch (world data packs' advancements: a tree is shared only without pack advancements)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:17:22 +01:00
Ludovic Marie a93ffc2f36 Merge pull request #3847 from horizon-rust/perf/spawner-idle
🤖 Agent: verified by server-tick-1: a spawner asks for an alive player in range before the spawn level is built, as BaseSpawner.serverTick returns first thing without one (no draw, no change); natural_level 1.6 % → 0.5 % of the tick thread at 100 bots; verify green, clippy -p horizon-client clean (main's log_display and axolotl test failures are main's); merging once CI parity passes.
2026-10-30 04:16:53 +01:00
Ludovic Marie 6e0605ad33 Merge pull request #4161 from horizon-rust/fix/lan-e2e-gateway-exact
🤖 Agent: queuing the lan_e2e levels test fix (#4145): the End gateway's ExactTeleport key and closing the guest's credits; levels passes on main's binary, pr_ready green.
2026-10-30 03:54:35 +01:00
Ludovic Marie 3bd99f79ae Merge pull request #3300 from masa-masa-0314/fix/datapack-recipe-loot-tags
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. @masa-masa-0314, this PR is on the maintainer's priority list, so instead of asking you again I merged main into your branch myself. Your commits are not rewritten; it took three main merges today, the last one at ada669266. In the conflicts: - main's new code (cushions, crafted-recipe triggers, creative slots, item use) now reads the world's tables through your `self.items()`, `world_items!`, `world_stations!` and `world_recipes!`; - main's new tests pass `PackOverlay::NONE`; - `game/mod_render.rs` stays deleted, as main removed the wasm mod UI. Tested together with #3802, #4095 and #2698 on today's main: - 123 of the 125 vanilla harnesses this branch can reach pass. `dispenser` fails because of another PR in that set; it passes on main plus this PR alone. `command_exec`'s failure was the vanilla server under load; on this head it matches 902/902. - pr_ready passes. - lan_e2e passes (crafting, recipes, drops, mobs, spawned, cushions). Queued for merge. Thank you for the data-pack work!
2026-10-30 03:54:30 +01:00
Ludovic MarieandClaude Opus 5.5 655b331b1e Merge main into this branch (goat memory init, mooshroom kinds test)
Main's goat_init_memories, goat ram and frog jump code keep their brain
memories under main's names; this branch saves them under vanilla's ids.
Both now use vanilla's: long_jump_cooling_down and ram_cooldown_ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:53:15 +01:00
levzzz a46ce6898b Merge pull request #3729 from xFedeT/fix/constructor-path-maluses
Mobs: the path maluses vanilla's constructors set (fire, lava, powder snow, cocoa, trapdoors)
2026-10-30 07:50:58 +05:00
Ludovic Marie 8a7cd0deaf Merge pull request #4089 from horizon-rust/fix/command-exec-oracle-settle
🤖 Agent: fixes main's red command_exec (oracle timeout under load); passed fresh locally on current main.
2026-10-30 03:49:50 +01:00
Ludovic Marie 6269b7a21e Merge pull request #4136 from horizon-rust/fix/portal-overlay-cooldown
🤖 Agent: queuing the portal trip fixes (Discord: Nether portal overlay stuck after a dimension change; End portal leaving the player in the air): the overlay fades in the arrival portal, and the player stays on the loading screen until the exit is found. Tested in game on both paths; lan_e2e portals passes. pr_ready is green except dispenser, main's regression from #3959 fixed by #4120.
2026-10-30 03:46:07 +01:00
Ludovic Marie c5410785c0 Merge pull request #3748 from bunchy7s/fix/command-block-screen
🤖 Agent: Ludovic's AI agent (Claude), proof. This PR was in a union of 16 needs-proof PRs merged into main at 1dde830ba. Parity: 195 of 196 affected harnesses passed, none failed. clippy and rustfmt passed. `lan_e2e --check commands` passes on the server thread and `--in-frame`. The command block's edit screen refreshing from the server's data is covered there. Queued.
2026-10-30 03:42:24 +01:00
Ludovic MarieandClaude Opus 5.5 ada669266f Merge main into this branch (creative display slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:30:02 +01:00
Ludovic Marie b9c54ecd50 Merge pull request #4027 from horizon-rust/perf/dedicated-no-sprite-pixels
🤖 Agent: verified by server-tick-1. Stacked on #4009: a dedicated server keeps the block models and sprite layers but no texture pixels or animation frames, which only the renderer reads; −28 MB idle RSS on Linux. dedicated_server passes locally; merge after #4009.
2026-10-30 03:12:58 +01:00
Ludovic MarieandClaude Opus 5.5 24bea8540d Make lan_e2e levels' guest_end step match vanilla's End exit
The End gateway's exact flag is `ExactTeleport` in TheEndGatewayBlockEntity's
NBT; `exact_teleport` was ignored, so the gateway searched for a landing and
left the guest away from the spawn platform. The guest's first exit from the
End then rolls the credits (ServerPlayer.showEndCredits) and only closing
them takes it home, as the portals step already does for the host.

Fixes #4145

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:03:16 +01:00
Ludovic MarieandClaude Opus 5.5 f03c1771af Drop the old level's pending teleport when a portal trip starts
Its late acceptance set the player's position back to the old level's
coordinates, so a trip that started right after a teleport waited forever for
chunks around the exit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:45:25 +01:00
levzzz 25f9cd3a99 Merge pull request #3875 from xFedeT/fix/boat-paddle-input-latch
Boats: steer with the input latched on the previous tick
2026-10-30 06:44:08 +05:00
Ludovic MarieandClaude Opus 5.5 f257391453 Chunk workers: give freed memory back once every worker is idle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:42:50 +01:00
Ludovic MarieandClaude Opus 5.5 e497995b44 Chunk workers: free the generator's scratch buffers after 5 s with nothing to do
Each streaming worker keeps its generator's density scratch buffers and
lookup caches between jobs, megabytes per worker, for as long as the world
is open: about 51 MB on the Mac mini's dedicated server with nobody on. A
worker that has waited 5 s with an empty queue now frees them (and the
mesher's face cache); the next job makes them again. Results do not change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:42:50 +01:00
Ludovic MarieandClaude Opus 5.5 2c91574bbb Ignore a portal traveller's old moves until it lands
Without the teleport change_level used to send at once, moves the client had
sent from the old level were taken as positions in the new one, and the level
loaded its chunks there instead of around the exit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:37:29 +01:00
levzzzandClaude Opus 5.5 f0ac52bf7f Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:35:03 +05:00
levzzz 1f3ccb0fc3 Merge pull request #3747 from xFedeT/fix/break-effects-for-popped-blocks
Break effects: a block that pops off when its support is mined plays its sound and particles for the miner
2026-10-30 06:30:23 +05:00
Ludovic Marie fcbb0d627a Merge pull request #4120 from horizon-rust/fix/golem-base-any-head
🤖 Agent: fixes main's red dispenser (golem base check from #3959); pr_ready green, 33 harnesses pass.
2026-10-30 02:28:55 +01:00
levzzz 30666b33b6 Merge pull request #3655 from jaszkus/feat/debug-stick
Add the Debug Stick
2026-10-30 06:24:29 +05:00
levzzz ada67586d0 Merge pull request #3642 from iLostName/off-hand-waits-for-main-hand
off hand: filling a composter or using bone meal ends the click before the off-hand torch
2026-10-30 06:24:12 +05:00
levzzzandClaude Opus 5.5 6ca9531468 Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:22:44 +05:00
levzzzandClaude Opus 5.5 972719f088 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:22:23 +05:00
levzzz b548c70594 Merge pull request #4018 from xFedeT/fix/wind-burst-smash
Mace smash on another player: Wind Burst launches the attacker, the fall stops
2026-10-30 06:18:40 +05:00
Ludovic Marie 4fef8a8682 Merge pull request #3921 from horizon-rust/feat/parity-headline
🤖 Agent: merging the parity headline (#3859 step 5): verify green, gap tests pass, the site built against it shows the paragraph without markers.
2026-10-30 02:18:15 +01:00
levzzzandClaude Sonnet 5.5 a80678b890 Merge main into fix/split-mob-sounds-behaviour (resolve game/creatures.rs, game/potions.rs, mobs/leash.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:16:25 +05:00
levzzz be8a82a40b Merge pull request #3955 from kacpherek/fix/split-animals
Animals behave like vanilla: turtles, goats, foxes, bees, axolotls, camels and horses
2026-10-30 06:13:52 +05:00
levzzz e54e8003e5 Merge pull request #3756 from horizon-rust/fix/server-brand-horizon
Server brand: Horizon's server names itself "horizon"
2026-10-30 06:13:35 +05:00
levzzzandClaude Sonnet 5.5 bc31cef0a7 Merge main into feat/debug-stick (no conflicts)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:08:52 +05:00
levzzzandClaude Sonnet 5.5 2b2a8bedd4 Merge main into off-hand-waits-for-main-hand (no conflicts)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:08:11 +05:00
levzzzandClaude Sonnet 5.5 6db70a1ae4 Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:50 +05:00
levzzzandClaude Sonnet 5.5 0d5cd6a591 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:30 +05:00
levzzzandClaude Sonnet 5.5 6d67bf43bb Merge main into fix/wind-burst-smash (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:11 +05:00
levzzz 6d59158973 Merge pull request #3643 from iLostName/crossbow-near-tnt-minecart
minecart: right-clicking a tnt minecart uses the held item, so a crossbow shoots at it
2026-10-30 06:06:30 +05:00
levzzz 647050d7aa Merge pull request #3953 from kacpherek/fix/split-screens
Menus, tooltips, world opening and chat now behave like vanilla
2026-10-30 06:05:42 +05:00
levzzz 1c333aa336 Merge pull request #3944 from kacpherek/fix/split-sulfur-glowsquid-water
Sulfur geysers and potent sulfur, glow squid dark ticks, and splash water bottles that douse fire
2026-10-30 06:05:24 +05:00
levzzz 41d772380a Merge pull request #3952 from kacpherek/fix/split-shelves-signs
Signs run the click commands in their text, as vanilla
2026-10-30 06:05:08 +05:00
levzzz df00ca8693 Merge pull request #3954 from kacpherek/fix/split-server
Fix server and LAN-host behaviour: spam kicks, straw beds, shulkers, ravagers, item frames, advancements and tab list
2026-10-30 06:04:51 +05:00
levzzz 589164c071 Merge pull request #3693 from Username59138/feat/creative-saved-hotbars
Creative inventory: the Saved Hotbars tab, as vanilla shows it
2026-10-30 06:04:34 +05:00
levzzz ed196ffd66 Merge pull request #4015 from d3grando/fix-3393-fallingblock
Falling sand and gravel ride bubble columns
2026-10-30 06:01:50 +05:00
levzzz 00480831ce Merge pull request #3995 from xFedeT/fix/fishing-hook-smooth
Fishing hook on a server's client moves between the tracker's updates, as FishingHook.tick does
2026-10-30 05:57:43 +05:00
levzzz 2fa595676e Merge pull request #3645 from iLostName/pillar-place-sound
block placement: a click on a block is handled before the move behind it, so pillar blocks are heard
2026-10-30 05:49:32 +05:00
levzzz d3338640bc Merge pull request #4002 from DUzzL/fix/issue-3997-guest-arrow-pickup
Arrows can be picked up by LAN guests and the single-player window (#3997)
2026-10-30 05:38:17 +05:00
Aria Figueredo ea10a17842 Expose native world lifecycle hooks 2026-10-29 19:36:15 -05:00
Ludovic Marie fcb14be442 Merge pull request #4100 from horizon-rust/fix/no-official-server-in-harness-runs
🤖 Agent: Ludovic's AI agent (Claude), pr-triage-3. Harness and horizon-ctl runs no longer add the official server to servers.dat in the working directory (the repo root) or send the players ping; before/after checked with a fixture run in a scratch folder. Queued.
2026-10-30 01:32:33 +01:00
levzzz f1c0ab982a Merge pull request #4040 from phexuss/fix/remote-look-at
Turn the player when a server sends PlayerLookAt
2026-10-30 05:32:12 +05:00
levzzz 173d00dadb Merge pull request #3668 from martipops/fix/damage-other-players
/damage hurts players other than the one running it
2026-10-30 05:31:56 +05:00
levzzz ea7e58787d Merge pull request #3675 from d3grando/fix-3236
Mooshrooms keep their colour: save Type, sync DATA_TYPE at 18
2026-10-30 05:31:39 +05:00
levzzzandClaude Sonnet 5.5 7ce7388582 Merge main into fix/split-animals (resolve crates/horizon-client/src/mobs/living.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:30:56 +05:00
levzzzandClaude Sonnet 5.5 6fea98f668 Merge main into fix/split-spear-ai-golem (resolve crates/horizon-client/src/mobs/goals.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:29:40 +05:00
levzzzandClaude Sonnet 5.5 3ee203767a Merge main into fix/own-head-pitch-follows-mouse (resolve crates/horizon-client/src/game/perspective.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:28:23 +05:00
levzzz a013e649c9 Merge pull request #4014 from DUzzL/fix/issue-4003-guest-trial-omen
Trial/Bad Omen of server players make trial spawners ominous
2026-10-30 05:28:08 +05:00
levzzzandClaude Sonnet 5.5 b78e407c12 Merge main into fix/split-agelock-goat-allay (resolve crates/horizon-client/src/mobs/ai.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:26:51 +05:00
levzzzandClaude Sonnet 5.5 9858aec68b Merge main into fix/split-piglin-hoglin (resolve crates/horizon-client/src/game/creatures.rs, mobs.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:25:06 +05:00
levzzzandClaude Sonnet 5.5 7852c700f1 Merge main into fix-3199 (resolve crates/horizon-client/src/mobs/ai.rs, camel.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:23:23 +05:00
Ludovic MarieandClaude Opus 5.5 14fad0786e Game: harness and horizon-ctl runs don't add the official server or ping
The official server's servers.dat entry and its marker go into the game
folder, which is the working directory, and the "N playing now" ping
starts at the same place. The guard skipped servers, benchmarks,
offscreen and frame-limited runs, but not a harness's fixture
(HORIZON_DISPENSE_FIXTURE, HORIZON_SCULK_FIXTURE, HORIZON_COMMAND_SCRIPT,
HORIZON_NET_REPORT) or a run horizon-ctl drives. Harnesses start those
in the source checkout, so a parity run left servers.dat and
horizon_official_server.txt in the repo root and counted itself as a
player. The guard now reuses synchronous_startup, which already names
every one of these runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:23:18 +01:00
levzzz 0dfa513424 Merge pull request #4043 from DUzzL/fix/issue-4031-fall-damage-sound
Fall damage plays its sound in multiplayer and for LAN guests
2026-10-30 05:22:21 +05:00
levzzzandClaude Sonnet 5.5 51c10dd31d Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:20:20 +05:00
levzzzandClaude Sonnet 5.5 0d9399ead9 Merge main into fix/constructor-path-maluses (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:20:03 +05:00
Ludovic MarieandClaude Opus 5.5 923f425f19 Advancements: load the tree once for the server's packets and the game's criteria
ServerAdvancements (the advancement packets a server sends) and the game
that runs the criteria each read and parsed every advancement file into a
tree of their own: 7 MB each, the same twice. Tree::shared keeps one per
data folder while anyone holds it, and both use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:20:01 +01:00
levzzzandClaude Sonnet 5.5 2be6cda974 Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:19:36 +05:00
levzzzandClaude Sonnet 5.5 9ad9d087ef Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:18:24 +05:00
levzzz 79593d80b6 Merge pull request #3659 from d3grando/fix-3091
Let parrots fly: flying navigation and move control, flapping and gliding
2026-10-30 05:18:18 +05:00
levzzzandClaude Sonnet 5.5 58853fdf50 Merge main into fix/wind-burst-smash (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:18:06 +05:00
levzzzandClaude Sonnet 5.5 834819ddf4 Merge main into fix/fishing-hook-smooth (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:17:45 +05:00
levzzzandClaude Sonnet 5.5 7835010339 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:17:22 +05:00
levzzz 590542c5ee Merge pull request #4004 from d3grando/fix-3006
Both mouse buttons at once replace the block in creative
2026-10-30 05:16:53 +05:00
Ludovic Marie 4cb754474d Merge pull request #3732 from xFedeT/fix/wither-skeleton-parity
🤖 Agent: Ludovic's AI agent (Claude), triage. Wither skeleton: its piglin target goal comes first among the priority 3 goals (vanilla's registerGoals order). levzzz5154's main merge touched only the mob_ai case list; the Rust change is the same as on the CI-parity-green head, and verify is green. Requeued.
2026-10-30 01:13:47 +01:00
Ludovic Marie 0855cc910f Merge pull request #3850 from Fauxvale/fix/far-open-water-light
🤖 Agent: Ludovic's AI agent (Claude), triage. Far terrain: no dark band in the sea where chunks were loaded: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:42 +01:00
Ludovic Marie 793337b0ed Merge pull request #3892 from matyden/telemetry/scrub-user-named-user
🤖 Agent: Ludovic's AI agent (Claude), triage. Telemetry: a user named "user" keeps the <user> label whole: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:38 +01:00
Ludovic Marie a603c95eba Merge pull request #3802 from ThatCakeIsAPie/fix/datapack-block-state-providers
🤖 Agent: Ludovic's AI agent (Claude), triage. Data packs: a pack's block state providers join the state_providers bundle: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:34 +01:00
Ludovic Marie 564b8a4b9e Merge pull request #3599 from doodlyyousuf/fix/pearl-hits-wind-charge
🤖 Agent: Ludovic's AI agent (Claude), triage. Let thrown pearls hit wind charges: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:30 +01:00
Ludovic MarieandClaude Opus 5.5 a16097dd96 Merge main into this branch (cushion placement checks, lan_bot sync test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:11:09 +01:00
Ludovic MarieandClaude Opus 5.5 4c802c1342 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:08:59 +01:00
Ludovic Marie 304a3ca76e Merge pull request #3371 from horizon-rust/draft/remove-wasm-mods-docs
🤖 Agent: WebAssembly removal 4/4 (docs), on #3353, merged up to date with main (roadmap: main's, with the native-mod wording reapplied; docs-only on top of the union proven in proven.txt). Merge after #3353; horizon-site#6 follows.
2026-10-30 01:08:50 +01:00
levzzz d163f38b6a Merge pull request #4005 from d3grando/fix-3117
Cushions swing the arm when placed and let a seated player change seats
2026-10-30 05:08:20 +05:00
levzzz e2fa940581 Merge pull request #3607 from degepie/fix/issue-3024-worn-armor-glint
Armour: worn pieces and the elytra show the enchantment glint (#3024)
2026-10-30 05:06:49 +05:00
Ludovic MarieandClaude Opus 5.5 a32ab2c5e4 Merge main into this branch (world presets, worldgen data packs, noise clamp)
Main now builds a world's preset itself (2607d06f9): VanillaTerrain keeps
main's for_world/for_world_with signatures, this branch adds region_random
and spawn_generated beside them, and its tests call for_dimension without
the preset argument.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:06:35 +01:00
Ludovic Marie 03b464ec0e Merge pull request #3902 from DUzzL/fix/issue-3882-defer-dataversion-stamp
🤖 Agent: Ludovic's AI agent (Claude), triage. Stamp DataVersion only after the world data is upgraded: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:06:35 +01:00
Ludovic Marie 186095dd37 Merge pull request #3908 from DUzzL/fix/issue-3883-camel-pose-tick
🤖 Agent: Ludovic's AI agent (Claude), triage. Fix camel freeze after spawn and LastPoseTick saved as a sign: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:06:30 +01:00
levzzz a4f0482bd9 Merge pull request #3792 from GIGABAIT93/fix/npc-skins
Player entities keep the skin they found: NPCs show theirs
2026-10-30 05:06:24 +05:00
Ludovic MarieandClaude Opus 5.5 2ba6caa289 Golems: a pumpkin's base check takes any block at the head
canSpawnGolem (a dispenser's carved pumpkin) and the setBlock that places a
head ask before the head is there, with vanilla's base patterns (" ", "#",
"#") and ("~ ~", "###", "~#~"), whose head cell takes any block. #3959's
find_pattern required the head there too, so a dispenser no longer built a
snow or iron golem: dispenser parity failed on main (carved_pumpkin, 1 of
6000 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:06:05 +01:00
Federico Tagliani 5f5d0434cb Merge remote-tracking branch 'origin/main' into fix/cat-goals 2026-10-30 01:05:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 11159a1ed5 Merge fork/fix/cat-goals (maintainer's merge of main)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:05:13 +01:00
levzzz bf8d58facd Merge pull request #4045 from d3grando/fix-3684-hitbox
A small player's server box follows minecraft:scale
2026-10-30 05:04:26 +05:00
Ludovic MarieandClaude Opus 5.5 a8e3cebc5f Merge main into this branch (wasm mod UI removed, item use slows sprint, crafted-recipe triggers, world presets)
Main's new code reads the world's tables (self.items(), world_stations!,
world_recipes!); main's new tests pass PackOverlay::NONE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:03:05 +01:00
Ludovic MarieandClaude Opus 5.5 30fcf2c30c command_exec oracle: wait for entities by the clock, not 400 ticks
Vanilla loads entity sections on other threads, so on a loaded runner
400 ticks (~2 s) ran out before the chunks around the player were
entity-ticking: the oracle threw "entities around the player did not
load" and main's parity has failed on command_exec since 2026-10-29 22:05.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:01:05 +01:00
levzzz 9d72c1ddcd Merge pull request #3716 from xFedeT/fix/merchant-closes-out-of-range
Villagers: the trade screen closes when the trader is gone or out of reach
2026-10-30 05:00:59 +05:00
levzzz 62caef810a Merge pull request #3934 from DUzzL/fix/issue-3912-turtle-ambient
Turtle: silent in water, in the air and as a baby
2026-10-30 05:00:41 +05:00
Federico TaglianiandClaude Sonnet 5.5 1c16f98098 Merge origin/main into fix/cat-goals: the sprint now comes from main
main's feline_server_ai_step sets the cat's sprint and Mob::movement_speed
adds the +30% for it, so the cat arm of custom_server_ai_step, the sprint dust
draws and the speed_bonus arm (a second +30%) are dropped; the avoidance goal
order and the CatAvoidEntityGoal probe name stay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:00:39 +01:00
levzzz 69d2086e7d Merge pull request #3623 from degepie/fix/issue-3026-fish-schools
Fish form schools: port FollowFlockLeaderGoal (#3026)
2026-10-30 05:00:16 +05:00
levzzz 0829657bcc Merge pull request #3868 from spidercraft/feat/lan-online-mode
LAN and friends worlds authenticate guests when the host is signed in: their skins and capes show
2026-10-30 04:59:38 +05:00
Ludovic MarieandClaude Opus 5.5 88e79c1119 Keep a portal traveller on the loading screen until its exit is found
Vanilla finds a portal's exit (the End platform, the Nether portal, the spawn
column after the End) before ServerPlayer.teleport sends the Respawn packet.
Horizon finds it once the destination has loaded, and meanwhile showed the
client at the approximate point: in the air above the End's void or at the
raw world spawn. The client now gets its position, LEVEL_CHUNKS_LOAD_START
and the portal sound only when the trip lands, so it stays on its level
loading screen as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:58:15 +01:00
Ludovic Marie 1dde830baf Merge pull request #3353 from horizon-rust/draft/remove-wasm-mods-launcher
🤖 Agent: WebAssembly removal 3/4 (launcher: a WebAssembly mod needs a native port), on #3351; proven with #3351 and #3371 on union/wasm-removal (proven.txt). Launcher clippy and its 275 tests pass. Merge after #3351.
2026-10-30 00:56:58 +01:00
Ludovic Marie 588ca0cd22 Merge pull request #3152 from senjinthedragon/hdr-screenshot
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into your branch (a fast-forward merge commit, 0e4f8e279; none of your commits rewritten). Since this PR was opened, main changed the HDR parameters (black level, UI brightness, tiers: 20 floats) and screenshots (`ScreenshotRequest`, world icons). In the merge: - `Hdr::params` fills main's layout for both the swapchain and your HDR10 capture; - only full-frame screenshots get the `-hdr.png` copy, not world icons; - the docs sentence moved into main's HDR paragraph. Tested on this head merged into main: - The harnesses it can reach pass (59/60). The one failure, `command_exec`'s `/data get` `custom_name`, comes from main. - fmt, clippy and the render tests pass. - On macOS (HDR10 output), a screenshot wrote both PNGs: the HDR one is 16-bit RGB with cICP 9/16/0/1 and cLLI 1000/203. Queued for merge.
2026-10-30 00:56:54 +01:00
Ludovic Marie 4488cda84f Merge pull request #3808 from doodlyyousuf/fix/release-notes-canonical-repo
🤖 Agent: Ludovic's AI agent (Claude), triage. Tooling only: release notes always link horizon-rust/horizon, even when a fork's CI builds them. verify is green. Queued.
2026-10-30 00:56:51 +01:00
Ludovic MarieandClaude Opus 5.5 2fd01bd8bd Merge main (0208c75ef) into the docs layer
The roadmap: main's (Now at most 8 items) with this layer's native-mod
wording reapplied; the WebAssembly runtime is removed, not being removed.
docs/README.md: main's data pack upgrade line kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:55:10 +01:00
levzzzandClaude Sonnet 5.5 ba5585da84 Merge main into fix/npc-skins (resolve crates/horizon-client/src/game/remote.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:53:10 +05:00
Ludovic Marie d409f691dc Merge remote-tracking branch 'origin/main' into upd-3642
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 00:52:43 +01:00
levzzzandClaude Sonnet 5.5 cc683c75f0 Merge main into fix/issue-3024-worn-armor-glint (resolve crates/horizon-client/src/game/perspective.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:51:24 +05:00
Ludovic Marie 57e49b068e Merge remote-tracking branch 'origin/main' into upd-3655
# Conflicts:
#	tests/test_remote_mining.py
2026-10-30 00:50:35 +01:00
d3grandoandClaude Opus 5.5 89f40134b2 Merge origin/main into fix-3199
mobs/ai.rs: this branch's same_vehicle_push_tests and main's tests appended after it are both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:50:11 +07:00
levzzzandClaude Sonnet 5.5 490a33aea9 Merge main into fix/issue-3912-turtle-ambient (resolve crates/horizon-client/src/mobs/ai.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:48:40 +05:00
Ludovic MarieandClaude Opus 5.5 7cce0bf15b Keep the portal cooldown on a server's client so the overlay fades in the arrival portal
handleRespawn gives the new LocalPlayer the server's portalCooldown, and the
client's setAsInsidePortal only restarts it while the player stands in the
arrival portal. Horizon sent 0 and ignored it, so the purple overlay built up
again and stayed on screen after a dimension change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:48:17 +01:00
Ludovic MarieandClaude Opus 5.5 a536b00c30 Worldgen: bind the feature block tags once, shared by decoration, structures and live features
Decoration, structure placement and live features each built their own
FeatureBlockBindings, binding every block and every block tag to its state
set: 5.5 MB each, the same three times. GeneratorData now binds them once
(feature_block_bindings) and the bindings share the maps through Arcs. Each
decorated region also cloned the whole tag map; it now shares it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:48:07 +01:00
levzzzandClaude Sonnet 5.5 4289f22f00 Merge main into fix/merchant-closes-out-of-range (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:46:19 +05:00
levzzzandClaude Sonnet 5.5 e4d8570fbc Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:42:41 +05:00
levzzzandClaude Sonnet 5.5 4e3a161ac6 Merge main into fix/constructor-path-maluses (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:41:45 +05:00
levzzzandClaude Sonnet 5.5 6cd8cc4987 Merge main into fix/wither-skeleton-parity (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:41:08 +05:00
levzzzandClaude Sonnet 5.5 30da32b83c Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:40:41 +05:00
levzzzandClaude Sonnet 5.5 173e278206 Merge main into fix/flyers-scan (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:40:15 +05:00
Ludovic MarieandClaude Opus 5.5 31ad980443 Merge main: main's new tab test builds a Tab with hotbar false
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:33:49 +01:00
DUzzL 084394a849 Keep campfire damage from making mobs panic 2026-10-30 00:32:02 +01:00
Ludovic Marie e8d5059b6a Merge remote-tracking branch 'origin/main' into upd-3693 2026-10-30 00:31:02 +01:00
Ludovic MarieandClaude Opus 5.5 23c5335910 Block entity rendering: reuse the meshes' face visibility instead of loading it again
BlockEntityRenderer loaded occlusion.nbt and the render definitions a second
time and built its own FaceVisibility, whose shapes then computed their faces
again: 13.6 MB of face shapes, twice. It now takes a clone of the mesh
tables' visibility, which shares the shapes and their cached faces.
Probes and tests that load the renderer alone still load their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:18:41 +01:00
Ludovic MarieandClaude Opus 5.5 ae0512e299 Merge main: the composter's useItemOn result is an InteractionResult
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:17:37 +01:00
Ludovic Marie 8c3d85eb58 Merge remote-tracking branch 'origin/main' into upd-3642 2026-10-30 00:16:56 +01:00
Ludovic Marie 4592a7e799 Merge remote-tracking branch 'origin/main' into upd-3655
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 00:14:42 +01:00
Ludovic Marie 5dc59b0f6a Merge remote-tracking branch 'origin/main' into upd-3748
# Conflicts:
#	crates/horizon-client/src/game/command_blocks.rs
2026-10-30 00:11:18 +01:00
DUzzL e93427276f Hurt mobs standing in a lit campfire (#4037)
CampfireBlock.entityInside hurts any living entity in a lit campfire for 1 (2 for soul campfires) with the campfire damage type, which is fire, makes mobs panic and does not knock back. Mobs ignored campfires entirely.
2026-10-30 00:11:15 +01:00
d3grandoandClaude Opus 5.5 70e83d9f65 Don't assert that a zombie pushes the ridden camel
Entity.push(Entity) never moves an entity that isVehicle, so in vanilla
a camel carrying a husk is not pushed by a zombie either. The test now
checks only that the camel still pushes the zombie.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:04:11 +07:00
DUzzL 8a773bca20 Play the fall damage sound for multiplayer clients and LAN guests 2026-10-30 00:00:06 +01:00
Ludovic MarieandClaude Opus 5.5 0e4f8e2799 Merge main into hdr-screenshot (HDR tiers, black level, UI brightness, world icons)
Hdr::params now fills main's 20-float layout for both the swapchain and the
HDR10 capture; the capture keeps its own pipeline and parameters. Screenshots
use main's ScreenshotRequest and copy_frame; only full-frame requests (not
world icons) get the -hdr.png copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:52:47 +01:00
Ludovic Marie 4464c5d64a Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:44 +01:00
Ludovic Marie b0c2f375bb Merge remote-tracking branch 'origin/main' into upd-3729
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:42 +01:00
Ludovic Marie c41e95bf53 Merge remote-tracking branch 'origin/main' into upd-3712
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:37 +01:00
Ludovic Marie 3c8a73f86f Merge remote-tracking branch 'origin/main' into upd-3773
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:15 +01:00
Ludovic Marie f58660a875 Merge remote-tracking branch 'origin/main' into upd-3732
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:12 +01:00
d3grandoandClaude Opus 5.5 c744e51084 Keep same-vehicle mobs in the cramming count, and test the camel fix
Entity.push returns at once for isPassengerOfSameVehicle, but
EntitySelector.pushableBy does not drop such a mob: it still counts for
cramming in LivingEntity.pushEntities, and Warden.doPush still runs for
it before Entity.push. push_entities now skips only the push itself.

A unit test for each half of #3199 (a camel and its husk never push each
other; RandomSitting never starts with a husk on its back or with a
lead, and does with nothing or a slime there), with a shared test world
builder in mobs/world.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:44:16 +07:00
d3grandoandClaude Opus 5.5 7f1edb9263 A camel and the husk on its back no longer push each other
LivingEntity.pushEntities pushed every pushable mob overlapping the box,
the rider and its own vehicle included. A husk sits half a block toward
the camel's front (Camel.getPassengerAttachmentPoint), inside the camel's
box, so each tick it pushed the camel backwards and the camel pushed
back: the camel slid off tail first and kept speeding up. Entity.push
skips passengers of the same vehicle (isPassengerOfSameVehicle: the same
root vehicle); pushEntities now does too, through the vehicle every Seen
mob now carries.

CamelAi.RandomSitting also checked only for a player on the camel's back.
Vanilla needs no controlling passenger at all (hasControllingPassenger),
and a husk is one (Mob.getControllingPassenger: a mob rider outside
#non_controlling_rider), so a camel carrying a husk sat down at night with
the husk floating above it. The goal now checks the mob rider, and the
leash vanilla's checkExtraStartConditions also checks.

Fixes #3199

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:44:16 +07:00
Claude 396ba2e5e7 Turn the player when a server sends PlayerLookAt
ClientboundPlayerLookAtPacket was decoded as an unknown packet, so
`/teleport ... facing` and `/rotate ... facing` on a server never turned
the camera. Decode it and port ClientPacketListener.handleLookAt:
Entity.lookAt from the player's feet or eyes towards the packet's entity
(its feet or eyes) when the client tracks it, else towards the point.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-29 22:42:00 +00:00
Ludovic MarieandClaude Opus 5.5 10e715be15 Dedicated server: no block sprite pixels, which only the renderer reads
A dedicated server loads the block models and the sprites' layers, as before,
but no longer lays out the texture array's pixels or the animations' frames:
nothing draws them. The integrated server shares the window's resources and
is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:36:59 +01:00
d3grandoandClaude Opus 5.5 5b3b108515 A small player's server box follows minecraft:scale
The server's copy of a player never ran LivingEntity.refreshDirtyAttributes,
so after /attribute ... minecraft:scale it kept the old 0.6 x 1.8 box until
the pose changed: a small player was sent back from every wall they walked
along and suffocated next to one. guest_server_tick now runs aiStep's spin
countdown, refreshDirtyAttributes (onAttributeUpdated(SCALE) ->
refreshDimensions) and updatePlayerPose in vanilla's order, and in_wall
takes the eye box from the body's width (Entity.isInWall, width * 0.8)
instead of a fixed 0.48.

Part of #3684.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:34:25 +07:00
Ludovic MarieandClaude Opus 5.5 a72876f0c0 dedicated_server harness: Horizon's server brand is "horizon"
The one intended difference from vanilla's server (MinecraftServer.getServerModName):
the harness checks Horizon sends brand=horizon, then compares the rest as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:22:02 +01:00
Federico Tagliani 39314175e9 Merge remote-tracking branch 'origin/main' into fix/wind-burst-smash 2026-10-29 23:21:58 +01:00
Federico TaglianiandClaude Sonnet 5.5 88b9342ffc Mace smash on another player: the attacker's side of hurtEnemy and the Wind Burst launch
Player.itemAttackInteraction ran only for a mob victim, so smashing a
player never stopped the fall, never fired the Wind Burst explosion on the
attacker and never wore the weapon.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 23:21:57 +01:00
DUzzL 21a033cea0 Trial Omen and Bad Omen of a server's players make trial spawners ominous 2026-10-29 23:21:28 +01:00
d3grandoandClaude Opus 5.5 a18c9206d9 Falling sand and gravel ride bubble columns
FallingBlockEntity.tick runs Entity.applyEffectsFromBlocks after its move,
and BubbleColumnBlock.entityInside lifts or drags it like any other entity
(Entity.handleOnAboveBubbleColumn / handleOnInsideBubbleColumn). Horizon's
fall_step only looked for cobwebs on the path, so sand dropped into a soul
sand column sank to the bottom.

The cobweb sweep now visits each block checkInsideBlocks reaches once, with
vanilla's intersects flag (every block of a move a block or longer, else
only those the box ends in), and a bubble column there pushes the block and
resets its fall below the surface. The bubble column lookup and push are
split out of bubble_column_push so items and mobs share them. Velocity is
zeroed on collided axes before the effects, as Entity.move does.

Part of #3393

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:12:39 +07:00
DUzzL ad9f9a66f6 LAN guests and single-player windows pick up arrows they touch (#3997) 2026-10-29 23:11:26 +01:00
Ludovic MarieandClaude Opus 5.5 ba59ac2ccf Dedicated server: build only the font atlas, as the integrated server does
A dedicated server draws nothing, yet it built the full HUD atlas (GUI,
particles, maps, skins) and the title panorama. The integrated server's
game already holds only the font, which books and chat measure with, and a
one-pixel panorama; both now come from UiResources::server, and the
dedicated server's synchronous load uses it too.

Idle, fresh world, 3 interleaved runs: RSS 836/836/841 -> 771/792/765 MB,
ready 5.05 -> 4.32 s (median).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:06:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 7ca3671df3 tests: the remote_motion fixture knows the hook motion method
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 23:02:36 +01:00
d3grandoandClaude Opus 5.5 0ce65d34c6 Both mouse buttons at once replace the block in creative
Minecraft.tick picks the crosshair's hitResult once, before
handleKeybinds, and startAttack, startUseItem, continueAttack and
pickBlockOrEntity all read that one result. Horizon re-picked for every
click, so after a creative START predicted the break, the use in the
same tick aimed past the broken block (at nothing, or the block behind).
InteractionInput::tick now pins the tick's hit result around the clicks.

The server also deferred a creative START to its guest tick, after the
UseItemOn that followed it in the same batch, which then placed against
the old block before it broke. ServerPlayerGameMode.handleBlockBreakAction
destroys at once for an instabuild player (destroyAndAck "creative
destroy"); the host now does too.

Fixes #3006

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:58:07 +07:00
Federico Tagliani 379ec36d52 Merge remote-tracking branch 'origin/main' into fix/fishing-hook-smooth 2026-10-29 22:55:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 7f70737138 Fishing hook on a server's client: its own movement between the tracker's updates
FishingHook.tick runs on a client, easing towards the last update while it
flies, floats and rides what it hooked. Horizon only eased towards the
updates, which come every five ticks, so the bobber stood still for two
ticks of five and jumped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 22:55:58 +01:00
d3grandoandClaude Opus 5.5 b80c1a9eef lan_e2e: cushions check covers the placing swing and changing seats
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:41:45 +07:00
d3grandoandClaude Opus 5.5 682f8a20fa Cushions swing the arm when placed and let a seated player change seats
CushionItem.useOn returns SUCCESS, so the hand swings: place_cushion now
swings, and a client of a server predicts the same SUCCESS once
Cushion.canBePlacedAt holds (the placement checks are split out as
cushion_spawn for both). Cushion.interact sits the player down through
Entity.startRiding, which first stops riding the current vehicle, so a
player on one cushion moves to another instead of being refused.
LivingEntity.stopRiding off a cushion now plays the get-up sound and
puts the rider beside it (shared with getting up on sneak).

Fixes #3117

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:41:45 +07:00
Kacpherek c1de70edd4 Keep a bred baby fox or goat free of the spawn finalize extras
Breeding spawns the baby through spawning::finalize, which now gives a fox a mouth item and a goat a lost horn and a random screaming roll; Fox.getBreedOffspring and Goat.getBreedOffspring run no finalizeSpawn (Fox.java, Goat.java). The baby fox is emptied, the baby goat keeps both horns and gets a parent's screaming or one chance in fifty.
2026-10-29 22:19:35 +01:00
Kacpherek 149c4b87f8 Reopen the chat with its kept draft
T and / always opened the chat with the caller's text. ChatComponent.createScreen (ChatComponent.java:442) restores the saved draft, and ChatScreen.removed (ChatScreen.java) keeps it on Escape when Save Chat Drafts is on.
2026-10-29 22:19:26 +01:00
Kacpherek a6b3066f85 Kick guests who spam chat or commands
A dedicated server never kicked a spamming guest: chat-spam-threshold-seconds and command-spam-threshold-seconds were read but unused. Vanilla adds 20 per message or command, decays one a tick and disconnects over the threshold with disconnect.spam, except for operators (ServerGamePacketListenerImpl.detectRateSpam, TickThrottler.java:23).
2026-10-29 22:19:23 +01:00
Kacpherek 08918a8ab0 Load a world's own resource pack when the world opens
A save's resourcepacks/resources.zip was never loaded, so its textures were missing. Vanilla WorldOpenFlows.loadBundledResourcePack pushes it as a local pack (ServerPackManager.pushLocalPack, ServerPackManager.java:61) before the world connects.
2026-10-29 22:19:23 +01:00
Kacpherek fe08d02d5d Hold player chat back while Chat Delay is set
Player and disguised chat showed at once, so Chat Delay was ignored. ChatListener.handleMessage, tick and flushQueue (ChatListener.java:75-120) queue the lines and show one per delay.
2026-10-29 22:19:23 +01:00
Kacpherek 359e83b338 Step option cycle buttons with the mouse wheel
The wheel over an option button only scrolled the list. CycleButton.mouseScrolled (CycleButton.java:115) cycles its value, up going back, and the list does not move.
2026-10-29 22:19:19 +01:00
Kacpherek 8b17c0383e Make foxes from snowy biomes snow foxes on natural spawns
Natural and spawner foxes were always red; vanilla Fox.Variant.byBiome picks the snow variant from spawns_snow_foxes (Fox.java, Fox.Variant).
2026-10-29 22:19:10 +01:00
Kacpherek 407dd201f8 Swap another player's held items when it swaps hands
Entity event 55 was ignored, so another player's main and off hand kept their items after a swap on the server; vanilla LivingEntity.handleEntityEvent calls swapHandItems (LivingEntity.java).
2026-10-29 22:19:09 +01:00
Kacpherek 889931c0b9 Let natural foxes hold food or a feather in their mouth
Only structure mobs ran Fox.populateDefaultEquipmentSlots, so a fox from a natural or spawner spawn never held an emerald, egg, rabbit foot or the like (Fox.java).
2026-10-29 22:19:09 +01:00
Kacpherek 509536179d Give natural goats their screaming, lost horn and ram cooldowns
Only structure mobs and the probe ran Goat.finalizeSpawn, so a goat from a natural or spawner spawn never screamed, lost a horn or had its ram cooldowns (Goat.java, GoatAi.initMemories).
2026-10-29 22:19:09 +01:00
Kacpherek a9d2ae39d9 Call only the pets of the same owner when a tamed mob is hurt
HurtByTargetGoal.alertOthers called every nearby wolf, including other owners' and the attacker's own tamed pets; vanilla checks TamableAnimal.getOwner and considersEntityAsAlly (HurtByTargetGoal.java, TamableAnimal.java).
2026-10-29 22:19:04 +01:00
Kacpherek 9d44aa4ba0 Stop a tamed unsaddled skeleton horse from taking steering from its rider
A tamed skeleton horse obeyed its rider without a saddle; AbstractHorse.getControllingPassenger (AbstractHorse.java) needs the saddle, and SkeletonTrapGoal only tames the trap horse.
2026-10-29 22:19:04 +01:00
Kacpherek 0c34e3f36c Refill axolotls' air from a splashed water potion
A splashed water bottle did nothing to axolotls; AbstractThrownPotion.affectEntitiesAround (AbstractThrownPotion.java) rehydrates each axolotl in the box, adding 1800 air up to the maximum.
2026-10-29 22:19:03 +01:00
Kacpherek 4049220715 Make a horse thud when it lands instead of the generic fall sound
A landing horse played the generic fall sound and no thud; AbstractHorse.causeFallDamage (AbstractHorse.java) plays entity.horse.land (baby variant for babies) at 0.4 for falls over one block.
2026-10-29 22:19:03 +01:00
Kacpherek 1139057d3e Play the camel saddle sound when a camel is saddled by hand
Saddling a camel with a saddle in hand played the horse sound; Camel.getSaddleSound (Camel.java) is the camel's saddle equip sound.
2026-10-29 22:19:03 +01:00
Kacpherek 9f3cba308c Send a flap game event from a flying bee every other tick
A bee in the air never gave off its flap vibration, so sculk sensors never heard it; Entity.processFlappingMovement with Bee.isFlapping (Bee.java) emits minecraft:flap.
2026-10-29 22:19:03 +01:00
Kacpherek 80b68bae0c Hold a sitting camel's rotation under its rider
A camel that refuses to move (sitting or changing pose) still turned with its rider's view; Camel.getRiddenRotation (Camel.java) keeps its rotation.
2026-10-29 22:19:03 +01:00
Kacpherek 48efd53e00 Breed axolotl babies with a random parent's colour and a rare blue
Axolotl.getBreedOffspring took the first parent's colour, never blue and never persistent; vanilla rolls one in 1200 for blue, else picks either parent and sets the baby persistent (Axolotl.java).
2026-10-29 22:19:03 +01:00
Kacpherek c7440b78d5 Hang a bee's lead at half its eye height
The lead hung at the default leash offset; Bee.getLeashOffset (Bee.java) puts it at half the eye height, a fifth of the width ahead.
2026-10-29 22:19:03 +01:00
Kacpherek 0703ae6bd4 Keep a mannequin's profile when it is saved and loaded
A saved mannequin lost its profile, so after a reload it wore the default skin. Vanilla saves and reads it as ResolvableProfile (Mannequin.addAdditionalSaveData / readAdditionalSaveData, Mannequin.java:126 and :144).
2026-10-29 22:19:02 +01:00
Kacpherek ec6ea46499 Break item frames caught in an explosion
A blast never reached item frames, so they stayed up and kept their item. Vanilla kills a non-fixed frame in reach and drops it with its item (BlockAttachedEntity.hurtServer, ServerExplosion.hurtEntities; ItemFrame.java:173).
2026-10-29 22:19:02 +01:00
Kacpherek 62c4d91aa5 Send the hat layer change to every player's tab list
A guest who toggled the hat skin layer was not announced: vanilla sends UPDATE_HAT to all players in handleClientInformation (ServerGamePacketListenerImpl.java:2180), and the join entry now carries the hat state too.
2026-10-29 22:19:02 +01:00
Kacpherek d5d42c8a5f Let ravagers trample crops they walk through
Ravagers only broke leaves, so crops in their path survived; CropBlock.entityInside (CropBlock.java:153) destroys a crop a ravager's box enters when mobGriefing is on.
2026-10-29 22:19:02 +01:00
Kacpherek 765ed5d0c2 Fire the thrown-item pickup advancement for the player
Picking up an item the player threw never granted thrown_item_picked_up_by_player, so You've Got a Friend in Me was unreachable; ServerPlayer.onItemPickup (ServerPlayer.java:2324) fires it with the stack left after the take.
2026-10-29 22:19:02 +01:00
Kacpherek a63b69c300 Fire thrown-item pickup advancement for piglins
A piglin taking gold you threw never granted thrown_item_picked_up_by_entity, so Oh Shiny stayed locked; LivingEntity.onItemPickup (LivingEntity.java:3429) fires it for the taker, before the stack is split.
2026-10-29 22:19:02 +01:00
Kacpherek 126f997703 Make live shulkers solid for the player
Walking ignored live shulkers, so the player passed through them; Shulker.canBeCollidedWith (Shulker.java:487) returns isAlive and Entity.canCollideWith uses it.
2026-10-29 22:19:02 +01:00
Kacpherek 6befa8622d Scale the LAN host's entity tracking by the Entity Distance option
The integrated host ignored entityDistanceScaling, so guests saw entities at the default range; IntegratedServer.getScaledTrackingDistance (IntegratedServer.java:451) scales each tracking range by it.
2026-10-29 22:19:02 +01:00
Kacpherek 4fe17c268c Show spectating guests as invisible to others
A guest in spectator mode sent no invisible flag, so others saw a full model; ServerPlayer.updateInvisibilityStatus (ServerPlayer.java:2054) sets it for every spectator.
2026-10-29 22:19:02 +01:00
Kacpherek 1474b118b4 Break a straw bed when its sleeper gets up in the Overworld
A straw bed stayed in place after its sleeper woke up, but the Overworld's straw_bed_rule has destroy_on_leave, so vanilla breaks it (AbstractBedBlock.java:119 onStopSleeping, StrawBedBlock.java destroyBed).
2026-10-29 22:18:57 +01:00
Kacpherek 4fcaff9935 Give a water bucket back for a tropical fish bucket fed to an axolotl
Feeding an axolotl a tropical fish bucket used the whole item; vanilla hands back a water bucket (Axolotl.usePlayerItem in Axolotl.java).
2026-10-29 22:18:54 +01:00
Kacpherek 0554db3906 Calm neutral mobs when the player who angered them dies
With forgive_dead_players on, wolves, bees, polar bears and other neutral mobs kept their anger after the player died. ServerPlayer.tellNeutralMobsThatIDied (ServerPlayer.java:991) makes mobs within 32 by 10 blocks run NeutralMob.playerDied, which stops the anger at that player (NeutralMob.java:122).
2026-10-29 22:18:54 +01:00
Kacpherek e94f1917a7 Give bred goats their cooldowns and screaming inheritance
A bred baby goat started without ram or long jump cooldowns and was never screaming. Goat.getBreedOffspring (Goat.java:152) runs GoatAi.initMemories, then takes screaming from a random parent or rolls 2 percent.
2026-10-29 22:18:54 +01:00
Kacpherek 713b2774aa Swim turtles with their own water travel
Turtles used the generic fluid travel in water (0.02 speed, 0.8 drag). Turtle.travelInWater (Turtle.java:266) moves at 0.1 with 0.9 drag and sinks slowly unless it has a target or is going home near its home.
2026-10-29 22:18:54 +01:00
Kacpherek 882e3a3005 Run the click event of text in dialog messages
Clicking styled text in a dialog message or item description did nothing. DialogBodyHandlers.java:54 and :108 run the text's click event through DialogScreen.runAction.
2026-10-29 22:18:51 +01:00
Kacpherek d684cafca1 Read the saved version id and name as LevelVersion.parse does
The downgrade check compared Data.DataVersion; vanilla LevelVersion.parse takes Version.Id (no Version reads as 0, no Id as the current one) and names a Version without a Name as the current version. Both world checks now share that reading, and the play doc lists the new outcomes.
2026-10-29 22:18:51 +01:00
Kacpherek 20ad245e02 Ask for a backup before opening a world saved by a newer version
Horizon opened a newer world without asking. WorldOpenFlows.openWorldCheckVersionCompatibility (WorldOpenFlows.java:352) asks for a backup first, with the red downgrade question (BackupStatus.DOWNGRADE in LevelSummary.java).
2026-10-29 22:18:51 +01:00
Kacpherek 6840b57201 Show the incompatible version alert instead of opening such a world
Horizon opened a world from another version series without a check. WorldOpenFlows.openWorldCheckVersionCompatibility (WorldOpenFlows.java:352) shows the red Incompatible version AlertScreen and keeps the world closed.
2026-10-29 22:18:51 +01:00
Kacpherek f31e30a081 Select the word under a double click on a book page
A double click on a book page only moved the caret, so no word was selected. Vanilla MultiLineEditBox.onClick and MultilineTextField.selectWordAtCursor (MultiLineEditBox.java:89, MultilineTextField.java:184) select the word before the cursor.
2026-10-29 22:18:46 +01:00
Kacpherek 913ad15462 Let Ctrl plus the mouse wheel change the GUI scale on the Video page
The wheel only scrolled the list there, and Ctrl was ignored. VideoSettingsScreen.mouseScrolled (File.java:263) steps guiScale by one and resets the list to the top instead.
2026-10-29 22:18:46 +01:00
Kacpherek 1976c27870 Join the selected world with Enter or Space on the world list
Enter and Space on a selected world did nothing, so keyboard players could not join from the list; vanilla WorldListEntry.keyPressed joins on selection keys (WorldSelectionList.java:602).
2026-10-29 22:18:46 +01:00
Kacpherek 7af0a21f2d Join the typed server address on Enter in Direct Connection
Enter did nothing on Direct Connection; vanilla DirectJoinServerScreen.keyPressed selects the server when the Join button is active (DirectJoinServerScreen.java:32).
2026-10-29 22:18:46 +01:00
Kacpherek 7a2121d84f Create the world on Enter in Create New World
Enter only confirmed a world rename, so it did nothing on Create New World; vanilla CreateWorldScreen.keyPressed calls onCreate on confirmation (CreateWorldScreen.java:360).
2026-10-29 22:18:46 +01:00
Kacpherek 3d5407ab11 Show the ghost recipe item tooltip on hover
Hovering a ghost ingredient or result in a crafting grid or furnace showed nothing; vanilla GhostSlots.extractTooltip shows the ghost item for the hovered slot (GhostSlots.java:65).
2026-10-29 22:18:46 +01:00
Kacpherek ef890e667f Show the item tooltip on hover in the statistics Items tab
Hovering an item icon in the Items tab showed nothing; vanilla ItemRowWidget.extractTooltip draws the item tooltip at the slot's corner (StatsScreen.java:512).
2026-10-29 22:18:46 +01:00
Kacpherek b5bcf031e5 Name a shield after its banner base colour
A shield with a banner base colour showed as plain Shield; vanilla ShieldItem.getName uses item.minecraft.shield.<colour> (ShieldItem.java:12).
2026-10-29 22:18:46 +01:00
Kacpherek 1e3f05fe8c Make the spear-ai-golem fixes build on current main 2026-10-29 22:18:23 +01:00
Kacpherek 88dc2c5f8d Make the piglin and hoglin fixes build on current main 2026-10-29 22:14:32 +01:00
Kacpherek 8880e0d373 Play the hoglin retreat grunt near a repellent
A hoglin near a repellent made no retreat grunt; vanilla plays entity.hoglin.retreat there (HoglinAi.java:231 getSoundForActivity).
2026-10-29 22:13:47 +01:00
Kacpherek b9ddeb1820 Start a gliding player's rocket half a block out at the rocket hand
The rocket of a gliding player started at the player's feet; vanilla offsets it 0.5 block toward the hand holding it (Entity.java:2688 getHandHoldingItemAngle, FireworkRocketEntity.java:138).
2026-10-29 22:13:41 +01:00
Kacpherek 50121630ce Ignore knockback on a creaking that cannot move
A creaking a player looks at still got shoved by hits; vanilla ignores knockback while it cannot move (Creaking.java:451 knockback).
2026-10-29 22:13:41 +01:00
Kacpherek edd7a9cb61 Spawn the crumbs when a held tool or armour piece breaks
A tool or armour piece that broke made no crumb particles; vanilla spawns five item crumbs for it (LivingEntity.java:1516 breakItem).
2026-10-29 22:13:41 +01:00
Kacpherek 5270924ba9 Show the crumbs of the food a fox is eating
Fox.handleEntityEvent(45) throws eight item crumbs from in front of its mouth; the server broadcasts 45 with the eating sound (Fox.java:289, :227).
2026-10-29 22:13:41 +01:00
Kacpherek 97eda2b7f5 Play the tadpole grow-up sound at volume 0.15
Tadpole.ageUp plays TADPOLE_GROW_UP at 0.15 from the tadpole's position, not at full volume from the eyes (Tadpole.java:227).
2026-10-29 22:13:41 +01:00
Kacpherek 86c1795ec6 Save and load a fox's sleeping, sitting and crouching flags
Fox.addAdditionalSaveData writes Sleeping, Sitting and Crouching from its flags and Fox.readAdditionalSaveData reads them back; a reload no longer wakes a sleeping fox (Fox.java:445). Trusted players are still not ported.
2026-10-29 22:13:41 +01:00
Kacpherek b469038d45 Feed a saddled pig held food instead of mounting it
Pig.mobInteract and Strider.mobInteract take a click holding food to Animal.mobInteract before any ride (Pig.java:167, Strider.java:401).
2026-10-29 22:13:41 +01:00
Kacpherek d17c1bc0e9 Keep a tame nautilus near its home, within 32 or 16 blocks
AbstractNautilus.checkRestriction sets the home at the nautilus with getNautilusRestrictionRadius (32, or 16 for a baby or saddled one) when it is not leashed or ridden (AbstractNautilus.java:237, :241).
2026-10-29 22:13:41 +01:00
Kacpherek ec77633cfe Let a cat lured with fish approach its picked player unscared
CatTemptGoal keeps a selectedPlayer re-rolled every 500 to 600 ticks and does not scare it (Cat.java:677).
2026-10-29 22:13:41 +01:00
Kacpherek 5909dc68b6 Lower a sheep's head to eat grass on every client
Sheep.handleEntityEvent(10) sets eatAnimationTick to 40 and EatBlockGoal.start broadcasts event 10 (Sheep.java:119, EatBlockGoal.java:40); the server now sends it and clients start and count down the eating head.
2026-10-29 22:13:41 +01:00
Kacpherek bb7adf1207 Cull lightning bolts beyond 64 blocks times the view scale
A bolt past 64 blocks (scaled by the entity distance) was still drawn; vanilla keeps only the sky flash and thunder there (LightningBolt.java:250).
2026-10-29 22:13:41 +01:00
Kacpherek 97294e3838 Hurt an Enderman that a splash water potion lands near
A water splash did nothing to an Enderman; AbstractThrownPotion.affectEntitiesAround hurts it 1 and Enderman.hurtWithCleanWater makes it teleport.
2026-10-29 22:13:41 +01:00
Kacpherek 01cf25687f Mix both parents' dye colours for sheep, wolf and cat babies
A baby took one parent's colour by a coin flip; DyeColor.getMixedColor mixes them with the two-dye crafting recipe, else picks one (Sheep.java:281, Wolf.java:608, Cat.java:383).
2026-10-29 22:13:41 +01:00
Kacpherek 2151ef21f2 Put a wolf's leash rope at 0.6 of its eye height
The rope point was at full eye height; Wolf.getLeashOffset (Wolf.java:659) uses 0.6 times it.
2026-10-29 22:13:41 +01:00
Kacpherek d686f4b9d7 Play the iron golem damage sound when it cracks
Hits that moved an iron golem to a new crack level were silent; IronGolem.hurtServer plays IRON_GOLEM_DAMAGE then (IronGolem.java:209).
2026-10-29 22:13:41 +01:00
Kacpherek 2b90acbc0d Play the fox bite sound after each melee strike
A fox's melee strike made no sound; FoxMeleeAttackGoal plays FOX_BITE after doHurtTarget (Fox.java:1101).
2026-10-29 22:13:41 +01:00
Kacpherek 2e49c68051 Keep turtles silent in water and as babies
Turtles in water or babies played the land ambient; Turtle.getAmbientSound plays TURTLE_AMBIENT_LAND only on land and grown up, else Mob's silent default (Turtle.java:176).
2026-10-29 22:13:41 +01:00
Kacpherek aa30c8e9d2 Growl the angry zoglin sound while it has a target
A zoglin with an attack target grunted its ambient instead of entity.zoglin.angry; Zoglin.getAmbientSound picks ZOGLIN_ANGRY when ATTACK_TARGET is set (Zoglin.java:286).
2026-10-29 22:13:41 +01:00
Kacpherek 4dcb669759 Play the fox sleep sound while a fox naps
A sleeping fox played its day ambient; Fox.getAmbientSound returns FOX_SLEEP while sleeping (Fox.java:677).
2026-10-29 22:13:40 +01:00
DUzzL ada08437e5 Turtle: silent in water, in the air and as a baby (Turtle.getAmbientSound) 2026-10-29 22:12:16 +01:00
Kacpherek 997bbef785 Walk zombies through their village at night
Zombies, husks and zombie villagers wandered with the plain stroll only. Vanilla Zombie.addBehaviourGoals adds MoveThroughVillageGoal(1.0, onlyAtNight, 4, canBreakDoors) at priority 6 (MoveThroughVillageGoal.java): within 6 sections of a village, pick the land spot (15, 7) whose nearest taken, unvisited village POI is closest, path to that POI (or ten blocks toward it, or only as far as a wooden door), and remember the 16 last POIs reached. The weighted land position now also honours a restricted home like vanilla's LandRandomPos.getPos.
2026-10-29 22:08:19 +01:00
Kacpherek 753660c0b4 Run the click command of a sign when a player uses it
Clicking a sign with a run_command in its text did nothing. SignBlock.useWithoutItem and useItemOn call SignBlockEntity.executeClickCommandsIfPresent (SignBlockEntity.java:182): each line of the side the player faces runs its run_command (as the player, from the block centre, permission level 2, no output: createCommandSourceStack with CommandSource.NULL), opens its show_dialog or logs its custom action, but only when the sign has allow_op_features; otherwise the action bar says click actions are disabled. A waxed sign still plays its failed-interaction sound, and a sign that ran one does not open the editor.
2026-10-29 22:08:07 +01:00
Kacpherek eba7cb0409 Make iron golems return to and patrol their village
Golems strolled anywhere at 0.6 (the plain stroll) and never went home. Vanilla IronGolem.registerGoals (IronGolem.java:68) adds MoveBackToVillageGoal at priority 2 (a stroll every 10 ticks, toward the section within 2 closest to a village, only outside the village) and GolemRandomStrollInVillageGoal at 4 (every 240 ticks: 3 in 10 anywhere, else toward a villager who wants a golem or a taken POI in a village section, from the level's random). Both are RandomStrollGoal subclasses, so Stroll takes an overriding spot picker and canUse test; the village sections and POI tickets come from the villager brain's helpers.
2026-10-29 22:08:07 +01:00
Kacpherek 2d477df4fd Let a waxed sign with a click command stop hanging signs chaining
A hanging sign used on another hanging sign always chained below it, even when the aimed sign had a run_command to run. SignBlockEntity.canExecuteClickCommands (SignBlockEntity.java:178) is a waxed sign whose facing side has one, and CeilingHangingSignBlock and WallHangingSignBlock shouldTryToChainAnotherHangingSign pass only without it. The game now asks the sign, and hands the answer to the wall hanging sign check through PlayerContext.
2026-10-29 22:08:07 +01:00
Kacpherek 71092caa14 Make piglins back off 9 to 11 blocks after a spear charge
A piglin that charged with a spear stayed on its target. SpearRetreat starts on the RETREAT status with a target, a spear and no item in use when LandRandomPos.getPosAway finds a spot 9 to 11 blocks away (checkExtraStartConditions), remembers it as SPEAR_FLEEING_POSITION, walks there for up to 100 ticks while looking at the target, and ends by erasing the status so the next approach starts (SpearRetreat.java).
2026-10-29 22:08:07 +01:00
Kacpherek 5968cd0f09 Make piglins run at the target and reposition mid-charge
A charging piglin stood still. SpearAttack.tick moves it every tick: run at the target (SpearAttack.java), and within 2 blocks of it or at the end of the path pick a spot 6 to 7 blocks past it (LandRandomPos.getPosAway, 2 more blocks on a mount) to run to, erasing it at the end of the path so it runs at the target again, until SPEAR_ENGAGE_TIME ends.
2026-10-29 22:08:07 +01:00
Kacpherek 8ce62d3aaa Make piglins raise the spear to charge a target
After closing in, a piglin with a golden spear never lowered it to attack. SpearAttack starts on the CHARGING status with a target, a spear and no item in use: it becomes aggressive, sets SPEAR_ENGAGE_TIME to the weapon's use time (KineticWeapon.computeDamageUseDuration), erases SPEAR_CHARGE_POSITION and starts using the spear (SpearAttack.start); the charge counts down while it looks at the target and ends in the RETREAT status (SpearAttack.java).
2026-10-29 22:08:07 +01:00
Kacpherek 2153681d47 Make piglins with a golden spear close in on 10 blocks
A piglin holding a golden spear (about 1 in 20 adults) walked up and meleed. PiglinAi.initFightActivity puts SpearApproach before the melee behaviour: with a target, a spear and no item in use, and no SPEAR_STATUS yet (checkExtraStartConditions), it sets the status APPROACH and walks to the target until it is within 10 blocks (SpearApproach.java), then the status is CHARGING. A piglin with a spear no longer melees (MeleeAttack.isHoldingUsableNonMeleeWeapon).
2026-10-29 22:08:07 +01:00
Kacpherek 349127673c Douse fire, candles and campfires where a water bottle lands
A potion that hit a block had no face and never put anything out. Thrown potions now keep the block and the face they entered it by, and AbstractThrownPotion.onHitBlock (server side, before the entity effects) douses the cell in front of that face, the cell behind it and the four horizontal neighbours when the potion is in #douses_fire. AbstractThrownPotion.douseFire destroys a fire block without drops, puts out a lit candle or candle cake (AbstractCandleBlock.extinguish) and a lit campfire (level event 1009, the block-change game event, lit false) (AbstractThrownPotion.java:48, :122).
2026-10-29 22:08:00 +01:00
Kacpherek 32e16761bd Hurt water-sensitive mobs and put out burning ones with a splash
A water potion only hurt endermen, and nothing it splashed on stopped burning. AbstractThrownPotion.affectEntitiesAround goes over every living entity in the potion's box grown by (4, 2, 4) and under 4 blocks away: #hurts_water_sensitive_entities hurts the sensitive ones (blaze, snow golem, strider, enderman) with indirectMagic and #extinguishes_entities calls extinguishFire on the burning ones, players and armor stands included (AbstractThrownPotion.java:94).
2026-10-29 22:08:00 +01:00
Kacpherek 57a2512025 Add putting out a burning mob and a lit candle from outside
Entity.extinguishFire plays the extinguish sound for a burning entity and then clears its fire; AbstractCandleBlock.extinguish unlights a candle or candle cake with its sound and the block-change game event (no source entity, and no smoke from a server). Mobs only lost their fire in water or rain, and candles only to a player's empty hand or a wind charge.
2026-10-29 22:07:51 +01:00
Kacpherek 25d15d2a0a Add the indirect_magic damage type for thrown potions
A thrown potion hurts through DamageSources.indirectMagic(potion, owner), which bypasses armor, is resisted by witches and wolf armor, and, unlike magic, knocks back from the potion. Mobs only had magic (no knockback) or a player attack. Add the kind, its constructor and the damage_type id, and keep Enderman.hurtServer's random teleport chance off it: the thrown-potion branch teleports without it (Entity.java, DamageTypeTags, Enderman.java).
2026-10-29 22:07:51 +01:00
Kacpherek a1a45472c8 Read splash potion effects around the hit from the potion tags
A thrown potion tested for plain water by id to hurt endermen and refill axolotls. Vanilla asks PotionContents.is for #hurts_water_sensitive_entities, #rehydrates_axolotls (and #douses_fire, #extinguishes_entities) in AbstractThrownPotion. Add the lookup over the data pack's tags/potion and use it for the two checks that exist.
2026-10-29 22:07:51 +01:00
Kacpherek 37f9481bb7 Add SignText.hasAnyClickCommands for a sign side
A sign side had no way to say whether its text carries a run_command click event. SignText.hasAnyClickCommands (SignText.java:120) is true when any line's style has a RunCommand click event. Horizon reads the saved click_event the way ClickEvent.CODEC does for the three actions a sign acts on (run_command, show_dialog, custom).
2026-10-29 22:07:19 +01:00
Kacpherek 04fb868e99 Keep click events and allow_op_features on loaded signs
Horizon flattened every sign line to plain text, so a click event in the text was lost the moment a sign loaded. SignText keeps each line's Component and its Style; the sign now keeps each line's click_event next to its text, writes it back with the line (an edited line keeps its style, SignBlockEntity.updateMessages), and reads and writes the allow_op_features flag of SignBlockEntity.loadAdditional and saveAdditional.
2026-10-29 22:07:15 +01:00
Kacpherek 7ed6012c79 Tick potent sulfur geysers on the server and the client
Wet or dormant sulfur never made anyone nauseous, periodic geysers never erupted, and continuous ones neither pushed entities nor showed their plume. Run PotentSulfurBlock.getTicker's tickers by state (PotentSulfurBlockEntity.java): the server gives nausea every 10 ticks, counts the geyser down with its positional random and flips dormant and erupting, and throws mobs, items and primed TNT up the column; the client releases gas clouds, draws the plume and plays its sound from the eruption block event, and pushes the local player, who moves on its own client. A window that simulates the world itself does both.
2026-10-29 22:06:57 +01:00
Kacpherek a8be1d9ee0 Add the potent sulfur block entity and its block events
Potent sulfur had no block entity of its own here, so no countdown was kept or saved and a geyser that began erupting made no sound or block event. Add PotentSulfurBlockEntity's waitingCountdown (saved as countdown) and eruptionTick, create it with the block and keep it through saves. PotentSulfurBlock.onPlace now queues the eruption block event, plays the start sound and signals block_activate for erupting and continuous states, triggerEvent reports the event handled, and updateShape resets the countdown when a block that was no geyser turns dormant (PotentSulfurBlock.java validBlockState).
2026-10-29 22:06:57 +01:00
Kacpherek b6f3d91b09 Raise the spear arm of zombies and piglins
HumanoidMobRenderer.getArmPose gives an arm holding a spear the SPEAR pose, which AnimationUtils.animateZombieArms leaves alone; the mobs here drew zombie or piglin arms around it. Zombies, husks, zombie villagers, zombified piglins and piglins now hold the spear out and charge it by their use time, and a piglin admiring gold no longer counts as using the spear in its main hand.
2026-10-29 22:06:55 +01:00
Kacpherek 26f46141c7 Make zombies and zombified piglins charge with a spear
Zombies, husks, zombie villagers and zombified piglins spawned with a spear just melee. SpearUseGoal (priority 2, and 1 for zombified piglins) walks to 10 blocks, lowers the spear for KineticWeapon.computeDamageUseDuration (halved, the goal ticks every other tick), charges along the target, repositions 6 to 7 blocks away, then backs off 9 to 11 blocks (LandRandomPos.getPosAway with a minimum distance) for up to 50 goal ticks. A mount's chargeSpeedModifier and the 2 blocks a passenger adds are kept.
2026-10-29 22:06:55 +01:00
Kacpherek 4f5a70ee53 Take ten points off a goat's fall damage
Goat.calculateFallDamage is the base damage minus 10, as Frog's is minus 5. Goats now leap down to five blocks and the arc lifts them higher still, so without it every ledge jump would hurt them.
2026-10-29 22:06:52 +01:00
Kacpherek d0761d3c50 Let goats long jump between ledges when their cooldown runs out
GoatAi.initLongJumpActivity adds LongJumpMidJump and LongJumpToRandomPos (5 wide, 5 high, velocity 3.5714288) to the activity that runs after the ram activity and before the idle one: no tempting player, no breed target, no walk target and no LONG_JUMP_COOLDOWN_TICKS. It lands on LongJumpToRandomPos.defaultAcceptableLandingSpot (a solid block below, no path malus).
2026-10-29 22:06:52 +01:00
Kacpherek caca2ef2d4 Land a long jumping goat with a thud and a 10 percent slide
LongJumpMidJump.stop slows a mob that lands to a tenth of its horizontal speed (multiply(0.1F, 1.0, 0.1F), the float widened to a double), plays the activity's landing sound at volume 2 (GoatAi: entity.goat.step), stands it up and samples the cooldown. A goat has no swim activity, so water does not end its flight.
2026-10-29 22:06:52 +01:00
Kacpherek 8874140947 Play the goat's long jump sound when it leaps
LongJumpToRandomPos.tick plays the activity's jump sound at the leap. GoatAi.initLongJumpActivity gives goats entity.goat.long_jump and screaming goats entity.goat.screaming.long_jump; the arc, the 40 tick wait and the jump boost are the same as the frog's.
2026-10-29 22:06:52 +01:00
Kacpherek a369faeb32 Let allays remember a note block they heard
An allay never learned a liked note block, so it always brought items to the player. AllayAi.hearNoteblock (AllayAi.java:108), called from Allay.VibrationUser.onReceiveVibration on NOTE_BLOCK_PLAY, stores the block as LIKED_NOTEBLOCK_POSITION with a 600 tick cooldown and renews the cooldown when the same block plays again.
2026-10-29 22:06:52 +01:00
Kacpherek 6dc2f77e99 Make an allay with a liked note block hear only that block
Allay.VibrationUser.canReceiveVibration (Allay.java:564) refuses vibrations without AI and, once a note block is liked, accepts only that block while the allay is within 1024 blocks of it in its dimension. Horizon accepted every vibration.
2026-10-29 22:06:52 +01:00
Kacpherek bb4da395a3 Add GlobalPos.isCloseEnough and the allay's liked note block memory
The liked note block slot (extra.home) was never set and was a bare block position. Vanilla keeps LIKED_NOTEBLOCK_POSITION as a GlobalPos (GlobalPos.java:31) and AllayAi.getItemDepositPosition drops it when it is over 1024 blocks away, in another dimension, no longer a note block or its cooldown ran out; the throw target is BlockPosTracker(pos.above()), the centre of that block. Saved in Brain.memories like vanilla.
2026-10-29 22:06:52 +01:00
Kacpherek 2555b46e7c Give allays a vibration listener that hears note blocks
An allay never listened for vibrations, so note blocks played near it went unnoticed. Vanilla gives it a VibrationSystem listener (Allay.VibrationUser, 16 blocks, GameEventTags.ALLAY_CAN_LISTEN = note_block_play) ticked after super.tick() in Allay.tick, saved under "listener".
2026-10-29 22:06:52 +01:00
Kacpherek 65a23ca98e Port map: the golden dandelion age lock is ported
The map still said a golden dandelion did nothing: drop the missing rows for
AgeableMob.mobInteract, canUseGoldenDandelion, setAgeLockedData, setAgeLocked,
makeAgeLockedParticle and Tadpole.setAgeLockedData (the code now cites them),
and say AgeableMob.defineSynchedData sends AGE_LOCKED too.
2026-10-29 22:06:52 +01:00
Kacpherek 24101477da Let a golden dandelion lock or release a baby animal's growth
Using a golden dandelion on a baby animal did nothing. AgeableMob.mobInteract (reached by Animal, Dolphin, SulfurCube and the other subclasses after their own checks) runs setAgeLocked with setAgeLockedData and returns SUCCESS when canUseGoldenDandelion holds; Tadpole.mobInteract does the same. A scared panda or one on its back (Panda.mobInteract) and an armadillo in its shell (Armadillo.mobInteract) answer first. A server's client treats the click as taken like the server does (AgeableMob.java:68).
2026-10-29 22:06:52 +01:00
Kacpherek e04428b60c Use up the golden dandelion, keep the mob and play the lock sound
Golden dandelion consumption, persistence and the use or unuse sound were missing. AgeableMob.setAgeLocked runs setAgeLockedData, consumes one item (not in creative), setPersistenceRequired when the mob is now locked, and plays golden_dandelion.use or .unuse at the block position (middle of the block, 1.0, 1.0, PLAYERS) (AgeableMob.java:88).
2026-10-29 22:06:52 +01:00
Kacpherek 79ed5ba529 Add the golden dandelion check for age locking a baby
AgeableMob.canUseGoldenDandelion(item, isBaby, cooldown, mob): the held item is a golden dandelion, the mob is a baby, its ageLockParticleTimer is 0 and its type is not in the cannot_be_age_locked entity tag (zombie horse, skeleton horse, villager) (AgeableMob.java:78).
2026-10-29 22:06:52 +01:00
Kacpherek a569559196 Add the age lock toggle a golden dandelion applies to a tadpole
A golden dandelion on a tadpole did nothing. Tadpole.setAgeLockedData sets AGE_LOCKED to its opposite, setAge(0) (back to the start of its 24000-tick growth) and ageLockParticleTimer = 40 (Tadpole.java:168).
2026-10-29 22:06:52 +01:00
Kacpherek ac74644dc9 Add the age lock toggle a golden dandelion applies to an ageable mob
Nothing toggled AgeLocked in play. AgeableMob.setAgeLockedData sets AGE_LOCKED to its opposite, setAge(getBabyStartAge()) (-24000, a sniffer's -48000, through the age boundary) and ageLockParticleTimer = 40 (AgeableMob.java:82).
2026-10-29 22:06:51 +01:00
Kacpherek c419845102 Draw the growth particles above a mob a golden dandelion touched
There was no age lock particle timer, so no pause or reset growth particles. AgeableMob.makeAgeLockedParticle runs at the end of AgeableMob.aiStep and Tadpole.aiStep: while ageLockParticleTimer is above 0, every even tick it sends one PAUSE_MOB_GROWTH (locked) or RESET_MOB_GROWTH particle at getRandomX(1.0), getRandomY(0.2) + bbHeight (+ 0.2F when locked), getRandomZ(1.0), then counts down (AgeableMob.java:225).
2026-10-29 22:06:51 +01:00
Kacpherek a6e91967a1 Hold an age-locked baby as a baby and sync the lock to clients
Nothing honoured AgeLocked in the shared age tick: a locked baby kept growing, a locked baby fed food still aged, and the lock never reached remote clients. AgeableMob.aiStep only counts a baby up when canAgeUp (isBaby && !isAgeLocked), and only for AgeableMob subclasses (it was also counting a tadpole's own age back down, so it never grew up). Animal.mobInteract, Panda.mobInteract and Dolphin.mobInteract feed a baby only when canAgeUp. AgeableMob.AGE_LOCKED (entry 17 after DATA_BABY_ID) and Tadpole.AGE_LOCKED are synched; MushroomCow.DATA_TYPE moves to 18 behind them (AgeableMob.java).
2026-10-29 22:06:51 +01:00
Kacpherek af8751173f Keep a riding piglin to the ride activity's behaviours
PiglinAi.initRideHoglinActivity lists Mount, SetEntityLookTarget(loved item, 8.0), a look at what it sees while a passenger and DismountOrSkipMounting; it has no idle movement or repellent avoidance, so a piglin heading for a hoglin no longer wanders off or strolls. A riding piglin looks at a player holding gold within 8 blocks, not 14.
2026-10-29 22:06:47 +01:00
Kacpherek af0bc10c34 Take a baby piglin off a baby it rides once its ride target ends
A baby piglin stayed on its mount forever. PiglinAi.updateActivity ends with stopRiding when RIDE_TARGET is gone and PiglinAi.isBabyRidingBaby (a baby piglin on a baby piglin or baby hoglin) holds, so a ride lasts as long as the 10 to 30 second memory.
2026-10-29 22:06:47 +01:00
Kacpherek 7fd82c846b Make a riding piglin get off a vehicle it no longer wants
No dismount rule ran. DismountOrSkipMounting keeps the vehicle (or else the ride target) only while it is alive and closer than 8 blocks and PiglinAi.wantsToStopRiding says no: the vehicle is grown, dead, the piglin was hurt in the last 40 ticks (HURT_BY), or the vehicle is a piglin that rides nothing itself. Otherwise the piglin gets off and forgets RIDE_TARGET.
2026-10-29 22:06:46 +01:00
Kacpherek f27a098c2d Walk a baby piglin to its ride target and mount it
No brain sent a mob to another to climb on it. Mount.create (ride activity) does nothing for a passenger, boards the ride target once closer than a block, and otherwise looks at it and sets a 0.8 speed walk to it; MoveToTargetSink ends that walk a block from the target's block and aims again when it moved over 2 blocks.
2026-10-29 22:06:46 +01:00
Kacpherek 08e206c459 Make an idle baby piglin pick a baby hoglin to ride
Nothing ever set RIDE_TARGET. PiglinSpecificSensor remembers the nearest visible baby hoglin, and PiglinAi.babySometimesRideBabyHoglin (idle activity) copies it into RIDE_TARGET for 10 to 30 seconds once SetEntityLookTargetSometimes.Ticker lets a baby through, every 10 to 40 seconds, with draws from the level random.
2026-10-29 22:06:46 +01:00
Kacpherek 5bc9606d99 Give a piglin a ride target and a ride activity
Brain activities wait on their memory: PiglinAi.getActivities lists a RIDE activity that is valid while RIDE_TARGET is there, between celebrating and idle in PiglinAi.updateActivity's order. RIDE_TARGET holds a mob and expires like any memory with an expiry.
2026-10-29 22:06:46 +01:00
Kacpherek c4f0c49c0e Pose a riding piglin sitting
PiglinModel extends HumanoidModel, whose setupAnim bends the legs and arms of a passenger; piglin_pose never set the riding flag, so a piglin on a mount stood stiff.
2026-10-29 22:06:46 +01:00
Kacpherek ff70b164cc Let a mob's brain board another mob and get off it
Only spawning put a mob on another; no brain could. Entity.startRiding checks the boarding cooldown, a free seat and no riding cycle, and Piglin.startRiding sends a baby piglin that boards a hoglin to the top of up to three piglins stacked on it. LivingEntity.stopRiding sets the passenger down at Animal.getDismountLocationForPassenger. A hoglin seats its rider at 1.49375 (0.875 for a baby), a baby piglin hangs 0.1875 below its seat (Piglin.BABY_DIMENSIONS).
2026-10-29 22:06:46 +01:00
Kacpherek 66c20c246d Open the new piglin and hoglin doc comments with their vanilla method
Doc wording only: each function that ports a vanilla method now starts its comment with the method name.
2026-10-29 22:06:46 +01:00
Kacpherek 32f2156b13 Count a piglin's hunted-recently wait down once a tick
Two additions to Piglin's per-tick state each counted the same memory down, so the HUNTED_RECENTLY wait that setAvoidTargetAndDontHuntForAWhile and StartHuntingHoglin start (30 to 120 seconds, PiglinAi.TIME_BETWEEN_HUNTS) ran out twice as fast.
2026-10-29 22:06:46 +01:00
Kacpherek 810a0152d3 Make idle piglins watch a player who holds gold
Piglins never turned to look at a player holding a gold ingot or another loved item. The idle and celebrate activities of PiglinAi.java start with SetEntityLookTarget on a visible player within 14 blocks for whom isPlayerHoldingLovedItem holds; add that look as a goal.
2026-10-29 22:06:46 +01:00
Kacpherek c124b94c8b Keep adult hoglins 8 blocks from the adult piglins they see
Idle hoglins ignored adult piglins and let them walk right up. HoglinAi.initIdleActivity walks an adult away from the nearest visible adult piglin at 0.4 once it is closer than 8 blocks, when it has no walk of its own (SetWalkTargetAwayFrom in HoglinAi.java).
2026-10-29 22:06:46 +01:00
Kacpherek 845343e019 Play a piglin's and hoglin's activity sound as its ambient grunt
Piglins and hoglins played their angry grunt whenever they had a target and otherwise the plain ambient one. Piglin.getAmbientSound and Hoglin.getAmbientSound play the sound of the activity the mob is in (PiglinAi.getSoundForCurrentActivity, HoglinAi.getSoundForCurrentActivity).
2026-10-29 22:06:46 +01:00
Kacpherek 89ecd6cd9d Grunt when a piglin or hoglin changes activity
A hoglin or piglin that started a fight, a retreat, a celebration or an admiring said nothing; only the ambient roll played. HoglinAi.updateActivity and PiglinAi.updateActivity make getSoundForActivity's sound (retreat, angry, admiring, celebrate, jealous) when the activity changes (Hoglin.java, Piglin.java).
2026-10-29 22:06:46 +01:00
Kacpherek e314b5c6e1 Make a hurt piglin flee, retreat or retaliate as vanilla does
A hit piglin ended its celebration and, for a player's hit, its admiring, but babies fought back instead of fleeing, an avoid target of another kind stayed, and an adult piglin took on hoglins it should retreat from. PiglinAi.wasHurtBy (Piglin.hurtServer) drops the admiring and the celebration for any attacker but a piglin, sends a baby into a 100-tick flight and calls the adults, retreats an adult from hoglins that outnumber piglins, and lets it retaliate only when maybeRetaliate allows.
2026-10-29 22:06:46 +01:00
Kacpherek be2963b1d6 Make a hurt hoglin retreat or retaliate as vanilla does
A hit hoglin only ended its pacified state and let the hurt-by goal target the attacker, so a baby never fled and an adult took on piglins and hoglins it should leave alone. HoglinAi.wasHurtBy (Hoglin.hurtServer) sends a baby into the retreat and lets an adult retaliate only when maybeRetaliate allows it.
2026-10-29 22:06:46 +01:00
Kacpherek d68d59f6b6 Make an outnumbered hoglin retreat after it hits a piglin
A hoglin that hit a piglin kept fighting even when piglins outnumbered the hoglins around it. HoglinAi.onHitTarget (Hoglin.doHurtTarget) sets the piglin as its avoid target and broadcasts the retreat to the adult hoglins it sees.
2026-10-29 22:06:46 +01:00
Kacpherek 7a98019450 Set an avoid target and broadcast a retreat to the adults nearby
A hoglin or piglin that decides to retreat had no way to set the avoid target, pick the nearest of its targets or warn the adults around it. Add HoglinAi.setAvoidTarget, PiglinAi.setAvoidTargetAndDontHuntForAWhile, retreatFromNearestTarget and broadcastRetreat; the broadcast reaches the other mob as an event that its next tick handles before its goals.
2026-10-29 22:06:46 +01:00
Kacpherek c40d0112a6 Let piglins and hoglins retreat from their avoid target
Hoglins and piglins never ran from piglins, hoglins or zombified mobs through an avoid target: baby hoglins panicked instead and piglins used a plain avoid goal. Add the retreat activity's walk away (HoglinAi.initRetreatActivity at 1.3 and 15 blocks, PiglinAi.initRetreatActivity at 1.0 and 12), the copies of a nemesis or zombified mob into AVOID_TARGET (PiglinAi.java), wantsToStopFleeing and the outnumbering tests, and keep the idle behaviours and the attack goals out of the retreat.
2026-10-29 22:06:45 +01:00
Kacpherek 89cbb09961 Let mobs stab with a spear they are using
A mob that held a spear could not use it: nothing started its use or ran KineticWeapon.damageEntities for it. A mob's use now counts ticks like the player's, stabs along its view from delay_ticks on (conditions scaled by 0.2, reach by the AttackRange mob_factor), hurts and pushes the player or mobs hit, dismounts riders and broadcasts entity event 2 (LivingEntity.stabAttack, causeExtraKnockback). A piglin's admiring is told apart from a spear in use by the off hand.
2026-10-29 22:06:40 +01:00
Kacpherek d43b957afc Add the sulfur geyser and noxious gas particles
Potent sulfur geysers draw four new particle types and two emitters, and none existed. Add noxious_gas, noxious_gas_cloud, geyser, geyser_base, geyser_poof and geyser_plume the way the vanilla providers build them (GeyserEruptionParticle.java, GeyserPlumeParticle.java, GeyserBaseParticle.java, NoxiousGasParticle.java, NoxiousGasCloudParticle.java): the gas fades from half its life, the base puffs burst upward, the plume shoots up and falls back, and the cloud and the eruption spawn the rest every tick. The cloud asks PotentSulfurBlockEntity.canBeReachedByNoxiousGas, ported with the other level reads of that class in the new potent_sulfur module. Particle packets with geyser data now map to and from the options.
2026-10-29 22:06:36 +01:00
Kacpherek 93dd53e1c6 Brighten a glow squid's body when it is not dark
A glow squid was lit like any mob, so it never glowed in the dark. GlowSquidRenderer.getBlockLightLevel lerps 1 - darkTicks / 10 over 0..15 and uses 15 outright, else the larger of that and the usual block light (GlowSquidRenderer.java:26).
2026-10-29 22:06:36 +01:00
Kacpherek 1a371c3e20 Count down the glow squid dark ticks and shed glow particles
A glow squid kept no dark timer and shed no glow. GlowSquid.aiStep runs after Squid.aiStep on the server and on clients: it lowers a positive dark tick count by one and calls addParticle(GLOW) at getRandomX(0.6), getRandomY(), getRandomZ(0.6) (GlowSquid.java:83). Only a client's level draws it; the server throws the same three random doubles.
2026-10-29 22:06:35 +01:00
Kacpherek f1a0963f2a Dim a glow squid for 100 ticks when it is hit
A hit glow squid never got its dark timer, so it did not dim. GlowSquid.hurtServer sets the dark ticks to 100 once Squid.hurtServer took the damage (GlowSquid.java:97).
2026-10-29 22:06:32 +01:00
Kacpherek 2947c2d71f Share the spear hit search with the mob code
The stab's entity search (ProjectileUtil.getHitEntitiesAlong) lived in the game crate's spear module; mobs that stab need it from the client library, so it moves to the spear module there and the game keeps using it.
2026-10-29 22:06:26 +01:00
Kacpherek 952fc5a1f5 Add LandRandomPos.getPosTowards for mobs that lean toward a spot
Horizon only had getPosAway on land. Vanilla's LandRandomPos.getPosTowards (LandRandomPos.java:23) takes the direction to the point and goes through getPosInDirection, a quarter turn (float cast pi/2) either side. getPosAway now shares that helper, so it uses the same float-cast angle.
2026-10-29 22:06:26 +01:00
Kacpherek ba7f106d53 Sense the adults a piglin or hoglin sees and track its activity
Piglins and hoglins had no brain memories: nothing counted the adult piglins and hoglins in sight (HoglinSpecificSensor, PiglinSpecificSensor), kept an avoid target with an expiry, or noted which activity they were in. Add them as mob state ticked from customServerAiStep (Hoglin.java, Piglin.java).
2026-10-29 22:06:24 +01:00
Kacpherek 9191728d12 Make a goat that cannot long jump wait 300 to 600 ticks
LongJumpToRandomPos.checkExtraStartConditions needs the mob on the ground and out of water, lava and honey, and otherwise sets LONG_JUMP_COOLDOWN_TICKS to half a sample of TIME_BETWEEN_LONG_JUMPS (GoatAi: 600 to 1200). A goat has no swim activity to keep it out of the check, so it runs in water too.
2026-10-29 22:06:23 +01:00
Kacpherek bd89ae3df7 Sync and save the glow squid dark ticks
A glow squid had nowhere to keep DATA_DARK_TICKS_REMAINING: nothing held it, sent it to clients, or saved it. GlowSquid defines it as the first entry after AgeableMob's two (index 18) and writes DarkTicksRemaining (GlowSquid.java). It lives in the mob's counter; the server sends it as synched data, a client applies it and the save keeps it.
2026-10-29 22:06:23 +01:00
Kacpherek 5ea674c6e6 Search an 11 block box for a goat's long jump
LongJumpToRandomPos.start lists every block within the activity's width and height of the mob (GoatAi: 5 and 5, so 11 x 11 x 11; the frog's is 9 x 5 x 9). A goat's activity is a plain LongJumpToRandomPos, so it skips LongJumpToPreferredBlock.start's draw from the mob's random.
2026-10-29 22:06:23 +01:00
Kacpherek d8ac144995 Share the frog's long jump goal pieces with goats
LongJumpToRandomPos and LongJumpMidJump serve both mobs in vanilla. The goal now sets the friction flag and the pose through helpers that know the goat's state, and the default landing spot and the cooldown count down (CountDownCooldownTicks) can be used by a goat. Frogs behave as before.
2026-10-29 22:06:23 +01:00
Kacpherek a7f03f52fd Give goats a long jumping pose with its smaller box and friction flag
A goat that long jumps takes Pose.LONG_JUMPING, which Goat.getDefaultDimensions scales by 0.7, and LongJumpUtil checks the arc with that pose's box (LongJumpUtil.isClearTransition). The pose goes to remote clients like the frog's, and the goat holds LivingEntity.discardFriction.
2026-10-29 22:06:23 +01:00
Ludovic MarieandClaude Opus 5.5 fabb6d78ec Parity headline: a plain paragraph, no HTML markers
The website renders the "Project status" intro with its own markdown, which
prints HTML comments as text: the block is now found by its first words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:56:45 +01:00
Ludovic MarieandClaude Opus 5.5 3082b36894 Parity headline: the player-visible gaps still open, on the README and the site
tools/parity_gaps.py counts the rows not yet ticked in the port ledger's
ten gap lists (#3512-#3521) and writes them first under "Project status"
(by area, linked) and in a badge beside the 100% one. The website shows the
block already: it renders that section's intro. Today: 833 open of 833.
Refreshed in the weekly roadmap pass (roadmap/README.md); tests check the
block, the badge and the site's heading agree, without the network.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:54:39 +01:00
DUzzL c9053670cd Fix camel pose time at spawn and in LastPoseTick saves
A camel kept its pose change as a signed age, so a camel finalized at age 0 had pose time 0 and could not move for 52 ticks, and LastPoseTick was saved as a sign. Keep a signed age anchor plus a sitting flag, and save and load LastPoseTick as a game time.
2026-10-29 21:42:33 +01:00
DUzzL 4269899778 Stamp the current DataVersion only after the world data is upgraded
upgrade_world wrote level.dat at the current DataVersion before converting chunks, so a failed conversion was never retried. The level.dat write now comes last.
2026-10-29 21:37:45 +01:00
TJKDev1andClaude Opus 5.5 6eb26eb046 Third person: the player's head pitch follows the mouse between ticks
Entity.turn adds the turn to xRotO as well as xRot, so the local
player's getXRot(partialTick) follows the mouse every frame. Every turn
(mouse, pad, arrow keys, the control `turn`) now goes through
turn_player, which moves both ends of the model's head pitch and ends
in AbstractBoat.onPassengerTurned's clamp. handleRotatePlayer snaps the
model (setOldRot), and handleMovePlayer moves its old pitch by the
packet as setOldPosAndRot does. A change the tick makes still eases in.

Fixes #3844

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:24:09 +01:00
Federico Tagliani d1129a9fce Merge remote-tracking branch 'origin/main' into fix/boat-paddle-input-latch 2026-10-29 21:17:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 0cfb0400c8 Boats: steer with the input latched on the previous tick
LocalPlayer.rideTick sets the boat's input after the boat ticked, so
AbstractBoat.tick steers with the previous tick's keys and the paddle
packet it sends describes the motion it just made. Horizon set the keys
before ticking, a tick ahead of that packet (GrimAC Simulation flags).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 21:15:45 +01:00
matydenandClaude Opus 5.5 230d89f7ec Telemetry: a user named "user" keeps the <user> label whole
The scrubber puts <user> in place of the folder name in /Users/... and
C:\Users\... paths, then replaces the machine's user name wherever it
stands alone. When that name is "user", the second pass matched the
word inside the label it had just written, so a crash report read
/Users/<<user>>/... and telemetry's a_crash_keeps_only_what_says_what_happened
failed on such machines (seen in #578 and #3838). A match between "<"
and ">" is now left as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:15:04 +02:00
spiderandClaude Opus 5.5 be48b7a4a9 LAN and friends worlds authenticate guests when the host is signed in
IntegratedServer.initServer uses authentication, so a vanilla LAN is in
online mode: each guest's hasJoined answer brings their signed textures
property, and everyone sees their skin and cape. Horizon's LAN stayed in
offline mode, so guests joined with no properties and showed as default
skins without capes.

OnlineMode::lan() makes the key pair when an account is signed in (an
offline name keeps offline mode), for the server thread and the in-frame
host alike; friend codes reach the same port through the tunnel. As in
ServerLoginPacketListenerImpl.handleHello, the integrated server's own
player over memory is never asked to authenticate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:03:58 +01:00
d3grandoandClaude Opus 5.5 ef3163c244 Merge origin/main into fix-3236
tools/lan_e2e.py: the mooshroom check and main's shulker_menu and keys_released checks, all kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:13:41 +07:00
FauxandClaude Opus 5.5 96877dcf48 Far terrain: no dark band in the sea where chunks were loaded
A chunk's summary kept the chunk's own light, which fades a level per block
of water, while the seed's far columns light water and its floor as open
sky. The chunks a player passed then drew a darker band through the far sea.
Summaries now light open water and its floor as open sky, and the water is
one span again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:11:33 -07:00
d3grandoandClaude Opus 5.5 3931a46585 lan_e2e: the player beside the eater hears each bite once too
check_eating (each player eats a loaf in front of the other) now also
counts entity.generic.eat on both sides: the eater and the watcher must
each hear one bite per crumb burst the eater draws, plus
FoodProperties.onConsume's sound, for the loaf eaten (#3129).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:07:35 +07:00
degepieandClaude Opus 5.5 bcfeeeeb2a Merge main into fix/issue-3024-worn-armor-glint
main added render_layers (armour without the body) and a body_visible flag;
its extra layers now carry the overlay too, like render_with's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-30 00:03:43 +05:00
Ludovic MarieandClaude Opus 5.5 e28ed6d877 Spawners: no level built while no player is in range
`BaseSpawner.serverTick` returns first thing without an alive player within
its range (`isNearPlayer`) or with `spawner_blocks_work` off, before any
draw. Each spawner still built the spawn code's level every tick for it:
every player with its box and mode, the nearest-player list and the mob
world. Ask the players directly first, without collecting them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:58:32 +01:00
d3grandoandClaude Opus 5.5 34c77f9eac lan_e2e: check that a mooshroom's colour reaches the guest
New `mooshroom` check: a mooshroom summoned with Type "brown" is drawn
brown on the host and the guest, and a red one a bolt strikes
(MushroomCow.thunderHit) turns brown on both screens, with the server's
Type "brown". DATA_TYPE is synched data entry 18 (#3236).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:35:21 +07:00
Ludovic MarieandClaude Opus 5.5 20ffb882bd Merge the launcher layer (main c7065027b) into the docs layer
main's edit to the WebAssembly server mods section goes with the section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:23:40 +01:00
Ludovic MarieandClaude Opus 5.5 38a315331d Merge the crates layer (main c7065027b) into the launcher layer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:22:50 +01:00
d3grandoandClaude Opus 5.5 e09cbf2b9e Merge origin/main into fix-3091
Conflicts kept from both sides:
- mobs/ai.rs: parrots register through parrot::register (flight), next to
  main's own rabbit and ocelot arms.
- tools/test_mob_ai_parity.py: the parrot scenarios and main's wolf_hurt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:07:41 +07:00
Ludovic MarieandClaude Opus 5.5 1c4b029b95 Merge the launcher change (main d009ebd) into the docs
main's content mod docs (items patch their template's components) kept; its
change to the WebAssembly API reference goes with the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:54:01 +01:00
Ludovic MarieandClaude Opus 5.5 31c0c3dfe4 Merge the crate removal (main d009ebd) into the launcher change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:52:54 +01:00
Federico Tagliani fea840e0d4 Merge origin/main into fix/break-effects-for-popped-blocks 2026-10-29 18:38:08 +01:00
Federico TaglianiandClaude Sonnet 5.5 582331c3ea lan_e2e: popped_break_effects mines by hand in survival, torch on top
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 18:32:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 eed02f0cda lan_e2e: popped_break_effects check for blocks that pop off a mined support
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 18:20:27 +01:00
Federico TaglianiandClaude Sonnet 5.5 dacde01da2 lan_e2e: merchant-close check for the trade screen closing out of reach
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 17:55:16 +01:00
Muhammad Asif 26c139f901 Keep launcher links on horizon-rust/horizon in release notes
Player-facing notes used GITHUB_REPOSITORY, so fork CI rewrote the
launcher releases href and failed
test_a_game_release_opens_with_the_way_to_the_launcher.
2026-10-29 21:51:25 +05:00
levzzzandClaude Opus 5.5 d4f466623d Merge main into fix/issue-3024-worn-armor-glint: the armour glint moves to overlay 36
Main gave overlay 35 to text drawn with a polygon offset (TEXT_POLYGON_OFFSET_OVERLAY,
its own material in gfx/entity_batch.rs), so armour with 35 would be drawn as that
material. The test now lists it among the overlays the glint must not take.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:47:51 +05:00
ThatCakeIsAPieandClaude Opus 5.5 c9999e3336 Worldgen: data packs' block state providers join the state_providers bundle
worldgen/block_state_provider was missing from overrides::KINDS, so a
pack's own providers (WWOO's wythers:stony_cliffs) never reached the
state_providers bundle the generator already reads, and every feature
using one was skipped. pack_files_go_to_their_bundles covers the folder.

Reported by AdachiReiYay's agent on #3476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:36:10 -05:00
ThatCakeIsAPieandClaude Opus 5.5 3a37c32d8d Merge upstream main into feat/level-shape-1
world.rs keeps main's FxHashMap/FxHashSet imports beside this branch's
SectionMask. Main's writes_mark_exactly_the_reachable_sections sorted
(column, mask) pairs, and a SectionMask has no order: it sorts by the
column, which each pair has once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:23:30 -05:00
GIGABAIT93andClaude Opus 5.5 91591e4dcf Player skins: skin_entries as a function, with a test
skin_entries holds the lookup update_remote_skins does, so a test can
play an NPC plugin's add, spawn and remove: the entity keeps the skin
its entry gave, a player spawned before its entry takes it when it
comes, and a removed entity forgets it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:55 +02:00
Federico TaglianiandClaude Sonnet 5.5 6180a78195 Ghast: target position, move control timing, look angle and shot sounds as vanilla
getSuitableFlyToPosition returns the first random position (no mirror under
the heightmap unless 64 tries fail); GhastMoveControl tests floatDuration
before decrementing it; GhastLookGoal turns with the float 180/pi and
Mth.atan2; the warning cry and the shot are level events whose pitch is the
client's, not draws from the ghast's random.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 17:14:38 +01:00
GIGABAIT93andClaude Opus 5.5 731896f222 Player entities keep the skin they found: NPCs show theirs
NPC plugins add an NPC's profile to the player list, spawn it, and take
the entry off the list a moment later to hide it there. Vanilla keeps
it: AbstractClientPlayer.getPlayerInfo looks the PlayerInfo up once and
holds it, so getSkin still reads its textures. PlayerInfoRemove dropped
the profile's textures here, so on tensa.co.ua every NPC showed its own
skin for about two seconds, then a default one (efe, zuri, sunny, kai).

level_player_textures keeps, per player entity, the textures it found
first, as level_player_names already keeps its name; update_remote_skins
resolves a player whose entry is gone from them. A new entity, a removed
one and a new level forget theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:58:21 +02:00
Federico TaglianiandClaude Sonnet 5.5 7703ebcf95 Cat: avoidance goal last, sprint while dashing
Cat.reassessTameGoals adds the player-avoidance goal after registerGoals, so
it is evaluated last. Cat.customServerAiStep sets sprinting while the move
control's speed modifier is 1.33: +30% movement speed, and Entity.baseTick
draws twice from the mob's random for the sprint dust. The probe names the
goal CatAvoidEntityGoal as vanilla's subclass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:42:19 +01:00
Ludovic MarieandClaude Opus 5.5 8ca96ad967 Server brand: Horizon's server names itself "horizon"
The integrated, LAN and dedicated server sent minecraft:brand "vanilla"
(MinecraftServer.getServerModName) while Horizon's client sends "horizon".
A modded server names itself, so Horizon's does too: F3 shows it, and a
server admin can tell Horizon servers apart (bunchy7s, Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:14:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 c11d57d1f8 Break effects: a block that pops off when its support is mined plays its break sound and particles for the miner
The server sent every level event of an acting player's action with that
player excluded, so a torch or flower that popped off when the player mined
the block it stood on was silent (no break sound, no particles) for the
miner. Block.playerWillDestroy excludes the player only from the destroy
events of the block it breaks (and its other half); what the break sets off
afterwards goes through destroyBlock with no player. Only the events up to the
broken block's are the player's own now.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:46:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 0b6c48be77 Zombified piglin: it runs Zombie.registerGoals too, with the turtle egg goal
ZombifiedPiglin extends Zombie and only overrides addBehaviourGoals, so it
keeps the turtle egg goal (priority 4) that Zombie.registerGoals adds. Horizon
left it out, so the goal never drew its start delay and the mob's random
stream drifted from vanilla's on its second tick.

Adds a zombified_piglin_player scenario to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:23:54 +01:00
bunchy7s 815beda680 Refresh command block edit screen with server data
Send the command block's block-entity data when opening its screen and
apply incoming block-entity updates to the open edit screen. This makes
the command and output settings match the server instead of a stale
client copy, mirroring vanilla's openCommandBlock and
handleBlockEntityData behaviour.

Files:
- crates/horizon-client/src/game.rs
- crates/horizon-client/src/game/command_blocks.rs
- crates/horizon-client/src/game/remote.rs
- crates/horizon-client/src/lan/chunks.rs
2026-10-29 09:19:03 -05:00
ThatCakeIsAPieandClaude Opus 5.5 8e0939f3e8 Merge upstream main into feat/level-shape-1
Main's horizon-client-render split (#3186) moved chunk.rs, world.rs,
streaming.rs and gfx.rs, which this branch changes: section_mask.rs moves
with them into horizon-client-render, and horizon-client re-exports it
beside the other moved modules. lan/chunks.rs keeps both imports (main's
spawner traits and SectionMask).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:14:39 -05:00
umcekoandClaude Opus 5.5 df96b6787d NeoForge oracle: export items' data components and armour facts
Each item now carries its default components (vanilla's codecs, 1.21.1's
form), its effective attribute modifiers (armour's come from a method,
not a component), enchantability and repair items, and for armour its
slot, defense, toughness, knockback resistance, equip sound and texture
layers: the ground truth an item port is compared against. The export
also creates its output folder instead of failing to write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
Federico TaglianiandClaude Sonnet 5.5 0c79bae87d Wither skeleton: its piglin target goal comes first among the priority 3 goals
WitherSkeleton.registerGoals adds its AbstractPiglin target goal before it
calls super.registerGoals, so among the priority 3 target goals it is
evaluated before the iron golem and turtle ones. Horizon added it last, so
the goals drew their random intervals in another order and a wither
skeleton's random stream drifted from vanilla's on its second tick.

Adds a wither_skeleton_player scenario to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:04:55 +01:00
Federico TaglianiandClaude Sonnet 5.5 5071edb248 Mobs: the path maluses vanilla's constructors set (fire, lava, powder snow, cocoa, trapdoors)
Animal, AbstractVillager, AbstractPiglin and CopperGolem set FIRE to -1 and
FIRE_IN_NEIGHBOR to 16; Fox, Parrot, Frog, Goat, Wolf, Blaze, ZombifiedPiglin
and WitherSkeleton set their own. Horizon left every mob on PathType's
defaults (FIRE 16, FIRE_IN_NEIGHBOR 8), so animals, villagers and piglins
walked through fire that vanilla paths around.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:55:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 94eff29428 Villagers: the trade screen closes when the trader is gone or out of reach
AbstractVillager.stillValid keeps a trade screen open only while the trader
lives and the player is within the entity interaction range plus 4 of its
box. Neither the host's own trade screen nor a guest's (which includes the
single-player window on the server thread) was checked, so the screen
stayed open after walking away.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:36:20 +01:00
Federico TaglianiandClaude Sonnet 5.5 136349ae95 Villagers: a baby that grows up gets the adult brain (Villager.ageBoundaryReached)
Horizon built a villager's brain once, with its baby flag, so a villager
that grew up kept the baby schedule (play) and never went to work. At the
age boundary the brain is now rebuilt as Villager.refreshBrain does: running
behaviours stop, the packed memories carry over and the sensors' first scans
are delayed by draws from the mob's random.

Adds villager_grows_up scenarios to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:22:45 +01:00
Ludovic MarieandClaude Opus 5.5 5cdf9a9151 Docs: Coordinates HUD joins Horizon's official native mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:09:02 +01:00
Username59138andClaude Opus 5.5 fa77b35140 Creative inventory: the Saved Hotbars tab, as vanilla shows it
The HOTBAR tab was skipped since the creative tabs came in (#49, "Not
covered: ... the saved-hotbars tab"), while saving and loading toolbars
with C or X and a hotbar key already worked.

- CreativeModeInventoryScreen.selectTab on the HOTBAR tab: the nine
  saved rows of hotbar.nbt; an empty row shows a paper in its own slot
  with creative_slot_lock and the item name inventory.hotbarInfo, named
  after the bound save and hotbar keys. The rows are read when the tab
  is selected and when the inventory opens on it.
- CustomCreativeSlot.mayPickup: a locked stack can't be clicked.
- Creative::shown gives a grid cell's stack, None for a toolbar's empty
  slot, for the clicks, the hover, the drawing and horizon-ctl.
- VanillaCreativeSearchOracle compared the tabs without the HOTBAR one,
  which Horizon didn't show; it now compares every tab vanilla shows
  (shouldDisplay). The tab's display items are empty on both sides: its
  contents come from HotbarManager when it is selected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:52:12 +03:00
masa-masa-0314andZCode Agent 845dbae91b Fix a recursive fallback in the world content tables' accessors
`Game::items`/`recipes`/`stations` fell back to themselves when the world
had no data packs instead of to `Resources.rest`, so a world without packs
recursed until the thread spun at 100%: the window never joined its
integrated server and showed "Loading terrain..." forever.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 20:42:34 +08:00
Ludovic Marie a167a89215 Merge branch 'draft/remove-wasm-mods-crates' into draft/remove-wasm-mods-launcher 2026-10-29 13:39:00 +01:00
Ludovic Marie 953a5a84ee Merge branch 'draft/remove-wasm-mods-launcher' into draft/remove-wasm-mods-docs 2026-10-29 13:39:00 +01:00
jaszkusandjaszkus's AI agent 37a2cc0057 Add the Debug Stick
DebugStickItem: a left click selects a block-state property and a right click cycles its value (Shift reverses it), stored in the minecraft:debug_stick_state component. The client never breaks the block and swings on use; the server cycles the state and sends the block update.

The sneak direction follows the acting player's sneak input (host, toggled sneak or guest), a guest's command permission is swapped for its turn so game-master checks use the guest's level, and useOn returns the vanilla SUCCESS/FAIL, triggers ITEM_USED_ON_BLOCK on success and only swings the local actor's client.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 13:37:04 +01:00
Ludovic Marie 029870bb90 Merge branch 'draft/remove-wasm-mods-launcher' into draft/remove-wasm-mods-docs
# Conflicts:
#	README.md
#	ROADMAP.md
#	roadmap/roadmap.json
#	roadmap/roadmap.toml
2026-10-29 13:32:46 +01:00
Ludovic Marie 1afbdca0f0 Merge branch 'draft/remove-wasm-mods-crates' into draft/remove-wasm-mods-launcher 2026-10-29 13:26:49 +01:00
martipopsandClaude Opus 5.5 5601fea75e Let /damage hurt players other than the acting one
cmd_damage only handled the window's own player and mobs: any other player
(a LAN guest, a dedicated server's player, a splitscreen player) fell through
to "Target is invulnerable". Run it as that player through for_player, as
/effect does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82HU35NWsN14K4taHT
2026-10-29 07:53:41 -04:00
d3grandoandClaude Opus 5.5 158075a8e8 Eating: the eater hears each bite once
Consumable.emitParticlesAndSounds plays its bite through
Player.playSound, which is level.playSound(player, ...): the server
sends it to everyone but the eater, whose own client plays it. A guest's
server turn played it into the broadcast to all, so the eater (every
single-player player, on the server thread) heard each bite twice: its
client's and the server's.

The server now sends a guest's Player.playSound to the others only, and
the client plays the last bite itself at USE_ITEM_COMPLETE
(completeUsingItem's emitParticlesAndSounds(16)), which it drew the
crumbs of but left silent. lan_e2e's eat check counts the bites heard
against the crumb bursts drawn.

Fixes #3129

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:41:12 +07:00
d3grandoandClaude Opus 5.5 e8c1a797c8 Mooshrooms keep their colour: save Type, sync DATA_TYPE at 18
MushroomCow.addAdditionalSaveData / readAdditionalSaveData were never
ported, so a brown mooshroom (summoned, or turned by lightning) loaded
back red. MushroomCow.DATA_TYPE is entry 18 (after AgeableMob's
DATA_BABY_ID and AGE_LOCKED), not 17: on a vanilla server the colour
change from MushroomCow.thunderHit never reached the renderer, and a
Horizon host sent an INT where vanilla clients expect AGE_LOCKED's
BOOLEAN.

Fixes #3236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:39:41 +07:00
d3grandoandClaude Opus 5.5 9fa7dddbe4 Let parrots fly: flying navigation and move control, flapping and gliding
Parrot.createNavigation and Parrot.calculateFlapping had no port, so a
parrot walked and hopped like a land mob, fell like a stone off a block
and never flew, tame or not.

- Parrot(): FlyingMoveControl(10, false), whose idle tick lets gravity
  back on; the FIRE_IN_NEIGHBOR, FIRE and COCOA maluses at -1; and
  createNavigation's FlyingPathNavigation (no doors, floats).
- ParrotWanderGoal: in water, LandRandomPos(15, 15); otherwise (all but
  one time in a thousand) getTreePos, the first empty block with an
  empty one above over leaves or a log within 3/6/3 in betweenClosed
  order; else WaterAvoidingRandomFlyingGoal.getPosition, now shared as
  Mob::flying_stroll_pos.
- calculateFlapping after aiStep: the wing state (folded on the ground
  or as a passenger), and a fall through the air slowed to 0.6. A
  multiplayer client runs the wing part for the parrots it displays.
- Parrot.isFlapping/onFlap from Entity.processFlappingMovement: the
  entity.parrot.fly beat each half flapSpeed flown.
- canFlyToOwner: a parrot may teleport onto leaves next to its owner.

tools/test_mob_ai_parity.py gets four parrot scenarios (two on open
grass, one by a tree, one let go ten blocks up), on Peaceful because
Parrot.getAmbient's imitation roll uses the level's random. They match
vanilla on every tick; on main they diverge at tick 1.

Fixes #3091

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:29:08 +07:00
iLostName 3c9b2599d8 block placement: a click on a block is handled before the move behind it, so pillar blocks are heard
The hub applied a player's move before the game ran the UseItemOn sent ahead of it, so BlockItem.canPlace checked the box after the move: mid-jump the server placed a block the client had refused, and only the client plays the place sound. The move now waits for the next tick, as it already does behind a dig. Fixes #3568.
2026-10-29 11:50:46 +01:00
iLostName ce2b51090f minecart: right-clicking a tnt minecart uses the held item, so a crossbow shoots at it
MinecartTNT has no interact of its own, so Entity.interact passes and Minecraft.startUseItem goes on to the item; the server client ended every click on a minecart there. A command block minecart without a game master, and a rideable one when sneaking or ridden, pass the same way. Fixes #3588.
2026-10-29 11:48:45 +01:00
iLostName 30d33e077f off hand: filling a composter or using bone meal ends the click before the off-hand torch
ComposterBlock.useItemOn and BoneMealItem.useOn are SUCCESS on vanilla's client, so Minecraft.startUseItem never tries the off hand; the server client predicted both as passing. A sapling, azalea or mushroom still passes there, as isValidBonemealTarget needs a ServerLevel. Fixes #3536.
2026-10-29 11:44:34 +01:00
degepieandClaude Opus 5.5 35345be102 Fish form schools: port FollowFlockLeaderGoal (#3026)
Cod, salmon and tropical fish had only panic, player avoidance and random
swimming. AbstractSchoolingFish adds FollowFlockLeaderGoal (priority 5, no
flags): after a 100-110 tick start delay a fish joins a nearby school with
room or gathers the free fish around it, and followers path to their
leader while it lives and is within 11 blocks. FishSwimGoal stops while the
fish follows (canRandomSwim), a leader forgets an empty school on a 1/200
level-random roll, and schools hold 8 (5 for salmon).

Recruiting changes other fish, so it goes through two MobEvents that
apply_brain_events applies after the recruiter's tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 15:09:49 +05:00
masa-masa-0314andZCode Agent 6e2ac2522d Re-trigger the pull request checks (no run arrived for the head commit)
Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 17:42:05 +08:00
masa-masa-0314andZCode Agent e72a8c5c6e Data packs: load the world's packs into the content tables
A world's data packs now change what the game plays with, the way
`ReloadableServerResources` loads a server's: recipes, loot tables,
advancements and tags from `<world>/datapacks` merge over the resource
root's own, at world open and on `/reload`. A pack's recipe or
advancement replaces the game's file for the same id
(`SimpleJsonResourceReloadListener` hands out the top resource of each
stack); tag files join the game's entries with `replace` dropping the
lower sources (`TagLoader.load`). Worlds without packs keep loading
exactly as before.

`datapack::DataStore` collects the packs' `recipe/`, `loot_table/`,
`advancement/` and `tags/…/` files (`DataStore.files`, the built-in
pack excluded: its files are the resource root's own, which the tables
read from disk). `horizon_world::loot::PackOverlay` carries them into
the loaders: `Items` (loot tables, item/block/entity_type tags),
`Recipes`, `StationData` (smithing, stonecutting, brewing, the recipe
book index), `advancements::Tree` and `LootData` (structure-block
containers). The game keeps the pack tables in `Game::world_tables`
and reads them through `Game::items`/`recipes`/`stations` (the
`world_items!`-family macros give the same field-level borrows at
conflict sites), rebuilding them when the world opens and on
`/reload`, with the advancement tree and the loot and LAN sync caches
forgotten alongside.

Not covered yet: worldgen registries, trade/banner/enchantment
registry files from packs, and the prepare-time behavior bundles
(mining facts stay with `block-behaviours.json`).

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 17:42:05 +08:00
Muhammad Asif c74d170fbc rustfmt pearl and wind-charge boxes 2026-10-29 14:36:21 +05:00
Ludovic MarieandClaude Opus 5.5 21773ca7f5 Merge main into this branch
streaming.rs moved to horizon-client-render (#3186) and main now sweeps far
columns only on some updates (if sweep); this branch's changes (no area at a
negative radius, rank_full_task, its test) are applied there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:33:55 +01:00
Muhammad Asif ce45e065c3 Let thrown pearls hit wind charges
AbstractWindCharge is pickable, so ThrownEnderpearl's
ProjectileUtil.getEntityHitResult can strike it and onHit teleports.
Horizon only targeted mobs and players, so a pearl passed through a
charge. Add the charge's entity box to the pearl's targets.
2026-10-29 14:23:49 +05:00
degepieandClaude Opus 5.5 861be0b075 Armour: worn pieces and the elytra show the enchantment glint
The player's armour layers (local player, other players, LAN guests)
never carried the glint, and the elytra drew with overlay 0. The armour
glint's overlay code, 13, was also the boat water mask's, so a glinting
layer drew depth only: an armour stand's enchanted chestplate was
invisible.

EquipmentLayerRenderer.renderLayers: each layer and its trim take the
armour glint when ItemStack.hasFoil, never the hurt tint (NO_OVERLAY).
ARMOR_GLINT moves to 35, past every material overlay of the entity
batch.

Fixes #3024.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 13:50:58 +05:00
Senjin c075240fa3 Merge remote-tracking branch 'origin/main' into hdr-screenshot 2026-10-29 08:32:56 +01:00
Ludovic MarieandClaude Opus 5.5 7d43179850 Docs: the official native mods in the hook points section
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Opus 5.5 64ff13890a Launcher: WebAssembly mods need a native port
The registry keeps its WebAssembly mods (Mod Menu, lookat, blockscope…)
in the index, so the modpacks that need them keep installing. The browser
neither lists nor installs one: its versions say "needs a native port".
A modpack still installs it, with its code left out (the game doesn't run
it), and Installed shows "Not ported: its code (needs a native port…)".
A WebAssembly mod is a `.hmod` that may do more than add content; content
mods are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 708f0b48bf Docs: WebAssembly code mods are gone; content and native mods remain
docs/mods.md is now about content mods (`cargo horizon-mod`, the manifest, the
content fence), native mods, publishing, the official native mods and a short
note on the removed WebAssembly mods. docs/api/mods.md and mods-reference.md
(the code API) are deleted. Other pages, the CLAUDE.md crate table and the
roadmap (five WebAssembly API items removed, others reworded for native mods)
no longer describe the sandbox, the SDK or the WIT.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
ThatCakeIsAPieandClaude Opus 5.5 9e91278794 Chunk packets map between the level and the client's column by the level's shape
The LAN encoder encodes a level of a LevelShape: its chunks hold the
shape's column, of which the level's own sections go on the wire, and the
heightmaps it sends count the column's sections from the chunk's min_y
(client_heightmaps_in takes it). A section's block entities sit at the
chunk's min_y. The remote client decodes chunk and light packets into the
column its ChunkFormat holds (the vanilla one until the server's
dimension types set it) instead of the fixed -64..320. A test encodes a
131-section column and checks its sections and light layers all go out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:56 -05:00
ThatCakeIsAPieandClaude Opus 5.5 fdb4e917c0 Saves read and write a chunk's own column, and SaveDir takes its level's shape
SaveDir::new takes the level's LevelShape from its caller instead of
guessing it from the folder name (LevelShape::for_dir goes). A saved chunk
is decoded into its level's column (decode_with, decode_proto), the
sections outside it left out; encoding, POI sections, heightmaps and the
saved light layers count sections from the chunk's own min_y, and distant
terrain read from saves spans the level's column. A test saves a
131-section chunk (yPos -4, 12-bit heightmaps) and reads it back whole,
and into the vanilla column without the sections above it.

Vanilla levels pass their vanilla shapes, so saves are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:55 -05:00
ThatCakeIsAPieandClaude Opus 5.5 c2b55479f1 World, streaming, meshing and the renderer read the level's column
World holds its level's LevelShape, and its chunks' column is the shape's.
Writes check the chunk's own range, the heightmap fallbacks and may_hold
read the column, and dirty section indexes are bounded by the mask instead
of the vanilla constant. Streaming marks the column's sections dirty, the
mesher places sections from the chunk's min_y, and the renderer builds its
occlusion graph, complete-column mask and section origins for the column
of the level it was cleared for (clear_sections takes it). A test streams
a 131-section level (To The Max's height) and checks that columns light
and mesh to y 2031, and that an edit at y 1800 relights and remeshes.

Every level is still the vanilla Overworld's shape here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
ThatCakeIsAPieandClaude Opus 5.5 a6d8409466 Light reads the chunk's column: flood fill, layers and relighting span its sections
light_chunk floods up to the centre chunk's own top, and the light it
returns, the saved and received light layers and the relighter's section
keys span the chunk's sections instead of the vanilla constants. The
relighter takes the column from the world it lights (LightWorld::layout,
the column its chunks hold), and LightChanges counts section indexes in
that column, so a taller level's sections near its top get relit and
remeshed too. Vanilla's column is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
Lyle ColeandClaude Opus 5.5 46c62bdf2a LevelShape: each level's shape as one value, and the column its chunks hold
LevelShape (skylight, min_y, height, and now logical_height) moves from
save.rs to chunk.rs, where every subsystem can read it, and checks a
dimension type as its codec does. Its column is the span of sections a
chunk of the level holds: the level's own range widened to the vanilla
Overworld's -64..320, so vanilla's three levels keep the 24 sections
they have always had. ColumnLayout is that span
as a value; Chunk records its own min_y, and get, set, highest, light,
biome and the heightmap cache read the chunk's sections instead of the
constants.

Nothing makes a chunk of another column yet: vanilla is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
Lyle ColeandClaude Opus 5.5 0c639904f1 Section sets past 32 sections: SectionMask replaces the u32 column masks
Dirty sections, the light edit log, random-tick and portal section caches,
the renderer's meshed-column masks and the LAN encoder's light masks held a
bit per section in a u32 or u64, which capped a column at 32 sections. A
256-bit SectionMask holds every layout a dimension type allows (254 sections
plus the two light borders). No behaviour change: the column is still 24
sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
SenjinandClaude Opus 5.5 9c18b4e524 HDR: the log says where the HDR screenshot went
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:09:30 +01:00
SenjinandClaude Opus 5.5 c7b02f8568 HDR: screenshots get an HDR10 copy, as a PNG tagged BT.2020 with PQ
F2 kept only the SDR frame. With HDR on it now also writes `…-hdr.png` beside
vanilla's screenshot: the HDR pass drawn once more into a 10-bit PQ target of its
own (so scRGB swapchains get one too), read back and saved as a 16-bit PNG with
the cICP chunk of PNG's third edition (BT.2020, PQ, full range) and cLLI (peak,
paper white). Each 10-bit value keeps its top bits below it, so 0 stays 0 and
1023 becomes 65535. Vanilla's screenshot is written as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:54:07 +01:00
FusselTVandClaude Opus 5.5 8401cde519 Dedicated server: nothing kept around the spawn while nobody is on
While nobody was online, the dedicated server behaved as if a player
stood at the world spawn: at startup it streamed a view-distance area
there and placed a stand-in player, and afterwards it kept that area
loaded, generated and ticked. Vanilla only loads the chunks its spawn
spiral tests (MinecraftServer.setInitialSpawn: getChunk(FULL), whose
3x3 is decorated in one generation task), has no spawn ticket, and finds
a player's spawn when one joins. In a pre-generated world the startup
area decorated in another order made ore blobs and a mineshaft start's
saved flags near spawn differ from vanilla's.

A streaming radius below 0 now means no area around the centre. The
dedicated server only finds the world spawn, loading each tested chunk
as an extra area of radius -1 ranked as one FULL task (x-outer,
z-inner), and keeps the radius at -1 while no guest is online.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:36:12 +01:00
Ludovic Marie 9bb3fc077e Merge #2570 2026-10-27 06:43:30 +01:00
Ludovic MarieandClaude Opus 5.5 2aa9056259 Structure mobs test: pass the world preset for_dimension now takes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:30 +01:00
Ludovic Marie 8117e2767f Merge main 2026-10-27 06:43:23 +01:00
Ludovic MarieandClaude Opus 5.5 bc5ec6fb46 Structure mobs test: pass the world preset #2570 added to for_dimension
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:39 +01:00
Ludovic Marie e5ebeddb90 Merge #2585
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-10-27 06:42:33 +01:00
Ludovic Marie d0b42fb88e Merge #2570 2026-10-27 06:42:19 +01:00
Ludovic Marie bca77d1ac4 Merge origin/main 2026-10-27 06:42:18 +01:00
Ludovic MarieandClaude Opus 5.5 815c09b1f8 Merge #2214 (world presets in the create-world screen)
The superflat screen and the agent's state read the world preset that
#2214 adds instead of the old flat flag; drop a stray parenthesis in
the horizon-prepare hint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:03 +01:00
Claude Codeandminimax-m3.1-flash 0104eeaf33 Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:02 +05:00
Claude Codeandminimax-m3.1-flash 729a224eef Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:52:31 +05:00
FusselTVandClaude Opus 5.5 22054eee90 Worldgen: chunk generation spawns its animals as vanilla, ticking or not
NaturalSpawner.spawnMobsForChunkGeneration runs in the generation
pipeline (spawnOriginalMobs), with the WorldGenRegion's random for the
spawn rules and finalizeSpawn. Horizon ran it from the game tick for
chunks that were fresh and lit, so:

- a world generated with ticks frozen, as a seed generator does, got no
  animals at all, and a chunk saved before a tick saw it lost them;
- the rules and finalizeSpawn drew from other randoms, and the reduced
  spawning::finalize gave no variants, babies or group data;
- the spawn list came from the biome's noise cell, not the zoomed biome
  (fullResolutionBiomes) vanilla reads;
- members of a pack were kept apart, though a WorldGenRegion has no
  entities in the way.

A chunk now gets its animals as soon as it is lit, whatever the tick
state. The positions come from the same selection, the rules and the
finalize from the chunk's region random, and each mob is made with its
constructor's state, finalized like a structure mob
(Reason::ChunkGeneration, group data per pack) and loaded from the NBT
generation would save. The natural spawn oracle's generation mode sees
no entities, as a WorldGenRegion.

Goats and frogs keep their long jump and ram cooldowns under vanilla's
memory names (long_jump_cooling_down, ram_cooldown_ticks), and rabbits
save the attack damage attribute Rabbit.setVariant makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:18 +01:00
Claude Codeandminimax-m3.1-flash 52f0e38d41 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:20:01 +05:00
FusselTVandClaude Opus 5.5 1a80673d50 Worldgen: structure mobs save the fields vanilla's chunk keeps
ProtoChunk.addEntity saves a structure's entity as soon as it is placed,
and the chunk loads it again when it becomes full, so vanilla keeps what
each entity's own save writes. Horizon kept the template's NBT with its
model on top, and its model left out some fields:

- the keys in vanilla's templates that no entity reads (food_level,
  lastRestock, CatType, OwnerUUID, ...) are dropped;
- villagers save VillagerDataFinalized and their age (AgeableMob writes
  Age, ForcedAge and AgeLocked whenever canBeABaby, not only for
  creatures; never for a frog, a parrot or a zombie nautilus), and the
  age is read back for the same mobs (a baby villager or hoglin no
  longer grows up on load);
- iron golems and the other neutral mobs save anger_end_time;
- zombies, drowned and piglins save the movement speed attribute that
  their setBaby creates on load;
- a drowned with a nautilus shell keeps drop_chances (offhand 2.0), and
  saved drop chances are read back;
- the rider of a new chicken jockey is saved as it was when the chicken
  was added, before its equipment and attributes, as vanilla's chunk
  saves the chicken with its passenger at that point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:29 +01:00
324 changed files with 16423 additions and 3945 deletions

No files matched your search

+3 -1
View File
@@ -22,7 +22,9 @@ jobs:
if: >-
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/claim') ||
startsWith(github.event.comment.body, '/unclaim'))
startsWith(github.event.comment.body, '/unclaim') ||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
+2 -5
View File
@@ -53,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `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-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
@@ -73,7 +73,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
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 test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
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
@@ -93,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
- 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 predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-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`,
+4 -1
View File
@@ -7,6 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -48,7 +49,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
+9 -9
View File
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
@@ -350,7 +350,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
</details>
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
@@ -380,7 +380,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
<details><summary>Technical</summary>
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
#### Orbital Laser on the normal game
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
<details><summary>Technical</summary>
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
</details>
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
</details>
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
<details><summary>Technical</summary>
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
</details>
+266 -7
View File
@@ -21,18 +21,19 @@ use horizon_protocol::{
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
item::Stack,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
play, recipes,
},
};
use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
Arc, LazyLock, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -44,7 +45,22 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
flood-packets (--flood rotations a tick),
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
moved-too-quickly check allows, never waiting for chunks),
and --flood a tick of packets that cost the server work:
flood-use (place and break stone around itself), flood-click (inventory
clicks with a stale state), flood-suggest (command suggestions, every
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
@@ -59,6 +75,16 @@ enum Behaviour {
Idle,
Walk,
Explore,
FloodMove,
FloodAcks,
FloodPackets,
FloodChunks,
FloodUse,
FloodClick,
FloodSuggest,
FloodBook,
FloodSign,
FloodRecipe,
}
impl Behaviour {
@@ -67,6 +93,16 @@ impl Behaviour {
Self::Idle => "idle",
Self::Walk => "walk",
Self::Explore => "explore",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
Self::FloodChunks => "flood-chunks",
Self::FloodUse => "flood-use",
Self::FloodClick => "flood-click",
Self::FloodSuggest => "flood-suggest",
Self::FloodBook => "flood-book",
Self::FloodSign => "flood-sign",
Self::FloodRecipe => "flood-recipe",
}
}
}
@@ -81,6 +117,8 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
flood: usize,
no_read: bool,
name: String,
report: Option<String>,
}
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
flood: 100,
no_read: false,
name: "Bot".into(),
report: None,
};
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
print!("{HELP}");
std::process::exit(0);
}
if arg == "--no-read" {
o.no_read = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
const BEHAVIOURS: [&str; 14] = [
"idle",
"walk",
"explore",
"mixed",
"flood-move",
"flood-acks",
"flood-packets",
"flood-chunks",
"flood-use",
"flood-click",
"flood-suggest",
"flood-book",
"flood-sign",
"flood-recipe",
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -156,6 +217,12 @@ struct Bot {
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// When the bot asked to join, and how long after that it got the last
/// chunk new to it: for a bot that stands still, its first full view.
asked: Mutex<Option<Instant>>,
view_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -185,6 +252,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
w.data
}
/// Item ids of the pinned version (26.3's item registry).
const STONE: i32 = 1;
const WRITABLE_BOOK: i32 = 1371;
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
fn costly(
behaviour: Behaviour,
i: usize,
sequence: i32,
pos: [f64; 3],
random: &mut Random,
) -> Option<Vec<u8>> {
use ids::play::serverbound as sb;
static LONG_COMMAND: LazyLock<String> =
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
play::Serverbound::EditBook(play::EditBook {
slot: 0,
pages: vec!["a".repeat(1024); 100],
title: None,
})
.encode()
});
let block = pos.map(|v| v.floor() as i32);
let packet = match behaviour {
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
} else {
play::Serverbound::PlayerAction(play::PlayerAction {
action: 0,
pos: at,
direction: 1,
sequence,
})
}
}
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
container: 0,
state: 0,
slot: 36 + (i % 9) as i16,
button: 0,
input: 0,
changed: Vec::new(),
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
"/"
} else {
LONG_COMMAND.as_str()
};
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
w.varint(sequence);
w.string(text);
}));
}
Behaviour::FloodBook => return Some(BOOK.clone()),
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
pos: [block[0], block[1] - 1, block[2]],
lines: std::array::from_fn(|_| "a".repeat(384)),
front: true,
}),
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
container: 0,
recipe: (i % 2042) as i32,
use_max: true,
}),
_ => return None,
};
Some(packet.encode())
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
@@ -339,6 +489,12 @@ fn read(
let mut batch = BatchSize::new();
let mut teleports = 0u64;
loop {
if bot.deaf.load(Ordering::Relaxed) {
while bot.online.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
return;
}
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
@@ -363,6 +519,9 @@ fn read(
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !lock(&bot.held).insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
} else if let Some(asked) = *lock(&bot.asked) {
bot.view_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::Relaxed);
}
if seen.insert((x, z)) {
bot.unique.fetch_add(1, Ordering::Relaxed);
@@ -457,6 +616,7 @@ fn run_bot(
) {
let name = format!("{}{index}", o.name);
let asked = Instant::now();
*lock(&bot.asked) = Some(asked);
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
@@ -489,6 +649,24 @@ fn run_bot(
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: 36,
item: tool,
})
.encode(),
);
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
@@ -504,7 +682,38 @@ fn run_bot(
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !lock(&bot.held).contains(&here) {
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 64.0 + random.unit() * 192.0;
pos[0] += angle.cos() * reach;
pos[2] += angle.sin() * reach;
send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
);
}
let angle = random.unit() * std::f64::consts::TAU;
pos[0] += angle.cos() * 128.0;
pos[2] += angle.sin() * 128.0;
} else if behaviour == Behaviour::FloodChunks {
// Up to cruising height first, then just under the server's 10 blocks a tick:
// every tick asks for new chunks.
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
pos[0] += heading.cos() * 9.9;
pos[2] += heading.sin() * 9.9;
}
} else if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -529,7 +738,37 @@ fn run_bot(
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
Behaviour::FloodMove
| Behaviour::FloodChunks
| Behaviour::FloodUse
| Behaviour::FloodClick
| Behaviour::FloodSuggest
| Behaviour::FloodBook
| Behaviour::FloodSign
| Behaviour::FloodRecipe => {}
Behaviour::FloodPackets => {
for i in 0..o.flood {
send(
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
yaw: (i % 360) as f32,
pitch: 0.0,
flags: 0,
})
.encode(),
);
}
}
Behaviour::FloodAcks | Behaviour::Explore => {
if behaviour == Behaviour::FloodAcks {
for _ in 0..o.flood {
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
})
.encode(),
);
}
}
// Flying in creative, by default about 22 blocks a second, which keeps new
// chunks coming as fast as the server sends them.
let step = o.explore_speed / 20.0;
@@ -538,6 +777,13 @@ fn run_bot(
}
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
send(body);
}
}
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
@@ -569,6 +815,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
"idle" => Behaviour::Idle,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
"flood-chunks" => Behaviour::FloodChunks,
"flood-use" => Behaviour::FloodUse,
"flood-click" => Behaviour::FloodClick,
"flood-suggest" => Behaviour::FloodSuggest,
"flood-book" => Behaviour::FloodBook,
"flood-sign" => Behaviour::FloodSign,
"flood-recipe" => Behaviour::FloodRecipe,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
@@ -663,6 +919,9 @@ fn main() {
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
// Only an idle bot's last new chunk ends its first view.
"view_after_ms": (behaviour_of(&o.behaviour, i) == Behaviour::Idle)
.then(|| b.view_after_ms.load(Ordering::Relaxed)),
"chunks": b.chunks.load(Ordering::Relaxed),
"unique_chunks": b.unique.load(Ordering::Relaxed),
"resent": b.resent.load(Ordering::Relaxed),
@@ -55,6 +55,12 @@ particle_types! {
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
NoxiousGas = "noxious_gas",
NoxiousGasCloud = "noxious_gas_cloud",
Geyser = "geyser",
GeyserBase = "geyser_base",
GeyserPoof = "geyser_poof",
GeyserPlume = "geyser_plume",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
@@ -149,6 +155,10 @@ impl ParticleType {
matches!(
self,
Self::BlockMarker
| Self::Geyser
| Self::GeyserBase
| Self::GeyserPoof
| Self::GeyserPlume
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
@@ -191,6 +201,8 @@ impl ParticleType {
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// NoxiousGasParticle.
| Self::NoxiousGas
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
@@ -13,6 +13,12 @@ pub enum Button {
}
pub trait InteractionWorld {
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
/// every click of the tick reads, even after one of them changed the
/// block it names (a creative break, then a use that replaces it).
fn pick_hit_result(&mut self);
/// The tick's clicks are done: later picks follow the crosshair again.
fn release_hit_result(&mut self);
fn using_item(&self) -> bool;
fn release_using(&mut self);
fn start_attack(&mut self) -> bool;
@@ -48,6 +54,7 @@ impl InteractionInput {
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
world.pick_hit_result();
let mut instant = false;
if world.using_item() {
if !use_down {
@@ -70,6 +77,7 @@ impl InteractionInput {
world.start_use();
}
world.continue_attack(!instant && attack_down);
world.release_hit_result();
}
}
@@ -86,6 +94,8 @@ mod tests {
events: Vec<&'static str>,
}
impl InteractionWorld for World {
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -183,4 +193,67 @@ mod tests {
input.tick(&mut world, false, false);
assert_eq!(world.events, ["release", "continue"]);
}
/// A floating block in front of the crosshair, nothing behind it, and a
/// block item in the hand of a creative player.
#[derive(Default)]
struct Column {
block: bool,
hit: Option<Option<()>>,
placed: bool,
}
impl Column {
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
fn target(&self) -> Option<()> {
self.hit.unwrap_or(self.block.then_some(()))
}
}
impl InteractionWorld for Column {
fn pick_hit_result(&mut self) {
self.hit = Some(self.target());
}
fn release_hit_result(&mut self) {
self.hit = None;
}
fn using_item(&self) -> bool {
false
}
fn release_using(&mut self) {}
fn start_attack(&mut self) -> bool {
// MultiPlayerGameMode.startDestroyBlock in creative: the
// predicted break leaves air.
if self.target().is_some() {
self.block = false;
}
!self.block
}
fn start_use(&mut self) {
// BlockItem.place: the clicked block, now air, can be replaced.
if self.target().is_some() && !self.block {
self.block = true;
self.placed = true;
}
}
fn pick(&mut self) {}
fn use_delay(&self) -> u32 {
0
}
fn continue_attack(&mut self, _: bool) {}
}
#[test]
fn attack_and_use_in_one_tick_share_the_hit_result() {
// #3006: both buttons at once replace the block the crosshair is on.
let mut input = InteractionInput::default();
let mut world = Column {
block: true,
..Default::default()
};
input.press(Button::Attack);
input.press(Button::Use);
input.tick(&mut world, false, false);
assert!(world.placed, "the use acts on the block the attack broke");
assert!(world.block);
assert!(world.hit.is_none(), "the next pick follows the crosshair");
}
}
+16 -2
View File
@@ -192,9 +192,17 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
}
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -218,7 +226,11 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
if pixels {
sprites.finish();
} else {
sprites.finish_without_pixels();
}
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
@@ -241,6 +253,7 @@ impl RenderAssets {
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
use horizon_world::loot::PackOverlay;
#[test]
fn always_edible_and_consume_effects_loaded() {
@@ -251,7 +264,8 @@ mod tests {
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let items =
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
impl BlockEntityRenderer {
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
let visibility = crate::models::load_visibility(root, registry)?;
Self::load_with_visibility(root, registry, visibility)
}
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
pub fn load_with_visibility(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> crate::Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
@@ -2363,6 +2373,7 @@ mod tests {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
..Default::default()
};
let first_text = crate::signs::SignData {
front: text("one", false),
+35 -16
View File
@@ -7,6 +7,7 @@ use crate::{
items::{ItemStack, Items},
providers::Providers,
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
@@ -142,20 +143,41 @@ pub struct Brewing {
pub inputs: HashSet<String>,
}
impl Brewing {
pub fn load(root: &Path, items: &Items) -> Self {
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
files.sort();
let mut recipes = Vec::new();
for p in files {
let Ok(bytes) = fs::read(&p) else { continue };
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
if !id.starts_with("minecraft:brewing/") {
continue;
}
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
continue;
};
match sources.iter_mut().find(|(n, _)| n == id) {
Some(slot) => slot.1 = v,
None => sources.push((id.to_owned(), v)),
}
}
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
if v["type"] != "minecraft:brewing" {
continue;
}
@@ -169,10 +191,7 @@ impl Brewing {
output = output.with_potion(potion);
}
recipes.push(BrewingRecipe {
id: format!(
"minecraft:brewing/{}",
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
),
id,
input: PotionIngredient::parse(&v["input"], items),
reagent: PotionIngredient::parse(&v["reagent"], items),
output,
+263 -18
View File
@@ -8,10 +8,159 @@ use std::sync::{Arc, OnceLock};
pub mod block_entity;
/// The vanilla Overworld's column, which every dimension's chunks hold for
/// now (`LevelShape::column`).
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
pub logical_height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
logical_height: 256,
};
/// A dimension type's shape, checked as its codec checks it.
pub fn new(
min_y: i32,
height: i32,
logical_height: i32,
sky_light: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
return Err(format!(
"logical_height {logical_height} is outside 0..={height}"
));
}
Ok(Self {
sky_light,
min_y,
height,
logical_height,
})
}
/// One above the highest block (`getMaxY() + 1`).
pub fn max_y(self) -> i32 {
self.min_y + self.height
}
pub fn min_section(self) -> i32 {
self.min_y >> 4
}
pub fn section_count(self) -> usize {
(self.height >> 4) as usize
}
/// Whether section `y` (a section coordinate) is inside the level.
pub fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
/// The sections a chunk of this level holds: the level's, widened to the
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
let low = self.min_y.min(MIN_Y);
let high = self.max_y().max(MIN_Y + HEIGHT);
ColumnLayout {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
/// (`LevelShape::column`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColumnLayout {
min_y: i32,
sections: usize,
}
impl ColumnLayout {
pub const VANILLA: Self = Self {
min_y: MIN_Y,
sections: SECTIONS,
};
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
/// A dimension type's `min_y` and `height`, as its codec accepts them:
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
if min_y % 16 != 0 || height % 16 != 0 {
return Err(format!(
"min_y {min_y} and height {height} must be multiples of 16"
));
}
if height < 16 {
return Err(format!("height {height} is below 16"));
}
if min_y < Self::LOWEST_Y
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
{
return Err(format!(
"min_y {min_y} with height {height} leaves {}..={}",
Self::LOWEST_Y,
Self::HIGHEST_Y
));
}
Ok(Self {
min_y,
sections: (height / 16) as usize,
})
}
pub fn min_y(self) -> i32 {
self.min_y
}
/// One above the highest block.
pub fn max_y(self) -> i32 {
self.min_y + self.height()
}
pub fn height(self) -> i32 {
self.sections as i32 * 16
}
pub fn sections(self) -> usize {
self.sections
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
/// The minimum block Y of section `index`.
pub fn section_y(self, index: usize) -> i32 {
self.min_y + index as i32 * 16
}
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
}
}
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
@@ -138,9 +287,11 @@ impl Section {
pub struct Chunk {
pub x: i32,
pub z: i32,
/// The minimum block Y of `sections[0]` (the layout's when it was made).
min_y: i32,
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
@@ -169,11 +320,16 @@ pub struct Chunk {
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
/// An all-air chunk in the current `layout`.
pub fn empty(x: i32, z: i32) -> Self {
Self::with_layout(x, z, ColumnLayout::VANILLA)
}
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
Self {
x,
z,
sections: (0..SECTIONS)
min_y: layout.min_y(),
sections: (0..layout.sections())
.map(|_| Arc::new(Section::default()))
.collect(),
surface: OnceLock::new(),
@@ -190,13 +346,36 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
self.min_y
}
/// The column the chunk holds.
pub fn layout(&self) -> ColumnLayout {
ColumnLayout {
min_y: self.min_y,
sections: self.sections.len(),
}
}
/// One above the column's highest block.
#[inline]
pub fn max_y(&self) -> i32 {
self.min_y + self.sections.len() as i32 * 16
}
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
#[inline]
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
return 0;
match self.section_index(y) {
Some(s) => self.sections[s].get(index(x, y, z)),
None => 0,
}
self.sections[s as usize].get(index(x, y, z))
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -211,16 +390,15 @@ impl Chunk {
.rev()
.filter(|(_, section)| !section.is_uniform(skip))
.find_map(|(s, _)| {
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
(base..base + 16).rev().find(|&y| matches(y))
})
}
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
let Some(s) = self.section_index(y) else {
return v;
}
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
};
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
// `Heightmap.update`: a write two or more below a map's height can't
// move it. Any other change rebuilds that map on the next read.
if previous != v {
@@ -236,7 +414,8 @@ impl Chunk {
previous
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
@@ -244,7 +423,8 @@ impl Chunk {
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
@@ -252,7 +432,7 @@ impl Chunk {
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
let mut surface = Box::new([MIN_Y as i16; 256]);
let mut surface = Box::new([self.min_y as i16; 256]);
let mut found = [false; 256];
let mut left = 256;
let mut values = [0u32; 4096];
@@ -264,7 +444,7 @@ impl Chunk {
continue;
}
section.unpack(&mut values);
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
for i in 0..256 {
if found[i] {
continue;
@@ -280,11 +460,11 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - MIN_Y) >> 4;
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
}
if s >= SECTIONS as i32 {
if s >= self.sections.len() as i32 {
return 0xF0;
}
self.sections[s as usize].light(index(x, y, z))
@@ -297,7 +477,7 @@ impl Chunk {
/// the quart y clamped to the column).
#[inline]
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
self.sections[(qy >> 2) as usize]
.biomes
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
@@ -504,4 +684,69 @@ mod tests {
}
}
}
#[test]
fn shapes_follow_the_dimension_type_codec() {
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
// To The Max's Overworld: the highest top vanilla allows.
let tall = ColumnLayout::new(-64, 2096).unwrap();
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
assert!(ColumnLayout::new(-2032, 4064).is_ok());
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
assert!(
ColumnLayout::new(min_y, height).is_err(),
"{min_y} {height}"
);
}
assert_eq!(tall.section_index(-65), None);
assert_eq!(tall.section_index(-64), Some(0));
assert_eq!(tall.section_index(2031), Some(130));
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
}
#[test]
fn vanilla_levels_keep_the_overworld_column() {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
}
#[test]
fn a_tall_column_stores_its_whole_height() {
let layout = ColumnLayout::new(-64, 2096).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
assert_eq!(
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
(-64, 2032, 131)
);
let stone = 1;
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
assert_eq!(chunk.get(5, 2031, 6), stone);
assert_eq!(chunk.get(5, 2032, 6), 0);
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
assert_eq!(chunk.highest(0, |y| y < 1000), None);
chunk.set(5, 999, 6, stone);
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
assert_eq!(chunk.light(0, -65, 0), 0);
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
assert_eq!(chunk.biome(0, 5000, 0), 9);
}
}
@@ -80,6 +80,24 @@ pub enum BlockEntity {
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
PotentSulfur(PotentSulfur),
}
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
/// last eruption block event, which the client's plume counts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PotentSulfur {
pub waiting_countdown: i32,
pub eruption_tick: i64,
}
impl Default for PotentSulfur {
fn default() -> Self {
Self {
waiting_countdown: -1,
eruption_tick: -1,
}
}
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
@@ -228,6 +246,8 @@ pub struct SignText {
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
/// The `click_event` in each line's component style, as saved.
pub click: [Option<Tag>; 4],
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -236,6 +256,8 @@ pub struct SignData {
pub front: SignText,
pub back: SignText,
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
fn layout(&self) -> crate::chunk::ColumnLayout {
self.columns.layout()
}
}
#[cfg(test)]
@@ -3,7 +3,7 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -306,6 +306,9 @@ impl BlockEntity {
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -349,6 +352,7 @@ impl BlockEntity {
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -367,6 +371,7 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -391,6 +396,7 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
@@ -419,6 +425,7 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
+46 -1
View File
@@ -423,9 +423,31 @@ pub struct DataStore {
pub predicates: BTreeMap<String, serde_json::Value>,
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
/// Functions that failed to parse (id, message), for logging.
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" => kind,
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
}
_ => return None,
};
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
}
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -440,7 +462,22 @@ impl DataStore {
pub fn load(packs: &[&DataPack]) -> Self {
let mut s = Self::default();
for pack in packs {
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
// `tags` walks the whole tags tree, so a `tags/function` file is
// listed by both kinds: handle every file once.
let mut seen = std::collections::BTreeSet::new();
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"tags",
]) {
if !seen.insert(f.clone()) {
continue;
}
if let Some(id) = resource_id(&f, "function", ".mcfunction")
&& let Some(bytes) = pack.read(&f)
{
@@ -470,6 +507,14 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if !builtin
&& let Some(id) = content_id(&f)
&& let Some(bytes) = pack.read(&f)
{
// The built-in pack's content files are the resource
// root's own, which the tables read from disk; only the
// world's packs come through here.
s.files.entry(id).or_default().push(bytes);
}
}
}
+3 -3
View File
@@ -53,7 +53,7 @@ struct Out {
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
@@ -254,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// Enchantment glint: item.fsh's GLINT path, sampled from
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
} else if in.overlay == 13u {
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
} else if in.overlay == 36u {
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
+139 -40
View File
@@ -7,6 +7,7 @@ use crate::{
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
section_mask::SectionMask,
sprites::SpriteAnimation,
};
use glam::{DVec3, Mat4, Vec3, Vec4};
@@ -105,8 +106,6 @@ mod view;
mod wgsl_prefix;
mod world_post;
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
/// The world pass again after occlusion culling's test: it loads both
@@ -517,7 +516,7 @@ pub struct Renderer {
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
/// Bumped whenever a column becomes or stops being complete.
detail_revision: u64,
arena: arena::Arena,
@@ -556,6 +555,9 @@ pub struct Renderer {
block_entity_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// The column `occlusion`, `sections` and `detail_columns` count in: the
/// level's (`clear_sections`).
layout: crate::chunk::ColumnLayout,
/// Resolution scale, FXAA and TAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
@@ -2744,6 +2746,7 @@ impl Renderer {
block_entities: Default::default(),
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
hdr: None,
hdr_output,
@@ -3180,7 +3183,8 @@ impl Renderer {
pub fn block_entity_revision(&self) -> u64 {
self.block_entity_revision
}
pub fn clear_sections(&mut self) {
/// Drops every section, for a level whose chunks hold `layout`.
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
self.block_entities.clear();
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
@@ -3196,12 +3200,23 @@ impl Renderer {
}
self.sorted_indices.clear();
self.translucency_cursor = 0;
self.occlusion.clear();
if layout == self.layout {
self.occlusion.clear();
} else {
self.layout = layout;
self.occlusion = occlusion::Occlusion::new(layout.min_y(), layout.sections() as i32);
self.sections.set_min_y(layout.min_y());
}
}
/// A column whose every section has its mesh (`lod_detail_columns`).
fn complete_column(&self) -> SectionMask {
SectionMask::below(self.layout.sections())
}
pub fn lod_detail_columns(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
let complete = self.complete_column();
self.detail_columns
.iter()
.filter_map(move |(&column, &mask)| (mask == COMPLETE_COLUMN).then_some(column))
.filter_map(move |(&column, &mask)| (mask == complete).then_some(column))
}
/// Changes whenever `lod_detail_columns` does.
pub fn lod_detail_revision(&self) -> u64 {
@@ -3210,16 +3225,22 @@ impl Renderer {
/// Whether the section at `key` has its mesh, for `lod_detail_columns`.
fn set_detail(&mut self, key: SectionKey, meshed: bool) {
let column = (key.0, key.2);
let mask = self.detail_columns.get(&column).copied().unwrap_or(0);
let next = if meshed {
mask | 1 << key.1
let mask = self
.detail_columns
.get(&column)
.copied()
.unwrap_or_default();
let mut next = mask;
if meshed {
next.insert(key.1 as usize);
} else {
mask & !(1 << key.1)
};
if (mask == COMPLETE_COLUMN) != (next == COMPLETE_COLUMN) {
next.remove(key.1 as usize);
}
let complete = self.complete_column();
if (mask == complete) != (next == complete) {
self.detail_revision += 1;
}
if next == 0 {
if next.is_empty() {
self.detail_columns.remove(&column);
} else {
self.detail_columns.insert(column, next);
@@ -3232,12 +3253,12 @@ impl Renderer {
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
if self.detail_columns.remove(&(x, z)) == Some(self.complete_column()) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
for sy in 0..crate::chunk::SECTIONS as i32 {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if retiring {
@@ -3927,7 +3948,7 @@ impl Renderer {
planes: frustum(cull_proj),
};
let camera = frame.camera;
let camera_section = camera_section(camera);
let camera_section = camera_section(camera, self.layout.min_y());
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
@@ -4141,7 +4162,7 @@ impl Renderer {
let scheduled = (i + count - self.translucency_cursor) % count < budget;
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
self.layout.min_y() + v.key.1 * 16,
v.key.2 * 16,
];
if (!s.sort.is_sorted() || nearby || scheduled)
@@ -4244,7 +4265,7 @@ impl Renderer {
{
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
self.layout.min_y() + v.key.1 * 16,
v.key.2 * 16,
];
if let Some(indices) =
@@ -5420,6 +5441,16 @@ impl Renderer {
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
}
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
let hdr_readback = readback
.as_ref()
.filter(|(request, _)| request.size.is_none())
.zip(self.hdr.as_ref())
.and_then(|(_, hdr)| {
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
let commands = crate::spans::time("encode", || encoder.finish());
let first_frame = self.first_frame == FirstFrame::Submit;
if first_frame {
@@ -5438,6 +5469,16 @@ impl Renderer {
timer.submitted(&self.queue);
}
if let Some((request, buffer)) = readback {
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
@@ -5546,11 +5587,8 @@ impl Renderer {
None
}
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
@@ -5563,28 +5601,34 @@ impl Renderer {
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width,
self.config.height,
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height as usize {
for px in data[y * row..y * row + width as usize * 4]
.as_chunks::<4>()
.0
{
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
}
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
buffer.unmap();
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
@@ -5605,6 +5649,50 @@ impl Renderer {
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the peak and paper white as its content light levels (cLLI).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
let Some(hdr) = &self.hdr else {
return Ok(());
};
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
self.config.width,
self.config.height,
);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Sixteen);
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
let nits = |n: f32| ((n * 10_000.0) as u32).to_be_bytes();
let peak = hdr.peak.max(hdr.paper_white);
writer.write_chunk(
png::chunk::cLLI,
&[nits(peak), nits(hdr.paper_white.min(peak))].concat(),
)?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
path.with_file_name(format!("{stem}-hdr.png"))
}
/// `Rgb10a2Unorm` pixels as 16-bit RGB, big-endian as PNG stores it: each 10-bit value
/// with its top bits repeated below, so 0 stays 0 and 1023 becomes 65535.
fn hdr10_to_rgb16(pixels: &[[u8; 4]]) -> Vec<u8> {
let mut out = Vec::with_capacity(pixels.len() * 6);
for &px in pixels {
let v = u32::from_le_bytes(px);
for shift in [0, 10, 20] {
let c = (v >> shift & 0x3ff) as u16;
out.extend_from_slice(&(c << 6 | c >> 4).to_be_bytes());
}
}
out
}
/// Centre-crop the frame to a square and average it down to `size`.
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
@@ -5678,10 +5766,10 @@ impl Frustum {
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
/// The section the camera is in.
fn camera_section(camera: DVec3) -> SectionKey {
fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
(
(camera.x.floor() as i32) >> 4,
(camera.y.floor() as i32 - crate::chunk::MIN_Y) >> 4,
(camera.y.floor() as i32 - min_y) >> 4,
(camera.z.floor() as i32) >> 4,
)
}
@@ -5958,6 +6046,17 @@ mod tests {
assert_eq!(blend.alpha.dst_factor, wgpu::BlendFactor::Zero);
}
#[test]
fn hdr_screenshots_keep_every_10_bit_step() {
// Rgb10a2Unorm: red in the low 10 bits, then green, then blue.
let px = |r: u32, g: u32, b: u32| (r | g << 10 | b << 20 | 3 << 30).to_le_bytes();
let out = super::hdr10_to_rgb16(&[px(0, 1023, 512)]);
assert_eq!(out, [0, 0, 0xff, 0xff, 0x80, 0x20]);
assert_eq!(
super::hdr_screenshot_path(std::path::Path::new("screenshots/2026-10-28_12.00.00.png")),
std::path::Path::new("screenshots/2026-10-28_12.00.00-hdr.png")
);
}
#[test]
fn shader_globals_match_the_rust_layout() {
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
let fields = |source: &str| -> Vec<String> {
+113 -37
View File
@@ -102,6 +102,10 @@ pub struct Hdr {
/// output keeps the hand at the world's brightness.
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
/// so it can differ from the swapchain's in one frame.
capture_pipeline: wgpu::RenderPipeline,
capture_params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
@@ -156,31 +160,33 @@ impl Hdr {
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: output.format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let pipeline_for = |format| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -223,18 +229,22 @@ impl Hdr {
alpha: scale_alpha,
}),
);
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
layout,
pipeline,
pipeline: pipeline_for(output.format()),
clear_alpha,
hand_mark,
params,
params: params(),
capture_pipeline: pipeline_for(Output::Pq.format()),
capture_params: params(),
output,
format,
targets: None,
@@ -375,6 +385,64 @@ impl Hdr {
let Some(t) = &self.targets else {
return;
};
queue.write_buffer(
&self.params,
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
pub fn capture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
world: bool,
) -> Option<wgpu::Texture> {
let t = self.targets.as_ref()?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR screenshot"),
size: t.frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Output::Pq.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR screenshot bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
],
});
queue.write_buffer(
&self.capture_params,
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Some(texture)
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
@@ -382,7 +450,7 @@ impl Hdr {
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
f32::from(u8::from(pq)),
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
@@ -391,7 +459,15 @@ impl Hdr {
params[12 + i] = t.start;
params[16 + i] = t.full;
}
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
params
}
fn draw(
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
@@ -407,8 +483,8 @@ impl Hdr {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &t.group, &[]);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
@@ -27,6 +27,8 @@ impl Column {
/// Values of type `T` per section key, in a dense list with stable keys and
/// slots that move only when a section is removed.
pub struct Resident<T> {
/// The minimum Y of section index 0 (the level's column).
min_y: i32,
items: Vec<(SectionKey, T)>,
slots: FxHashMap<SectionKey, u32>,
columns: Vec<Column>,
@@ -36,6 +38,7 @@ pub struct Resident<T> {
impl<T> Default for Resident<T> {
fn default() -> Self {
Self {
min_y: crate::chunk::MIN_Y,
items: Vec::new(),
slots: FxHashMap::default(),
columns: Vec::new(),
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
pub fn get_mut(&mut self, slot: u32) -> &mut T {
&mut self.items[slot as usize].1
}
/// Sections from now on count from `min_y` (a level of another column).
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
pub fn clear(&mut self) {
self.items.clear();
self.slots.clear();
@@ -144,10 +151,10 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(crate::chunk::MIN_Y + key.1 * 16),
f64::from(min_y + key.1 * 16),
f64::from(key.2 * 16),
) - camera
}
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
in_frustum(key, camera, frustum, false)
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
}
/// `in_view` without the distance test; `inside`: the section's column is
/// entirely inside the frustum, so the section is too.
fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
inside: bool,
) -> Option<(f64, Vec3)> {
let origin = origin(key, camera);
let origin = origin(key, min_y, camera);
let min = origin.as_vec3();
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
if !near && !beyond(column.x, column.z) {
continue;
}
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
if near && !reachable(key) {
continue;
}
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
out.push(Visible {
distance: d.to_bits(),
key,
@@ -286,7 +294,7 @@ mod tests {
if near {
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
} else if beyond(k.0, k.2) {
in_frustum(k, camera, &frustum, false)
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
} else {
None
}
@@ -329,8 +337,8 @@ mod tests {
.columns
.iter()
.filter(|c| {
let min = origin((c.x, c.low, c.z), camera).as_vec3();
let top = origin((c.x, c.high, c.z), camera).as_vec3();
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
frustum.contains(min, top + Vec3::splat(16.0))
})
.count();
+2
View File
@@ -59,10 +59,12 @@ pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod potent_sulfur;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod settings;
pub mod shader_pack_list;
pub mod signs;
+130 -27
View File
@@ -17,7 +17,7 @@
use crate::fast_hash::FxHashMap;
use crate::{
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
chunk::{Chunk, ColumnLayout, Light, Section, index},
};
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
@@ -273,7 +273,7 @@ pub fn light_chunk(
tables: &LightTables,
sky_light: bool,
) -> Vec<Light> {
region[1][1].expect("centre chunk");
let sections = region[1][1].expect("centre chunk").sections.len();
// Highest occupied section in the region bounds the work.
let mut top_section = 0;
for chunk in region.iter().flatten().flatten() {
@@ -286,7 +286,7 @@ pub fn light_chunk(
}
// One extra section above the highest occupied one: block light rises
// up to 14 blocks into it (sky light there is always 15).
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
let h = ((top_section + 1) * 16).min(sections * 16);
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
// then never touches the large `LightTables::cells` table.
@@ -422,14 +422,21 @@ pub fn light_chunk(
};
spread(&mut sky, &mut sky_queue);
spread(&mut block, &mut queue);
pack_lighting(h, &sky, &block, sky_light)
pack_lighting(sections, h, &sky, &block, sky_light)
}
/// Extract just the centre column's light; never copy worker snapshot sections.
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
/// Extract just the centre column's light (`sections` of it); never copy
/// worker snapshot sections.
fn pack_lighting(
sections: usize,
h: usize,
sky: &[u8],
block: &[u8],
sky_light: bool,
) -> Vec<Light> {
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
let open = if sky_light { 0xF0 } else { 0x00 };
(0..SECTIONS)
(0..sections)
.map(|si| {
if si * 16 >= h {
return Light::Uniform(open);
@@ -467,6 +474,7 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
/// `apply_lighting` for a chunk lit before: writes only the sections whose
/// light changed and adds the cells that changed to `changed`.
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
changed.keyed(chunk.layout());
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
@@ -506,7 +514,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
mut above: [u8; 256],
sky_light: bool,
) {
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
let sections = chunk.sections.len();
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
let sky = sky.as_ref().filter(|_| sky_light);
let section = Arc::make_mut(&mut chunk.sections[si]);
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
@@ -539,7 +548,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
sky_light: bool,
) -> Vec<usize> {
let mut changed = Vec::new();
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
let sections = chunk.sections.len();
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
let sky = sky.as_ref().filter(|_| sky_light);
if sky.is_none() && block.is_none() {
continue;
@@ -569,8 +579,15 @@ pub trait LightWorld {
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
/// Replace the light of a section `section` returned.
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
/// The column the world's chunks hold (its level's).
fn layout(&self) -> ColumnLayout;
}
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
fn layout(&self) -> ColumnLayout {
self.values()
.next()
.map_or(ColumnLayout::VANILLA, |c| c.layout())
}
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
self.get(&(cx, cz))
.filter(|c| c.lit)
@@ -587,6 +604,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
/// gets written is copied out on the first write and stored back by `finish`.
struct Region<'w> {
world: &'w mut dyn LightWorld,
layout: ColumnLayout,
slots: Vec<Slot>,
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
index: FxHashMap<Pos, u32>,
@@ -602,6 +620,7 @@ const NONE: u32 = u32::MAX;
impl<'w> Region<'w> {
fn new(world: &'w mut dyn LightWorld) -> Self {
Self {
layout: world.layout(),
world,
slots: Vec::new(),
index: FxHashMap::default(),
@@ -610,10 +629,8 @@ impl<'w> Region<'w> {
}
#[inline]
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
return None;
}
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
let s = self.layout.section_index(y)?;
let key = (x >> 4, s as i32, z >> 4);
if self.last.0 != key {
self.last = (key, self.load(key));
}
@@ -702,12 +719,20 @@ type Pos = (i32, i32, i32);
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LightChanges {
sections: FxHashMap<Pos, u32>,
/// The column the section indexes count in.
layout: ColumnLayout,
}
impl LightChanges {
/// Changes in chunks of `layout`: what the relighter or a light job adds
/// after this (they all come from one level).
fn keyed(&mut self, layout: ColumnLayout) {
debug_assert!(self.sections.is_empty() || self.layout == layout);
self.layout = layout;
}
/// A cell whose stored light changed.
#[inline]
pub fn push(&mut self, (x, y, z): Pos) {
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
}
/// Cells of section `key` (x, section index, z) in the given classes.
@@ -732,7 +757,7 @@ impl LightChanges {
self.sections.keys().copied()
}
/// Every section (x, section index, z) whose cells lie within one block
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
/// of a changed cell, section indexes outside the column left out (a
/// section next to several changed ones comes once for each).
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
@@ -759,6 +784,7 @@ impl LightChanges {
}
reach
};
let sections = self.layout.sections() as i32;
for (&(x, sy, z), &classes) in &self.sections {
let mut offsets = 0;
let mut rest = classes;
@@ -770,7 +796,7 @@ impl LightChanges {
let o = offsets.trailing_zeros() as i32;
offsets &= offsets - 1;
let s = sy + o / 3 % 3 - 1;
if (0..SECTIONS as i32).contains(&s) {
if (0..sections).contains(&s) {
out((x + o % 3 - 1, s, z + o / 9 - 1));
}
}
@@ -794,11 +820,12 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
/// edge in the column (every block at or above it is a sky source).
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
let mut y = MIN_Y + HEIGHT - 1;
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
let mut y = max_y - 1;
let Some(mut top) = world.state(x, y, z) else {
return MIN_Y;
return min_y;
};
while y > MIN_Y {
while y > min_y {
let Some(bottom) = world.state(x, y - 1, z) else {
break;
};
@@ -808,7 +835,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
top = bottom;
y -= 1;
}
MIN_Y
min_y
}
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
/// resident, lit chunks: clear the light that may have come through or from
@@ -825,6 +852,7 @@ pub fn relight(
changed: &mut LightChanges,
) {
let mut region = Region::new(world);
changed.keyed(region.layout);
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
@@ -840,6 +868,7 @@ fn relight_region(
relight_layer(world, tables, false, positions.to_vec(), force, changed)
});
if sky_light {
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
let mut seeds = positions.to_vec();
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
crate::spans::time("lt_columns", || {
@@ -851,13 +880,13 @@ fn relight_region(
columns.insert((x, z), source);
// Cells whose source status changed: non-15 at or above the new
// lowest source, and old 15s (sources) below it.
for y in source..MIN_Y + HEIGHT {
for y in source..max_y {
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
seeds.push((x, y, z));
}
}
let mut y = source - 1;
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
seeds.push((x, y, z));
y -= 1;
}
@@ -977,6 +1006,7 @@ fn relight_layer(
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
const AIR: u32 = 0;
const STONE: u32 = 1;
@@ -1004,11 +1034,18 @@ mod tests {
)
}
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
}
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
fn world_of(
layout: crate::chunk::ColumnLayout,
edit: impl Fn(&mut Chunk),
) -> HashMap<(i32, i32), Arc<Chunk>> {
let mut chunks = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
let mut c = Chunk::with_layout(x, z, layout);
for y in layout.min_y()..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
@@ -1218,6 +1255,66 @@ mod tests {
}
}
#[test]
fn a_tall_column_is_lit_to_its_top() {
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for i in 0..256 {
chunk.set(i & 15, 1600, i >> 4, STONE);
}
chunk.set(8, 1500, 8, TORCH);
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
assert_eq!(lights.len(), 131);
apply_lighting(&mut chunk, lights);
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
}
#[test]
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
let mut w = world_of(layout, |c| {
for i in 0..256 {
c.set(i & 15, 1900, i >> 4, STONE);
}
});
let edits = [
((8, 1800, 8), TORCH),
((8, 1900, 8), AIR),
((3, 2020, 3), TORCH),
];
let mut changed = LightChanges::default();
for ((x, y, z), state) in edits {
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
}
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
let full = light_chunk(&region, &tables(), true);
assert_eq!(full.len(), 131);
for (si, s) in full.iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
// y 2020 is section 130 of the tall column, the top one.
assert!(
changed
.sections()
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
);
let mut dirty = Vec::new();
changed.dirty_sections(|s| dirty.push(s.1));
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
}
#[test]
fn incremental_relight_matches_full_relight() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
@@ -1521,7 +1618,7 @@ mod tests {
let mut old = fixture.clone();
let mut new = fixture.clone();
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
for (a, b) in old.sections.iter().zip(&new.sections) {
for i in 0..4096 {
assert_eq!(a.light(i), b.light(i));
@@ -1538,14 +1635,20 @@ mod tests {
let mut current = fixture.clone();
let held = shared.then(|| current.clone());
// Warm up and detach for the uniquely-owned scenario.
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
let started = Instant::now();
for _ in 0..500 {
let retained = shared.then(|| current.clone());
if optimized {
apply_lighting(
black_box(&mut current),
pack_lighting(h, black_box(&sky), black_box(&block), true),
pack_lighting(
SECTIONS,
h,
black_box(&sky),
black_box(&block),
true,
),
);
} else {
baseline_apply(
+68
View File
@@ -161,6 +161,31 @@ impl Column {
self.spans.push(span);
}
}
/// Open water and its floor lit as open sky, as the seed's far columns
/// are: a chunk's own light fades a level per block of water, so the
/// chunks a player passed drew a darker band through the seed's sea.
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
if !self.spans.last().is_some_and(water) {
return;
}
let floor = self
.spans
.iter()
.rposition(|span| !water(span))
.unwrap_or(0);
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
if i >= floor {
span.light |= 0xf0;
}
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
@@ -212,6 +237,7 @@ impl Column {
});
}
}
column.light_open_water(kinds);
let mut water = None;
for span in column.spans.iter().rev() {
if kind(span.state) & FLUID != 0 {
@@ -1772,6 +1798,48 @@ mod tests {
);
}
#[test]
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
let span = |min, max, state, light| Span {
min,
max,
state: BlockStateId(state),
biome: 0,
light,
};
// 0 air, 1 stone, 2 water, 3 lava.
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
// A chunk's own light: a cave, the floor, water fading a level a block.
let mut sea = Column {
surface: None,
spans: vec![
span(30, 40, 1, 0),
span(40, 48, 1, 0x10 | 2 << 12),
span(48, 61, 2, 0x20),
span(61, 62, 2, 0xe0),
span(62, 63, 2, 0xf0),
],
};
sea.light_open_water(&kinds);
assert_eq!(
sea.spans,
[
span(30, 40, 1, 0),
span(40, 48, 1, 0xf0 | 2 << 12),
span(48, 63, 2, 0xf0)
]
);
// Land, and lava, keep their light.
for top in [1, 3] {
let mut column = Column {
surface: None,
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
};
let before = column.spans.clone();
column.light_open_water(&kinds);
assert_eq!(column.spans, before);
}
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
+2 -2
View File
@@ -639,7 +639,7 @@ impl Padded {
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
let mut blocks = vec![0u32; P * P * P];
let mut light = vec![0u8; P * P * P];
let base_y = MIN_Y + sy * 16;
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
let mut own = Box::new([0u32; 4096]);
@@ -848,7 +848,7 @@ impl Mesher<'_> {
}
let pad = Padded::new(region, sy);
out.visibility = visibility(&pad, &self.tables.info);
let base_y = MIN_Y + sy * 16;
let base_y = center.min_y() + sy * 16;
let mut tints = Tints::new(
region,
self.biomes,
+99 -20
View File
@@ -2797,26 +2797,7 @@ impl OptionsUi {
}
Act::Done => events.extend(self.back(s)),
Act::Cycle(opt) => {
opt.cycle(s, right, framebuffer);
if opt == Opt::Preset {
apply_preset(s);
}
if opt == Opt::HighContrast {
// Options.highContrast: the built-in pack follows the option.
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
if on != s.high_contrast {
s.resource_packs.retain(|id| id != "high_contrast");
if s.high_contrast {
s.resource_packs.push("high_contrast".into());
}
// PackSelectionScreen reads the repository each time it
// opens: the next one starts from this selection.
self.pack_selection = s.resource_packs.clone();
self.pack_screen = None;
events.push(Event::Packs(s.resource_packs.clone()));
}
}
events.push(Event::Changed);
events.extend(self.cycle_option(s, opt, right, framebuffer));
}
Act::Extra(item) => {
if let Some(name) = item.key_name() {
@@ -2924,6 +2905,77 @@ impl OptionsUi {
self.scroll -= amount * 18.0;
self.clamp_scroll(top, bottom, content);
}
/// `CycleButton.cycleValue` on an option, then what the option changes:
/// the preset's values, High Contrast's built-in pack.
fn cycle_option(
&mut self,
s: &mut Settings,
opt: Opt,
back: bool,
framebuffer: [i32; 2],
) -> Vec<Event> {
let mut events = Vec::new();
opt.cycle(s, back, framebuffer);
if opt == Opt::Preset {
apply_preset(s);
}
if opt == Opt::HighContrast {
// Options.highContrast: the built-in pack follows the option.
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
if on != s.high_contrast {
s.resource_packs.retain(|id| id != "high_contrast");
if s.high_contrast {
s.resource_packs.push("high_contrast".into());
}
// PackSelectionScreen reads the repository each time it
// opens: the next one starts from this selection.
self.pack_selection = s.resource_packs.clone();
self.pack_screen = None;
events.push(Event::Packs(s.resource_packs.clone()));
}
}
events.push(Event::Changed);
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
pub fn wheel(
&mut self,
s: &mut Settings,
size: (f32, f32),
cursor: (f32, f32),
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Event> {
let plain = self.resource_error.is_none()
&& Self::graphics_level(self.page()).is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
self.scroll_at(s, size, cursor, amount);
return Vec::new();
}
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
WKind::Button {
active: true,
act: Act::Cycle(opt),
..
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
{
Some(*opt)
}
_ => None,
});
match hovered {
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
Some(_) => Vec::new(),
None => {
self.scroll_at(s, size, cursor, amount);
Vec::new()
}
}
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
if self.resource_error.is_some() {
@@ -2940,6 +2992,33 @@ impl OptionsUi {
_ => self.scroll(s, size, amount),
}
}
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
/// (auto counts as one past the maximum) and the list goes back to the top.
pub fn video_wheel_gui_scale(
&mut self,
s: &mut Settings,
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Event> {
let max = i64::from(crate::window::calculate_scale(
framebuffer,
0,
s.force_unicode,
));
let old = if s.gui_scale == 0 {
max + 1
} else {
i64::from(s.gui_scale)
};
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
let new = old + step;
if new == 0 || new > max {
return Vec::new();
}
s.gui_scale = new as u32;
self.scroll = 0.0;
vec![Event::Changed]
}
/// The pack screen at this GUI size, made on first use.
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
let m = crate::screen_draw::Measure(&atlas.font);
+284 -3
View File
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
/// `ItemParticleOption`: the particle sprite of each layer of the item's
/// model as resolved for the ground.
pub item: Option<Vec<String>>,
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -55,6 +58,21 @@ impl ParticleOptions {
to_color: [1.0, 0.0, 0.0],
trail: None,
item: None,
geyser: None,
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
Self {
geyser: Some((water_blocks, 0.0)),
..Self::simple(ty)
}
}
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
Self {
geyser: Some((water_blocks, burst_impulse_base)),
..Self::simple(ty)
}
}
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
@@ -189,6 +207,29 @@ enum Behaviour {
size_start: f32,
y_prev: f64,
},
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
NoxiousGas {
fade_start: f32,
},
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
/// gas reaches.
NoxiousGasCloud,
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
/// one eruption; `aux` is the speed its provider was given.
GeyserEruption {
water_blocks: i32,
aux: [f64; 3],
},
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
GeyserPlume {
start_y: f64,
max_y: f64,
propulsion: f32,
spray: [f32; 2],
min_size: f32,
max_size: f32,
done: bool,
},
BubblePop,
BubbleColumnUp,
CurrentDown {
@@ -869,6 +910,26 @@ impl Particle {
(age / self.lifetime as f32).clamp(0.0, 1.0)
}
}
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
/// blocks from the centre of `center_block` in a random horizontal direction,
/// a quarter block lower, from the level's random.
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
let dx = f64::from(random.next_float() - 0.5);
let dz = f64::from(random.next_float() - 0.5);
// Vec3.normalize.
let length = (dx * dx + dz * dz).sqrt();
let (dx, dz) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(dx / length, dz / length)
};
let distance = f64::from(random.next_float() * 3.0);
[
f64::from(center_block[0]) + 0.5 + dx * distance,
f64::from(center_block[1]) + 0.5 - 0.25,
f64::from(center_block[2]) + 0.5 + dz * distance,
]
}
/// `FireflyParticle.getFadeAmount`.
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
if progress >= 1.0 - fade_in {
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
Err(_) => d,
}
}
fn block_at(p: [f64; 3]) -> [i32; 3] {
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
[
p[0].floor() as i32,
p[1].floor() as i32,
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
(s.fluid, height)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
self.create(options, p, v);
}
}
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
self.add_with(
&ParticleOptions::simple(ParticleType::NoxiousGas),
pos,
[0.0; 3],
false,
true,
);
}
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
/// always-visible particles as decreased, and decreased drops a third.
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
q.set = set;
q
}
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
T::Smoke
| T::LargeSmoke
| T::WhiteSmoke
| T::Ash
| T::WhiteAsh
| T::DustPlume
| T::NoxiousGas
| T::GeyserBase
| T::GeyserPoof => {
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
false,
[v[0], v[1] + f64::from(0.15f32), v[2]],
),
// NoxiousGasParticle.Provider: scale 3.
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
// GeyserBaseParticle: the burst impulse scales every axis.
T::GeyserBase | T::GeyserPoof => {
let (water_blocks, burst_impulse_base) = o.geyser?;
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
(
[f64::from(burst_impulse); 3],
3.0 + 0.125 * water_blocks as f32,
0.0,
0,
0.0,
true,
v,
)
}
_ => {
// WhiteAshParticle.Provider draws the motion.
let pr = &mut self.random;
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
}
};
// GeyserBaseParticle.Provider: a random spot half a block
// around the point, a fifth of a block up (the provider's
// random, as float arithmetic added to the double position).
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
let pr = &mut self.random;
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
[x, y, z]
} else {
p
};
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
q.friction = 0.96;
q.gravity = gravity;
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
);
q.behaviour = Behaviour::DustPlume;
}
match ty {
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
// ticks, fading out over its second half.
T::NoxiousGas => {
q.set_color(1.0, 1.0, 1.0);
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
* f64::from(3.0f32)) as i32;
q.behaviour = Behaviour::NoxiousGas {
fade_start: q.lifetime as f32 / 2.0,
};
}
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
T::GeyserBase | T::GeyserPoof => {
q.friction = 0.725;
q.set_color(1.0, 1.0, 1.0);
q.velocity[1] = q.velocity[1].abs();
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
q.lifetime = (25.0 * lifetime_factor) as i32;
}
_ => {}
}
q.set = set;
q
}
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
T::NoxiousGasCloud => {
let mut q = Particle::base_at(r, ty, p);
q.lifetime = 20;
q.no_render = true;
q.behaviour = Behaviour::NoxiousGasCloud;
q
}
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
T::Geyser => {
let (water_blocks, _) = o.geyser?;
let mut q = Particle::base_at(r, ty, p);
q.lifetime = 20;
q.no_render = true;
q.behaviour = Behaviour::GeyserEruption {
water_blocks,
aux: v,
};
q
}
// GeyserPlumeParticle, after its provider's random placement.
T::GeyserPlume => {
let (water_blocks, _) = o.geyser?;
let pr = &mut self.random;
let at = [
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
p[1] + f64::from(pr.next_float()),
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
];
let plume_height = 5 * water_blocks.max(1);
let mut q = Particle::moving(r, ty, at, v);
q.has_physics = true;
q.speed_up_when_y_blocked = true;
q.lifetime = plume_height * 5;
q.velocity[1] = 0.0;
let level = &mut self.level_random;
let spray = [
(level.next_float() - 0.5) * 0.2,
(level.next_float() - 0.5) * 0.2,
];
q.friction = 1.0;
let propulsion =
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
q.gravity = -propulsion;
let initial_size = q.size * 0.75;
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
q.size = min_size;
q.behaviour = Behaviour::GeyserPlume {
start_y: at[1],
max_y: at[1] + f64::from(plume_height) - 1.0,
propulsion,
spray,
min_size,
max_size,
done: false,
};
q.set = set;
q
}
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
y_prev: p.position[1],
};
}
Behaviour::NoxiousGas { fade_start } => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
p.base_tick(level);
if p.age % 2 == 0 {
let source = ParticleLevel::block_at(p.position);
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
self.spawn_noxious_gas_particle(spawn);
}
}
}
Behaviour::GeyserEruption { water_blocks, aux } => {
p.base_tick(level);
if p.age % 2 == 0 {
for _ in 0..2 {
self.add(
&ParticleOptions::geyser_base(
ParticleType::GeyserBase,
water_blocks,
1.5,
),
p.position,
aux,
);
}
}
for _ in 0..water_blocks + 2 {
self.add(
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
p.position,
aux,
);
}
if p.age % 10 == 0 {
for _ in 0..20 {
self.add(
&ParticleOptions::geyser_base(
ParticleType::GeyserPoof,
water_blocks,
2.0,
),
p.position,
aux,
);
}
}
}
Behaviour::GeyserPlume {
start_y,
max_y,
propulsion,
spray,
min_size,
max_size,
mut done,
} => {
p.base_tick(level);
if !done
&& (p.velocity[1] < 0.0
|| p.position[1] > max_y
|| p.position[1] == p.previous[1])
{
p.lifetime = p.lifetime.min(p.age + 5);
p.friction = 0.0;
done = true;
}
let y_progress_linear =
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
self.sprite_from_age(p);
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
p.behaviour = Behaviour::GeyserPlume {
start_y,
max_y,
propulsion,
spray,
min_size,
max_size,
done,
};
}
Behaviour::BubbleColumnUp => {
p.base_tick(level);
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
@@ -0,0 +1,119 @@
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
//! reaches a point. The client's particles and the server's nausea and
//! launches read the level the same way, through a [`ParticleLevel`].
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
use horizon_core::{
geometry::{Vec3, traverse_blocks},
position::BlockPos,
};
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
/// with no collision shape.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
let state = level.world.block(pos[0], pos[1], pos[2]);
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
return true;
}
let mut scene = SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
match level.collision.collision_shape(&mut scene, state, pos) {
Ok(shape) => shape.is_empty(),
Err(_) => !level.facts.get(state).full_collision,
}
}
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
/// (`FluidState.isSourceOfType`).
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
let (fluid, source) = level.fluid_source(pos);
fluid == Fluid::Water && source
}
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
/// the sulfur, within the water column's reach, that is not water-filled and
/// lets gas through; None when something solid or a deeper column stops it.
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
let mut pos = [origin[0], origin[1] + 1, origin[2]];
while pos[1] <= max_y {
let state = level.world.block(pos[0], pos[1], pos[2]);
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
if !is_water_source(level, pos)
|| (!is_water_block && !is_geyser_passable_block(level, pos))
{
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
return Some(pos);
}
break;
}
pos[1] += 1;
}
None
}
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
/// `pos` up, at most six per water block, a geyser's push goes through.
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
let geyser_force_height = 6 * water_blocks;
for i in 0..geyser_force_height {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
return i;
}
}
geyser_force_height
}
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
/// can enter, within three blocks of the source block's centre, and above
/// water the source's water can be seen from.
pub fn can_be_reached_by_noxious_gas(
level: &ParticleLevel,
source_block: [i32; 3],
pos: [f64; 3],
) -> bool {
let block = ParticleLevel::block_at(pos);
if !is_geyser_passable_block(level, block) {
return false;
}
let center = source_block.map(|c| f64::from(c) + 0.5);
let distance_sqr =
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
if distance_sqr > 9.0 {
return false;
}
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
is_water_source(level, ParticleLevel::block_at(below_pos))
&& have_line_of_sight(level, below_source, below_pos)
}
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
/// no fluids (as the mobs' line of sight does) finds no block between the
/// points.
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
let mut scene = SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
let state = level.world.block(p.x, p.y, p.z);
if level.facts.is_air(state) {
return None;
}
let shape = level
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p).map(|_| ())
});
!matches!(hit, Ok(Some(())))
}
@@ -121,6 +121,10 @@ pub fn warden_can_listen(event: &str) -> bool {
|| event == SHRIEK
|| shrieker_can_listen(event)
}
/// `GameEventTags.ALLAY_CAN_LISTEN`: note block notes.
pub fn allay_can_listen(event: &str) -> bool {
event == "minecraft:note_block_play"
}
/// `GameEventTags.IGNORE_VIBRATIONS_SNEAKING`.
pub fn ignored_when_sneaking(event: &str) -> bool {
matches!(
@@ -0,0 +1,164 @@
//! Sets of section indexes in a chunk column, a bit per section.
//!
//! Vanilla's tallest level has 254 sections (`DimensionType.Y_SIZE / 16`), and
//! light data adds a section below and above it: 256 bits hold every layout a
//! dimension type allows.
/// Section indexes of one column, 0 being the lowest.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct SectionMask([u64; 4]);
impl SectionMask {
pub const CAPACITY: usize = 256;
pub const EMPTY: Self = Self([0; 4]);
pub const ALL: Self = Self([u64::MAX; 4]);
/// The indexes `0..n`.
pub const fn below(n: usize) -> Self {
let mut words = [0; 4];
let mut w = 0;
while w < 4 {
let bits = n.saturating_sub(w * 64);
words[w] = if bits >= 64 {
u64::MAX
} else {
(1 << bits) - 1
};
w += 1;
}
Self(words)
}
pub fn single(i: usize) -> Self {
let mut mask = Self::EMPTY;
mask.insert(i);
mask
}
#[inline]
pub fn contains(&self, i: usize) -> bool {
i < Self::CAPACITY && self.0[i >> 6] >> (i & 63) & 1 != 0
}
#[inline]
pub fn insert(&mut self, i: usize) {
self.0[i >> 6] |= 1 << (i & 63);
}
#[inline]
pub fn remove(&mut self, i: usize) {
self.0[i >> 6] &= !(1 << (i & 63));
}
pub fn is_empty(&self) -> bool {
self.0 == [0; 4]
}
pub fn len(&self) -> usize {
self.0.iter().map(|w| w.count_ones() as usize).sum()
}
/// The indexes from `i` up.
pub fn from(self, i: usize) -> Self {
self & !Self::below(i)
}
/// The indexes, lowest first.
pub fn iter(self) -> Indexes {
Indexes(self)
}
}
impl std::ops::BitOr for SectionMask {
type Output = Self;
fn bitor(self, other: Self) -> Self {
Self(std::array::from_fn(|w| self.0[w] | other.0[w]))
}
}
impl std::ops::BitOrAssign for SectionMask {
fn bitor_assign(&mut self, other: Self) {
*self = *self | other;
}
}
impl std::ops::BitAnd for SectionMask {
type Output = Self;
fn bitand(self, other: Self) -> Self {
Self(std::array::from_fn(|w| self.0[w] & other.0[w]))
}
}
impl std::ops::Not for SectionMask {
type Output = Self;
fn not(self) -> Self {
Self(self.0.map(|w| !w))
}
}
impl std::fmt::Debug for SectionMask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_set().entries(self.iter()).finish()
}
}
impl FromIterator<usize> for SectionMask {
fn from_iter<I: IntoIterator<Item = usize>>(indexes: I) -> Self {
let mut mask = Self::EMPTY;
for i in indexes {
mask.insert(i);
}
mask
}
}
/// `SectionMask::iter`: the set's indexes in ascending order.
#[derive(Clone, Copy, Debug)]
pub struct Indexes(SectionMask);
impl Iterator for Indexes {
type Item = usize;
#[inline]
fn next(&mut self) -> Option<usize> {
let words = &mut (self.0).0;
let w = words.iter().position(|&w| w != 0)?;
let bit = words[w].trailing_zeros() as usize;
words[w] &= words[w] - 1;
Some(w * 64 + bit)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.0.len();
(len, Some(len))
}
}
impl ExactSizeIterator for Indexes {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sets_across_words() {
let mut mask = SectionMask::EMPTY;
for i in [0, 23, 63, 64, 130, 255] {
mask.insert(i);
}
assert_eq!(mask.iter().collect::<Vec<_>>(), [0, 23, 63, 64, 130, 255]);
assert_eq!(mask.len(), 6);
assert!(mask.contains(130) && !mask.contains(131) && !mask.contains(300));
mask.remove(64);
assert_eq!(mask.from(24).iter().collect::<Vec<_>>(), [63, 130, 255]);
assert_eq!(mask.from(256), SectionMask::EMPTY);
assert_eq!(mask.from(0), mask);
assert!(SectionMask::EMPTY.is_empty() && !mask.is_empty());
}
#[test]
fn below_counts_whole_and_partial_words() {
for n in [0, 1, 24, 63, 64, 65, 131, 254, 256] {
let mask = SectionMask::below(n);
assert_eq!(mask.len(), n);
assert_eq!(mask.iter().collect::<Vec<_>>(), (0..n).collect::<Vec<_>>());
}
assert_eq!(SectionMask::below(1000), SectionMask::ALL);
}
#[test]
fn operators() {
let a: SectionMask = [1, 70, 200].into_iter().collect();
let b: SectionMask = [70, 3].into_iter().collect();
assert_eq!((a | b).iter().collect::<Vec<_>>(), [1, 3, 70, 200]);
assert_eq!((a & b).iter().collect::<Vec<_>>(), [70]);
assert_eq!(
(!a & SectionMask::below(4)).iter().collect::<Vec<_>>(),
[0, 2, 3]
);
assert_eq!(format!("{a:?}"), "{1, 70, 200}");
}
}
@@ -150,6 +150,8 @@ pub struct Settings {
pub chat_links: bool,
pub chat_links_prompt: bool,
pub chat_opacity: f32,
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
pub chat_delay: f64,
pub text_background_opacity: f32,
pub chat_scale: f32,
pub chat_width: f32,
@@ -246,6 +248,7 @@ impl Default for Settings {
chat_links: true,
chat_links_prompt: true,
chat_opacity: 1.0,
chat_delay: 0.0,
text_background_opacity: 0.5,
chat_scale: 1.0,
chat_width: 1.0,
@@ -448,6 +451,7 @@ impl Settings {
chat_links: f.bool("chatLinks"),
chat_links_prompt: f.bool("chatLinksPrompt"),
chat_opacity: f.double("chatOpacity") as f32,
chat_delay: f.double("chatDelay"),
text_background_opacity: f.double("textBackgroundOpacity") as f32,
chat_scale: f.double("chatScale") as f32,
chat_width: f.double("chatWidth") as f32,
@@ -551,6 +555,9 @@ impl Settings {
put_b(&mut f, "chatLinks", self.chat_links);
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
put_f(&mut f, "chatOpacity", self.chat_opacity);
if self.chat_delay != f.double("chatDelay") {
f.set("chatDelay", V::Double(self.chat_delay));
}
put_f(
&mut f,
"textBackgroundOpacity",
+87 -4
View File
@@ -31,9 +31,45 @@ impl Default for SignText {
lines: Default::default(),
color: BLACK,
glowing: false,
click: Default::default(),
}
}
}
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
#[derive(Debug, PartialEq)]
pub enum ClickAction<'a> {
/// `ClickEvent.RunCommand`.
RunCommand(String),
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
ShowDialog(&'a Tag),
/// `ClickEvent.Custom`.
Custom {
id: String,
payload: Option<&'a Tag>,
},
}
/// A saved `click_event` as its action: the three a sign acts on, read the
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
let Some(Tag::String(action)) = event.get("action") else {
return None;
};
match action.to_string_lossy().as_str() {
"run_command" => match event.get("command") {
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
_ => None,
},
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
"custom" => match event.get("id") {
Some(Tag::String(id)) => Some(ClickAction::Custom {
id: id.to_string_lossy(),
payload: event.get("payload"),
}),
_ => None,
},
_ => None,
}
}
impl SignText {
pub fn is_empty(&self) -> bool {
self.lines.iter().all(String::is_empty)
@@ -42,6 +78,14 @@ impl SignText {
pub fn text_color(&self) -> u32 {
DYE_TEXT[usize::from(self.color.min(15))].1
}
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
/// click event.
pub fn has_any_click_commands(&self) -> bool {
self.click
.iter()
.flatten()
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
}
/// `AbstractSignRenderer.getDarkColor`.
pub fn dark_color(&self) -> u32 {
let c = self.text_color();
@@ -70,6 +114,13 @@ impl SignData {
pub fn line_height(&self) -> f32 {
if self.hanging { 9.0 } else { 10.0 }
}
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
/// has a `run_command` click event. (Horizon keeps no filtered text, so
/// `Player.isTextFilteringEnabled` never selects it.)
pub fn can_execute_click_commands(&self, front: bool) -> bool {
let side = if front { &self.front } else { &self.back };
self.waxed && side.has_any_click_commands()
}
/// NBT entries after the common block-entity header.
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
let side = |t: &SignText| {
@@ -79,7 +130,18 @@ impl SignData {
Tag::List(
t.lines
.iter()
.map(|l| Tag::String(l.as_str().into()))
.zip(&t.click)
.map(|(line, click)| {
let text = Tag::String(line.as_str().into());
match click {
// The line's style keeps its click event.
Some(event) => Tag::compound([
("text", text),
("click_event", event.clone()),
]),
None => text,
}
})
.collect(),
),
),
@@ -90,19 +152,25 @@ impl SignData {
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
])
};
vec![
let mut out = Vec::new();
if self.allow_op_features {
out.push(("allow_op_features", Tag::Byte(1)));
}
out.extend([
("front_text", side(&self.front)),
("back_text", side(&self.back)),
("is_waxed", Tag::Byte(i8::from(self.waxed))),
]
]);
out
}
pub fn decode(&mut self, tag: &Tag) {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
for (line, m) in out.lines.iter_mut().zip(messages) {
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
*line = component_text(m);
*click = click_event(m);
}
}
if let Some(Tag::String(c)) = t.get("color") {
@@ -117,6 +185,21 @@ impl SignData {
self.front = side(tag.get("front_text"));
self.back = side(tag.get("back_text"));
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
self.allow_op_features =
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
}
}
/// The `click_event` in the style of a stored line: the one of a component
/// object, or of the first component of a list (`createFromList` copies it
/// and appends the rest as siblings).
fn click_event(tag: &Tag) -> Option<Tag> {
let root = match tag {
Tag::List(list) => list.first()?,
other => other,
};
match root.get("click_event") {
Some(event @ Tag::Compound(_)) => Some(event.clone()),
_ => None,
}
}
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
@@ -159,6 +159,14 @@ impl Sprites {
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
}
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
/// size, but neither their pixels nor the animations' frames.
pub fn finish_without_pixels(&mut self) {
self.lay_out(false);
}
fn lay_out(&mut self, pixels: bool) {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
@@ -166,6 +174,12 @@ impl Sprites {
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.size = size;
return;
}
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
+97 -25
View File
@@ -4,7 +4,7 @@
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::{
chunk::{Chunk, SECTIONS},
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -16,11 +16,15 @@ use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
};
/// How long a worker waits with nothing to do before it gives back its
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
@@ -57,6 +61,9 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// What a loaded chunk needs from the generator before the game uses it,
/// on the worker that loaded it.
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -220,6 +227,9 @@ struct Shared {
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
@@ -310,6 +320,8 @@ pub struct Streaming {
deferred: Vec<Done>,
sequence: u64,
pub center: (i32, i32),
/// The view distance around `center`; below 0, no area there (a dedicated
/// server nobody is on).
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
@@ -513,7 +525,9 @@ impl Streaming {
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
released: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -534,14 +548,42 @@ impl Streaming {
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
queue = shared.ready.wait(queue).unwrap();
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache) until the next job needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
{
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
@@ -677,7 +719,7 @@ impl Streaming {
self.meshing.clear();
for &(x, z) in world.chunks.keys() {
if self.lit.contains(&(x, z)) {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
}
@@ -695,7 +737,7 @@ impl Streaming {
self.generated_total += 1;
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
@@ -710,7 +752,7 @@ impl Streaming {
for dy in -1..=1 {
for dx in -1..=1 {
let s = sy as i32 + dy;
if (0..SECTIONS as i32).contains(&s) {
if (0..world.layout().sections() as i32).contains(&s) {
world.dirty_sections.insert((x + dx, s, z + dz));
}
}
@@ -736,7 +778,7 @@ impl Streaming {
self.lighting.remove(&(x, z));
// By key, not by a pass over every pending section: a moved or
// shrunk storage forgets thousands of columns in one frame.
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
self.meshing.remove(&(x, sy, z));
self.waiting.remove(&(x, sy, z));
}
@@ -854,7 +896,7 @@ impl Streaming {
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0, sy, key.1));
}
}
@@ -1037,7 +1079,7 @@ impl Streaming {
if first {
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x + dx, sy, z + dz));
}
}
@@ -1105,7 +1147,9 @@ impl Streaming {
.iter()
.filter(|&(&(x, z), p)| {
p.is_some()
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& (self.radius < 0
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
@@ -1331,7 +1375,7 @@ impl Streaming {
}
if !self.ranges().in_view(x, z) {
world.dirty_sections.take_column(x, z);
for sy in section_indexes(sections) {
for sy in sections.iter().map(|sy| sy as i32) {
self.waiting.insert((x, sy, z));
}
continue;
@@ -1355,7 +1399,7 @@ impl Streaming {
continue;
};
world.dirty_sections.take_column(x, z);
for sy in section_indexes(sections) {
for sy in sections.iter().map(|sy| sy as i32) {
let first = self.meshed.insert((x, z));
self.sequence += 1;
self.meshing.insert((x, sy, z), self.sequence);
@@ -1472,7 +1516,7 @@ impl Streaming {
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
if let Some(log) = &mut world.light_log {
for (x, sy, z) in changed.sections() {
*log.entry((x, z)).or_default() |= 1 << sy;
log.entry((x, z)).or_default().insert(sy as usize);
}
}
changed.dirty_sections(|section| {
@@ -1556,6 +1600,16 @@ impl Streaming {
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
self.ranges().in_range(x, z, margin)
}
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
/// chunk ranked before.
pub fn rank_full_task(&mut self, x: i32, z: i32) {
for dx in -1..=1 {
for dz in -1..=1 {
self.decoration_order.rank_or_append(x + dx, z + dz);
}
}
}
pub fn band(&self) -> Option<Band> {
self.band
}
@@ -1861,7 +1915,7 @@ impl Ranges<'_> {
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
let r = i64::from(self.radius + 1);
// The extra areas are squares: their finished square grown by `reach`.
dx * dx + dz * dz <= r * r
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|| self.in_extra(x, z, 1 + reach)
|| self.in_band(x, z, 1 + reach)
}
@@ -1894,7 +1948,7 @@ impl Ranges<'_> {
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
if dx * dx + dz * dz <= r * r {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
// A forced chunk holds a FORCED ticket at level 31
@@ -1931,16 +1985,6 @@ impl Drop for Streaming {
}
}
}
/// The section indexes set in a column's bits, lowest first.
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
std::iter::from_fn(move || {
(bits != 0).then(|| {
let sy = bits.trailing_zeros() as i32;
bits &= bits - 1;
sy
})
})
}
/// The queued jobs' priorities computed again from each one's column
/// `distance` (`Streaming::submit`).
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
@@ -2529,6 +2573,34 @@ mod tests {
}
}
/// An idle dedicated server keeps no area around its centre: only a spawn
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
/// forced chunks.
#[test]
fn no_area_around_an_idle_servers_centre() {
let forced = HashSet::from([(30, 30)]);
let extra = [((5, -3), -1)];
let r = Ranges {
center: (5, -3),
radius: -1,
extra: &extra,
forced: &forced,
cover: None,
band: None,
};
for x in -5i32..=15 {
for z in -13i32..=7 {
let d = (x - 5).abs().max((z + 3).abs());
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
}
}
assert!(r.in_range(30, 30, 1));
let idle = Ranges { extra: &[], ..r };
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
}
/// A server player's area (`lan_update_streaming`: the server view
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
+15
View File
@@ -1078,6 +1078,21 @@ impl EditBox {
self.set_cursor(pos, shift);
}
}
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
/// the cursor is selected, its end found past non-whitespace only
/// (`getWordEndPosition`).
pub fn select_word_at_cursor(&mut self) {
let begin = multiline_word_position(&self.value, -1, self.cursor);
let end = begin
+ self
.value
.chars()
.skip(begin)
.take_while(|c| !java_whitespace(*c))
.count();
self.set_cursor(begin, false);
self.set_cursor(end, true);
}
/// EditBox.onDrag: the selection follows the pointer.
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
let pos = self.position_at(atlas, x);
+45 -27
View File
@@ -1,7 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::{
chunk::{Chunk, ColumnLayout, LevelShape},
fast_hash::{FxHashMap, FxHashSet},
section_mask::SectionMask,
};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
@@ -11,28 +14,30 @@ use std::sync::Arc;
/// rather than at every section of it.
#[derive(Clone, Debug, Default)]
pub struct DirtySections {
columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
}
const _: () = assert!(SECTIONS <= 32);
impl DirtySections {
pub fn insert(&mut self, (x, sy, z): (i32, i32, i32)) {
debug_assert!((0..SECTIONS as i32).contains(&sy));
*self.columns.entry((x, z)).or_default() |= 1 << sy;
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
self.columns.entry((x, z)).or_default().insert(sy as usize);
}
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
(0..SECTIONS as i32).contains(&sy)
&& self.columns.get(&(x, z)).is_some_and(|m| m & 1 << sy != 0)
(0..SectionMask::CAPACITY as i32).contains(&sy)
&& self
.columns
.get(&(x, z))
.is_some_and(|m| m.contains(sy as usize))
}
/// The dirty section indexes of column (x, z), as bits, removed.
pub fn take_column(&mut self, x: i32, z: i32) -> u32 {
self.columns.remove(&(x, z)).unwrap_or(0)
/// The dirty section indexes of column (x, z), removed.
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
self.columns.remove(&(x, z)).unwrap_or_default()
}
/// Keep the columns `keep` accepts.
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
self.columns.retain(|&(x, z), _| keep(x, z));
}
/// The columns holding dirty sections, with their section bits.
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
/// The columns holding dirty sections, with their section indexes.
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), SectionMask)> + '_ {
self.columns.iter().map(|(&c, &m)| (c, m))
}
pub fn clear(&mut self) {
@@ -43,10 +48,13 @@ impl DirtySections {
}
/// How many sections are dirty.
pub fn len(&self) -> usize {
self.columns.values().map(|m| m.count_ones() as usize).sum()
self.columns.values().map(SectionMask::len).sum()
}
}
pub struct World {
/// The level's build range and skylight (its dimension type); its
/// chunks hold `shape.column()`.
pub shape: LevelShape,
pub fast_lava: bool,
pub chunks: crate::chunk_map::ChunkMap,
pub random: LegacyRandom,
@@ -88,7 +96,7 @@ pub struct World {
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), SectionMask>>,
/// The villager POIs of each section (`PoiManager`'s sections).
pub pois: crate::poi_sections::PoiSections<crate::poi_sections::Poi>,
/// Moving-piston states removed during the last tick; retained until the
@@ -165,6 +173,7 @@ impl Weather {
impl World {
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
chunks: Default::default(),
random: LegacyRandom::new(seed),
revision: 0,
@@ -249,6 +258,10 @@ impl World {
world.revision = 0;
Ok(world)
}
/// The column the level's chunks hold.
pub fn layout(&self) -> ColumnLayout {
self.shape.column()
}
/// Resident storage write; marks the touched sections (and neighbours on
/// section borders) for remeshing and the surrounding chunks for relighting.
/// Neighbour updates, drops and block entities belong to the gameplay layer.
@@ -256,7 +269,8 @@ impl World {
let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
return Err("write outside resident chunks".into());
};
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
let (min_y, sections) = (chunk.min_y(), chunk.sections.len() as i32);
if chunk.section_index(y).is_none() {
return Err("write outside world height".into());
}
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
@@ -276,14 +290,14 @@ impl World {
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
let sy = (y - min_y) >> 4;
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
if local_y == 15 && sy + 1 < sections {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
@@ -319,17 +333,20 @@ impl World {
Ok(())
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
/// block that isn't `air`, or the column's minimum Y (also where the chunk
/// isn't loaded).
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
let bottom = self.layout().min_y();
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
})
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block where `blocks` (motion blocking or holding a fluid),
/// or `MIN_Y` (also where the chunk isn't loaded).
/// or the column's minimum Y (also where the chunk isn't loaded).
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
let bottom = self.layout().min_y();
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
})
}
@@ -348,12 +365,13 @@ impl World {
hi: [i32; 3],
mut matches: impl FnMut(BlockStateId) -> bool,
) -> bool {
let top = MIN_Y + SECTIONS as i32 * 16 - 1;
let layout = self.layout();
let (bottom, top) = (layout.min_y(), layout.max_y() - 1);
// Heights outside the sections read as state 0.
if (lo[1] < MIN_Y || hi[1] > top) && matches(BlockStateId(0)) {
if (lo[1] < bottom || hi[1] > top) && matches(BlockStateId(0)) {
return true;
}
let sections = (lo[1].max(MIN_Y) - MIN_Y) >> 4..=(hi[1].min(top) - MIN_Y) >> 4;
let sections = (lo[1].max(bottom) - bottom) >> 4..=(hi[1].min(top) - bottom) >> 4;
for cx in lo[0] >> 4..=hi[0] >> 4 {
for cz in lo[2] >> 4..=hi[2] >> 4 {
let Some(chunk) = self.chunks.get(&(cx, cz)) else {
@@ -465,8 +483,8 @@ mod tests {
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
got.sort_unstable_by_key(|&(column, _)| column);
want.sort_unstable_by_key(|&(column, _)| column);
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
@@ -18,6 +18,7 @@ use horizon_client::{
};
use horizon_core::{geometry::Axis, random::LegacyRandom};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use serde_json::{Map, Value, json};
use std::{collections::BTreeMap, fs, path::Path};
@@ -77,9 +78,9 @@ fn main() -> Result<()> {
let assets = Assets::load(&root).map_err(step("assets"))?;
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items);
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
let collisions =
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
let loaded = began.elapsed().as_secs_f64() - built;
@@ -7,6 +7,7 @@ use horizon_client::{
chunk::{Chunk, MIN_Y, SECTIONS, Section},
fast_hash::FxHashMap,
portal_sections::PortalSections,
section_mask::SectionMask,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -83,17 +84,14 @@ fn may_hold_portal_cached(
if count > 64 {
return true;
}
let mut y_mask = 0u32;
for sy in y0..=y1 {
y_mask |= 1 << sy;
}
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
world.get(&(cx, cz)).is_some_and(|chunk| {
cache.get((cx, cz), chunk, |v| {
let portals = cache.get((cx, cz), chunk, |v| {
behaviours.portal_axis(BlockStateId(v)).is_some()
}) & y_mask
!= 0
});
!(portals & y_mask).is_empty()
})
})
})
@@ -16,6 +16,7 @@ use horizon_client::{
items::Items,
};
use horizon_core::random::LegacyRandom;
use horizon_world::loot::PackOverlay;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
@@ -127,7 +128,7 @@ pub fn main() -> Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let renderer = BlockEntityRenderer::load(root, registry)?;
let tags = Items::tags_only(root)?;
let tags = Items::tags_only(root, &PackOverlay::NONE)?;
let frames = tags
.block_tags
.get("minecraft:conduit_effect_block")
@@ -9,6 +9,7 @@ use horizon_client::{
items::Items,
};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use std::{fs, path::Path};
fn parse(text: &str) -> BlockState {
@@ -58,7 +59,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let assets = Assets::load(root)?;
let transformers = Transformers::load(root, assets.registry)?;
let mut out = String::new();
@@ -10,6 +10,7 @@ use horizon_client::{
menu::{Env, Kind, Menu},
};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, path::Path};
@@ -77,9 +78,9 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: cauldron_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
// The inventory menu never reads banner patterns.
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
@@ -3,6 +3,7 @@ use horizon_client::{
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
@@ -14,7 +15,7 @@ use std::{
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
@@ -8,6 +8,7 @@ use horizon_client::{
items::{ItemStack, Items},
stations,
};
use horizon_world::loot::PackOverlay;
use std::{fmt::Write as _, fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
@@ -17,7 +18,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: enchanting_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let e = Enchantments::load(root);
let mut out = String::new();
for line in input.lines() {
@@ -593,6 +593,7 @@ pub fn main() -> horizon_client::Result<()> {
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
flag("isRiding"),
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
@@ -8,7 +8,7 @@ use horizon_client::{
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack, PackOverlay};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
/// `id[;enchantment:level...]`.
@@ -64,7 +64,7 @@ pub fn main() -> horizon_client::Result<()> {
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root)?;
let loot = LootData::load(root, &PackOverlay::NONE)?;
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
@@ -23,6 +23,7 @@ use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fmt::Write as _, fs, path::Path, sync::Arc};
struct Level<'a> {
@@ -142,8 +143,8 @@ const GENES: [&str; 7] = [
"aggressive",
];
const MEMORIES: [&str; 7] = [
"long_jump_cooldown",
"ram_cooldown",
"long_jump_cooling_down",
"ram_cooldown_ticks",
"attack_target_cooldown",
"hunted_recently",
"dig_cooldown",
@@ -399,7 +400,7 @@ pub fn main() -> horizon_client::Result<()> {
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let mob_data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let types = MobTypes::load(root)?;
let registries = SpawnRegistries::load(root);
@@ -506,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
difficulty,
data: &data,
created: Vec::new(),
jockey_rider: None,
};
group = finalize::finalize(&mut cx, &mut mob, reason, group);
let created = std::mem::take(&mut cx.created);
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
recipe_book::{Book, BookType, GhostTarget, RecipeIndex},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fmt::Write as _, fs, path::Path};
@@ -18,9 +19,9 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: ghost_recipe_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let index = RecipeIndex::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&index);
let mut out = String::new();
@@ -5,6 +5,7 @@
//!
//! usage: hotbar_fixture RESOURCES CASES OUTPUT
use horizon_client::{hotbar, item_data::canon_stack, items::Items};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::path::Path;
@@ -13,7 +14,10 @@ use std::{fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: hotbar_fixture RESOURCES CASES OUTPUT";
let items = Items::catalog(Path::new(args.first().and(args.get(1)).ok_or(usage)?))?;
let items = Items::catalog(
Path::new(args.first().and(args.get(1)).ok_or(usage)?),
&PackOverlay::NONE,
)?;
let known = |id: &str| items.get(id).is_some();
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let mut out = String::new();
@@ -15,6 +15,9 @@ struct World {
passing: bool,
}
impl InteractionWorld for World {
// The fixture's targets don't change within a tick.
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -13,6 +13,7 @@ use horizon_client::{
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::registry::Ids;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{
cell::{Cell, RefCell},
@@ -83,11 +84,12 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
let station_recipes =
horizon_client::stations::StationRecipes::load(root, &items, &PackOverlay::NONE);
let enchantments = horizon_client::enchant::Enchantments::load(root);
let names = horizon_client::creative::Names::load(root);
let case_maps = CaseMaps::default();
@@ -27,6 +27,7 @@ use horizon_core::{
geometry::{Aabb, Vec3},
random::LegacyRandom,
};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap},
@@ -196,7 +197,11 @@ fn who(w: Option<Who>) -> String {
fn goal_name(m: &Mob, name: &str) -> String {
let vanilla = match name {
"Float" => "FloatGoal",
"RandomFloatAround" => "RandomFloatAroundGoal",
"GhastLook" => "GhastLookGoal",
"GhastShoot" => "GhastShootFireballGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"ParrotWander" => "ParrotWanderGoal",
"RandomStroll" => "RandomStrollGoal",
// AbstractFish's private subclass of RandomSwimmingGoal.
"RandomSwimming"
@@ -223,6 +228,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"MoveThroughVillage" => "MoveThroughVillageGoal",
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
@@ -873,7 +880,7 @@ pub fn main() -> horizon_client::Result<()> {
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let biomes = Biomes::load(root)?;
let items = Items::load(root, registry, &facts)?;
let items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
let tables = LightTables::load(root, registry, &facts)?;
let collision = collisions::load(root, registry)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
@@ -53,7 +53,8 @@ use horizon_core::{
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
registry::BlockStateId,
};
use serde_json::Value;
use std::{
@@ -522,11 +523,11 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
// Trial spawner rewards: the loot tables and the configs.
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
horizon_client::mobs::trial_spawner::load_registry(root);
horizon_client::structure_blocks::loot_data(root)
horizon_client::structure_blocks::loot_data(root, &PackOverlay::NONE)
} else {
None
};
@@ -795,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |l, r, _| spawn(l, r).is_some(),
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
..ParticleOptions::simple(ty)
},
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
ParticleType::Geyser | ParticleType::GeyserPlume => {
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
}
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
ty,
o["water_blocks"].as_i64()? as i32,
o["burst_impulse_base"].as_f64()? as f32,
),
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
ParticleType::Trail => {
let t = &o["target"];
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
menu::{Env, Kind, Menu, place::PostPlace},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, path::Path};
@@ -34,9 +35,9 @@ pub fn main() -> horizon_client::Result<()> {
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
@@ -19,6 +19,7 @@ use horizon_client::{
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use std::{
cell::RefCell,
@@ -470,7 +471,7 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let attributes = GameplayAttributes::load(root);
let shared = Shared {
facts: &facts,
@@ -9,6 +9,7 @@ use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -66,7 +67,7 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let index = RecipeIndex::load(root);
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut out = String::new();
for line in cases.lines() {
let case: Value = serde_json::from_str(line)?;
@@ -16,6 +16,7 @@ use horizon_client::{
TabView, draw, overlay_cells, overlay_origin,
},
};
use horizon_world::loot::PackOverlay;
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::Path};
@@ -35,8 +36,8 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let book = Book::build(&recipes, &items);
let atlas = HudAtlas::build(root)?;
let root_buf = root.to_path_buf();
@@ -12,6 +12,7 @@ use horizon_client::{
menu::place::Stacked,
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fmt::Write as _, fs, path::Path};
@@ -20,9 +21,9 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let index = RecipeIndex::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&index);
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
stations::StationRecipes,
};
use horizon_world::loot::PackOverlay;
use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
@@ -19,9 +20,9 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let stations = StationRecipes::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let stations = StationRecipes::load(root, &items, &PackOverlay::NONE);
let enchantments = Enchantments::load(root);
let mut out = String::new();
for line in input.lines() {
@@ -14,6 +14,7 @@ use horizon_client::{
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap, HashSet},
@@ -147,7 +148,7 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let types = MobTypes::load(root)?;
let mut out = String::new();
for raw in input.lines() {
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
aquatic::squid_client_step(&mut mob, &mut Vec::new());
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
@@ -13,6 +13,7 @@ use horizon_client::{
random_sequences::RandomSequences,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
@@ -237,12 +238,12 @@ pub fn main() -> horizon_client::Result<()> {
let root = Path::new(args.get(1).ok_or(usage)?);
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
let trades = Trades::load(root);
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::default();
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
@@ -14,6 +14,7 @@ use horizon_client::{
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
vanillagen::VanillaTerrain,
};
use horizon_world::loot::PackOverlay;
use std::{
alloc::{GlobalAlloc, Layout, System},
sync::atomic::{AtomicUsize, Ordering::Relaxed},
@@ -105,9 +106,9 @@ fn main() -> Result<()> {
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
step("block facts+behaviours");
let items = Items::load(&root, assets.registry, &facts)?;
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE)?;
step("items");
let recipes = Recipes::load(&root, &items)?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
step("recipes");
let biomes = Biomes::load(&root)?;
step("biomes");
+43 -1
View File
@@ -5,7 +5,8 @@
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -66,8 +67,36 @@ fn frame(v: &Value) -> Frame {
}
}
impl Tree {
/// [`Tree::load`], shared while anyone holds it: a server's advancement
/// packets (`ServerAdvancements`) and its game's criteria read the same
/// files, and each kept its own copy (7 MB). A world whose data packs bring
/// advancements gets its own tree ([`Tree::load_with`]).
pub fn shared(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Arc<Tree> {
if overlay.kind("advancement").next().is_some() {
return Arc::new(Tree::load_with(data, overlay));
}
static TREES: Mutex<Vec<(PathBuf, Weak<Tree>)>> = Mutex::new(Vec::new());
// Held while loading: a second reader waits for the first one's tree.
let mut trees = TREES.lock().unwrap_or_else(|e| e.into_inner());
trees.retain(|(_, tree)| tree.strong_count() > 0);
if let Some(tree) = trees
.iter()
.find(|(path, _)| path == data)
.and_then(|(_, tree)| tree.upgrade())
{
return tree;
}
let tree = Arc::new(Tree::load(data));
trees.push((data.to_path_buf(), Arc::downgrade(&tree)));
tree
}
/// Loads `<data>/<namespace>/advancement/**/*.json`.
pub fn load(data: &Path) -> Tree {
Self::load_with(data, &horizon_world::loot::PackOverlay::NONE)
}
/// The tree with a world's data packs' advancement files over the game's
/// own: the top pack's file for each id replaces it.
pub fn load_with(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Tree {
let mut tree = Tree::default();
let Ok(namespaces) = std::fs::read_dir(data) else {
return tree;
@@ -77,6 +106,8 @@ impl Tree {
let root = ns.path().join("advancement");
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
}
// One JSON per id, the packs' over the game's own.
let mut json: BTreeMap<String, Value> = BTreeMap::new();
files.sort();
for (id, path) in files {
let Ok(text) = std::fs::read_to_string(&path) else {
@@ -85,6 +116,17 @@ impl Tree {
let Ok(v) = serde_json::from_str::<Value>(&text) else {
continue;
};
json.insert(id, v);
}
for (id, files) in overlay.kind("advancement") {
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
Ok(v) => {
json.insert(id.to_owned(), v);
}
Err(e) => log::warn!("Horizon: pack advancement {id} not loaded: {e}"),
}
}
for (id, v) in json {
if let Some(a) = parse(&id, &v) {
let index = tree.list.len();
for (ci, c) in a.criteria.iter().enumerate() {
+36 -15
View File
@@ -9,6 +9,7 @@ use crate::{
items::{ItemStack, Items},
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
@@ -1180,7 +1181,7 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
})
}
impl Recipes {
pub fn load(root: &Path, items: &Items) -> Result<Self> {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
@@ -1193,27 +1194,47 @@ impl Recipes {
files.sort();
Ok(files)
};
let stem = |p: &Path| {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned()
};
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
.into_iter()
.map(|p| (String::new(), p))
.collect();
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
for p in json_files(&root.join("data/minecraft/recipe"))? {
sources.push((stem(&p), fs::read(&p)?));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
files.extend(
json_files(&dir)?
.into_iter()
.map(|p| (format!("{namespace}:"), p)),
);
for p in json_files(&dir)? {
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
}
}
}
for (prefix, path) in files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let name = format!("{prefix}{stem}");
// The world's packs' recipes over the game's own: the top pack's
// file for each id replaces it (`SimpleJsonResourceReloadListener`
// sees one resource per id, the resource stack's top).
for (id, files) in overlay.kind("recipe") {
let Some((ns, path)) = id.split_once(':') else {
continue;
};
let name = if ns == "minecraft" {
path.to_owned()
} else {
id.to_owned()
};
let bytes = PackOverlay::top(files).to_vec();
match sources.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = bytes,
None => sources.push((name, bytes)),
}
}
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
let v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("Horizon: recipe {name} not loaded: {e}");
@@ -1420,7 +1441,7 @@ impl Recipes {
)
})
.collect();
let curses = crate::items::load_tags(root, "enchantment")?
let curses = crate::items::load_tags(root, "enchantment", overlay)?
.remove("minecraft:curse")
.unwrap_or_default();
Ok(Self {
+85 -1
View File
@@ -20,6 +20,9 @@ pub struct Tab {
pub column: u32,
pub search: bool,
pub inventory: bool,
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
@@ -229,7 +232,7 @@ impl Creative {
}
search_items = items.clone();
}
if kind == "HOTBAR" || t["visible"] == false {
if t["visible"] == false {
continue;
}
let icon = t["icon"]
@@ -244,6 +247,7 @@ impl Creative {
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
});
}
Ok(Self::new(tabs, search_items, search_index))
@@ -277,6 +281,7 @@ impl Creative {
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
});
}
let search_index = search_items
@@ -333,6 +338,7 @@ impl Creative {
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
}
@@ -464,6 +470,23 @@ impl Creative {
});
items
}
/// The stack in grid cell `index` (row-major from the tab's top), None
/// for an empty cell: a saved toolbar's empty slot, or past the end.
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
self.items(names)
.get(index)
.filter(|s| s.count > 0)
.cloned()
}
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
/// 0) for an empty slot.
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
tab.items = items;
}
self.forget_items();
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
@@ -664,6 +687,13 @@ pub enum DisplayClick {
Drop(ItemStack),
}
/// Whether a stack has `minecraft:creative_slot_lock`.
pub fn slot_locked(stack: &ItemStack) -> bool {
stack
.data()
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
}
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
/// the tab's end) and `max` gives an item's max stack size. The tab's items
@@ -676,6 +706,11 @@ pub fn click_display(
input: crate::menu::Input,
) -> DisplayClick {
use crate::menu::Input;
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
// toolbars' hint) can't be clicked at all.
if shown.is_some_and(slot_locked) {
return DisplayClick::Nothing;
}
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
let quick_key = input == Input::QuickMove;
match input {
@@ -738,6 +773,7 @@ pub fn click_display(
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
@@ -1047,6 +1083,53 @@ mod tests {
assert_eq!(c.search.value, "");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
]}))
.unwrap();
assert!(c.tab().hotbar);
assert!(!c.tab_headers()[1].hotbar);
let mut hint = stack("paper", 1);
hint.data_mut().set_other(
"minecraft:creative_slot_lock",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
// A saved sword in slot 0, an empty slot, then the hint.
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
let names = Names {
map: HashMap::new(),
};
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
assert_eq!(c.shown(&names, 1), None);
assert_eq!(c.shown(&names, 3), None);
let hint = c.shown(&names, 2).unwrap();
assert!(slot_locked(&hint));
assert!(!slot_locked(&stack("diamond_sword", 1)));
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
// stack stays as it is.
use crate::menu::Input::*;
for (carried, button, input) in [
(None, 0, Pickup),
(None, 0, QuickMove),
(None, 2, Clone),
(None, 3, Swap),
(None, 0, Throw),
(Some(stack("dirt", 2)), 0, Pickup),
(Some(stack("dirt", 2)), 1, Pickup),
] {
let mut carried = carried;
let before = carried.clone();
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
assert_eq!(effect, DisplayClick::Nothing);
assert_eq!(carried, before);
}
}
#[test]
fn search_matches_vanilla_trees() {
let c = creative(0);
@@ -1191,6 +1274,7 @@ mod tests {
column,
search: false,
inventory: false,
hotbar: false,
};
// Top row, second column: the button spans x 27..53, y -28..4.
let t = tab(true, 1);
+7 -2
View File
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
};
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
// both on the left. The label is a plain StringWidget: it has no
// click handler, so a click event in it does nothing.
let mut shown = label.clone();
for span in &mut shown.spans {
span.style.click = None;
}
Element {
w: w.max(font.width_text(label)),
h: 9.0 + 4.0 + 20.0,
parts: vec![
Part::Line(label.clone(), 0.0, 0.0),
Part::Line(shown, 0.0, 0.0),
Part::Field {
input: i,
rect: [0.0, 13.0, w, 20.0],
+3 -1
View File
@@ -980,6 +980,7 @@ mod experience_tests {
#[cfg(test)]
mod fire_environment_tests {
use super::*;
use horizon_world::loot::PackOverlay;
#[test]
fn ordinary_items_burn_but_native_netherite_resists_lava() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -991,7 +992,8 @@ mod fire_environment_tests {
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let items =
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
world
+359 -38
View File
@@ -87,6 +87,11 @@ impl Base<'_> {
}
}
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
}
/// `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.
@@ -150,6 +155,123 @@ fn player_uuid() -> [i32; 4] {
u as u32 as i32,
]
}
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
if let Some(name) = &p.name {
entries.push(("name", string(name)));
}
if let Some(id) = p.uuid {
entries.push((
"id",
Tag::IntArray(vec![
(id >> 96) as i32,
(id >> 64) as i32,
(id >> 32) as i32,
id as i32,
]),
));
}
if !p.properties.is_empty() {
let properties = p
.properties
.iter()
.map(|property| {
let mut entries: Vec<(&'static str, Tag)> = vec![
("name", string(&property.name)),
("value", string(&property.value)),
];
if let Some(signature) = &property.signature {
entries.push(("signature", string(signature)));
}
Tag::compound(entries)
})
.collect();
entries.push(("properties", Tag::List(properties)));
}
for (key, texture) in [
("texture", &p.body),
("cape", &p.cape),
("elytra", &p.elytra),
] {
if let Some(texture) = texture {
entries.push((key, string(texture)));
}
}
if let Some(slim) = p.slim {
entries.push(("model", string(if slim { "slim" } else { "wide" })));
}
Tag::compound(entries)
}
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
if let Tag::String(name) = tag {
return Some(horizon_protocol::entity_data::Profile {
name: Some(name.to_string_lossy()),
uuid: None,
properties: Vec::new(),
body: None,
cape: None,
elytra: None,
slim: None,
});
}
let property = |name: String, value: &Tag, signature: Option<String>| match value {
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
name,
value: v.to_string_lossy(),
signature,
}),
_ => None,
};
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
Some(Tag::List(list)) => list
.iter()
.filter_map(|p| {
let name = match p.get("name")? {
Tag::String(s) => s.to_string_lossy(),
_ => return None,
};
let signature = match p.get("signature") {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
property(name, p.get("value")?, signature)
})
.collect(),
// ... or a map from a name to its values.
Some(Tag::Compound(map)) => map
.iter()
.flat_map(|(name, values)| match values {
Tag::List(values) => values
.iter()
.filter_map(|v| property(name.to_string_lossy(), v, None))
.collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
};
let text = |key: &str| match tag.get(key) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
Some(horizon_protocol::entity_data::Profile {
name: text("name"),
uuid: uuid_from_tag(tag.get("id")).map(|a| {
a.iter()
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
}),
properties,
body: text("texture"),
cape: text("cape"),
elytra: text("elytra"),
slim: text("model").map(|model| model == "slim"),
})
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -196,6 +318,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
}
}
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
if m.species() == crate::mobs::Species::Mannequin
&& let Some(profile) = &m.profile
{
put(&mut tag, "profile", profile_nbt(profile));
}
// `Entity.saveWithoutId` writes the flag only when set.
if m.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
@@ -286,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
let id = m.kind.id.as_str();
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
// parrot); a tadpole saves its own age.
let species = m.species();
if saves_age(species) {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
@@ -324,19 +447,29 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
// Fox.addAdditionalSaveData; Horizon models only the type, the
// flags and no trusted players yet.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
("Crouching", crate::mobs::fox::CROUCHING),
] {
map.entry(key.into()).or_insert(value);
map.insert(
key.into(),
Tag::Byte(i8::from(crate::mobs::fox::flag(m, bit))),
);
}
}
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
// effects are not modelled).
"minecraft:mooshroom" => {
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
map.insert("Type".into(), string(kind));
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
@@ -531,6 +664,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
"minecraft:villager" => {
map.insert("VillagerData".into(), villager_data(m));
// A loaded villager with VillagerData is finalized.
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
map.insert("Xp".into(), Tag::Int(t.xp));
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
@@ -584,32 +719,56 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
]));
}
map.insert("Inventory".into(), Tag::List(inv));
if let Some(st) = &m.extra.allay {
map.insert("listener".into(), st.vibration.to_nbt());
}
map.insert(
"DuplicationCooldown".into(),
Tag::Long(i64::from(m.extra.egg_time)),
);
let mut memories: Vec<(&str, Tag)> = Vec::new();
if m.extra.tame {
map.insert(
"Brain".into(),
memories.push((
"minecraft:liked_player",
Tag::compound([(
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
)]),
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
);
));
}
// GlobalPos.CODEC, and the cooldown while it runs.
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
memories.push((
"minecraft:liked_noteblock",
Tag::compound([(
"value",
Tag::compound([
("dimension", string(liked.dimension)),
("pos", Tag::IntArray(liked.pos.to_vec())),
]),
)]),
));
}
if m.extra.ints[2] > 0 {
memories.push((
"minecraft:liked_noteblock_cooldown_ticks",
Tag::compound([("value", Tag::Int(m.extra.ints[2]))]),
));
}
// Always written, so a forgotten note block does not come back
// from the compound the allay was loaded with.
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::compound(memories))]),
);
}
"minecraft:camel" | "minecraft:camel_husk" => {
// LastPoseTick: negative while sitting.
let sitting = crate::mobs::camel::sitting(m);
// LastPoseTick: the pose change time, negative while sitting. The
// game time is not known here; the world's save overwrites it
// with the real one (`camel::synced_pose_tick`).
map.insert(
"LastPoseTick".into(),
Tag::Long(if sitting { -1 } else { 0 }),
Tag::Long(crate::mobs::camel::synced_pose_tick(m, 0)),
);
if let Some(saddle) = m.saddle_item() {
map.insert(
@@ -667,6 +826,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
}
// GlowSquid.addAdditionalSaveData.
"minecraft:glow_squid" => {
map.insert(
"DarkTicksRemaining".into(),
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
@@ -728,9 +894,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
if matches!(
id,
"minecraft:iron_golem"
| "minecraft:wolf"
| "minecraft:polar_bear"
| "minecraft:zombified_piglin"
| "minecraft:enderman"
) {
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
// The attribute their load makes, which AttributeMap.pack then saves:
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
// the attack damage.
use crate::mobs::finalize::Attr;
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
{
Some(Attr::MovementSpeed)
} else if species == crate::mobs::Species::Rabbit {
Some(Attr::AttackDamage)
} else {
None
};
if let Some(attr) = made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
let attr_id = format!("minecraft:{}", attr.name());
if !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
list.push(Tag::compound([
("id", string(&attr_id)),
("base", Tag::Double(base.to_bits())),
]));
map.insert("attributes".into(), Tag::List(list));
}
}
// Mob.addAdditionalSaveData: the chances that are not the default.
if let Some(chances) = &m.extra.drop_chances {
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
.iter()
.zip(chances.iter())
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
.collect();
if set.is_empty() {
map.remove(&"drop_chances".into());
} else {
map.insert("drop_chances".into(), Tag::compound(set));
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -1046,11 +1267,24 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.angry_at = Some(crate::mobs::Who::Player);
}
}
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
if m.species() == crate::mobs::Species::Mannequin {
m.profile = tag.get("profile").and_then(profile_from_nbt);
}
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
*chance = f32::from_bits(*v);
}
}
m.extra.drop_chances = Some(Box::new(chances));
}
if saves_age(m.species()) {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -1181,6 +1415,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
("Crouching", crate::mobs::fox::CROUCHING),
] {
if flag(tag, key) {
m.extra.ints[0] |= bit;
}
}
}
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
// Variant.DEFAULT, red.
crate::mobs::Species::Mooshroom => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
@@ -1256,6 +1505,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
if let (Some(st), Some(listener)) = (m.extra.allay.as_mut(), tag.get("listener")) {
st.vibration = crate::sculk::Data::from_nbt(listener);
}
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
@@ -1264,13 +1516,37 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
let memory = |key: &str| {
tag.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get(key))
.and_then(|l| l.get("value"))
};
m.extra.ints[2] =
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
if let Some(st) = m.extra.allay.as_mut()
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
&& p.len() == 3
{
st.noteblock = Some(crate::mobs::allay::GlobalPos {
dimension: crate::mobs::allay::dimension_id(
&text(noteblock.get("dimension")).unwrap_or_default(),
),
pos: [p[0], p[1], p[2]],
});
}
}
if crate::mobs::camel::is_camel(m.species()) {
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
// The tick is a game time: `camel::finish_load` puts it on the mob's
// clock once the world's time is known.
let tick = match tag.get("LastPoseTick") {
Some(Tag::Long(v)) => *v,
_ => 0,
};
crate::mobs::camel::stand_up_instantly(&mut m);
if sitting {
m.extra.egg_time = -1 - 400;
}
m.extra.pose_change_tick = tick;
m.extra.camel_sitting = tick < 0;
// Camels are always tame (`Camel.register` in Horizon).
m.extra.tame = true;
}
@@ -1357,6 +1633,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
if m.species() == crate::mobs::Species::GlowSquid {
crate::mobs::aquatic::set_dark_ticks(
&mut m,
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
);
}
// IronGolem.readAdditionalSaveData: not player created when absent.
if m.species() == crate::mobs::Species::IronGolem {
m.extra.player_created = flag(tag, "PlayerCreated");
@@ -3014,7 +3297,34 @@ mod sheep_nbt_tests {
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
fn a_baby_villager_keeps_its_age() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let saved = Tag::compound([
("id", string("minecraft:villager")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Age", Tag::Int(-21359)),
]);
let m = mob_from_nbt(&saved, &types, 1).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
#[test]
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -3038,6 +3348,17 @@ mod sheep_nbt_tests {
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
// MushroomCow: a brown mooshroom stays brown, and a save without (or
// with an unknown) Type reads as red.
let mut mooshroom = new("minecraft:mooshroom");
mooshroom.extra.variant = 1;
let (back, tag) = round_trip(&mooshroom);
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
assert_eq!(back.extra.variant, 1);
mooshroom.extra.variant = 0;
let (back, tag) = round_trip(&mooshroom);
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
assert_eq!(back.extra.variant, 0);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
+116 -9
View File
@@ -1,5 +1,6 @@
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
//! the cobwebs that hold it back.
//! the blocks it moves through (cobwebs, bubble columns).
use crate::motion::BubbleColumn;
use crate::{
mobs::{Damage as MobDamage, DamageKind, Mob},
save::{float, number, put},
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
pub fn web_multiplier() -> Vec3 {
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
}
/// What a block a falling block moves through does to it in `entityInside`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Inside {
/// `WebBlock`: `Entity.makeStuckInBlock`.
Cobweb,
/// `BubbleColumnBlock`: lifts or drags it.
BubbleColumn(BubbleColumn),
Nothing,
}
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
/// (`from` to `to`; `original` the move it asked for, when the move was
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
/// `entityInside` makes it stuck. Each movement sweeps its axes in
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
pub fn passes_cobweb(
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
/// the blocks it went through (`block` says what each is) push it and reset
/// its fall. Returns whether a cobweb made it stuck.
pub fn apply_effects_from_blocks(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
mut is_web: impl FnMut(BlockPos) -> bool,
velocity: &mut Vec3,
fall_distance: &mut f64,
mut block: impl FnMut(BlockPos) -> Inside,
) -> bool {
let mut stuck = false;
inside_blocks(from, to, original, |p, intersects| match block(p) {
Inside::Cobweb => {
// makeStuckInBlock forgets the fall.
*fall_distance = 0.0;
stuck = true;
}
// BubbleColumnBlock.entityInside acts only when intersecting.
Inside::BubbleColumn(column) if intersects => {
if column.push(velocity) {
*fall_distance = 0.0;
}
}
Inside::BubbleColumn(_) | Inside::Nothing => {}
});
stuck
}
/// Each block `checkInsideBlocks` reaches, once, in order, with
/// whether its `entityInside` counts the block as intersected (every block of a
/// movement a block or longer, else only those the box ends in). Each movement
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
fn inside_blocks(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
mut visit: impl FnMut(BlockPos, bool),
) {
let mut seen = std::collections::HashSet::new();
// checkInsideBlocks(from, to, ..): the iterations used plus one.
let mut check = |from: Vec3, to: Vec3, max: i32| {
let d = f64::from(1.0e-5_f32);
let target = bounds(to).deflate(Vec3::new(d, d, d));
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
let mut iterations = 0;
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
if iteration >= max {
return false;
}
iterations = iteration;
// getEntityInsideCollisionShape is the whole block for cobwebs.
stuck |= is_web(p);
if seen.insert(p) {
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
let inside =
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
visit(p, long || inside);
}
true
});
iterations + 1
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
if budget <= 0 {
check(to, to, 1);
}
stuck
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
@@ -249,6 +295,23 @@ mod tests {
assert!(!wear_anvil(&mut state));
}
}
fn passes_cobweb(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
is_web: impl Fn(BlockPos) -> bool,
) -> bool {
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
if is_web(p) {
Inside::Cobweb
} else {
Inside::Nothing
}
});
assert_eq!(fall == 0.0, stuck);
stuck
}
#[test]
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
let web = BlockPos::new(0, 5, 0);
@@ -285,6 +348,50 @@ mod tests {
assert!(!passes_cobweb(beside, beside, None, is_web));
}
#[test]
fn bubble_columns_lift_or_drag_a_falling_block() {
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
let block = |p: BlockPos| match (p.x, p.y, p.z) {
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
drag: p.x == 3,
surface: p.y == 9,
}),
_ => Inside::Nothing,
};
let step = |x: f64, from: f64, to: f64, vy: f64| {
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
let mut velocity = Vec3::new(0.0, vy, 0.0);
let mut fall = 2.0;
let original = Some(to.subtract(from));
let stuck =
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
assert!(!stuck);
(velocity.y, fall)
};
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
// Released in the column: its box ends in blocks 5 and 6, each lifts
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
assert!(near(vy, 0.08), "{vy}");
assert_eq!(fall, 0.0);
// The lift is capped at 0.7.
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
// Falling a block or more into the surface block: every column
// block on the way counts, the surface throws it up by 0.1
// (handleOnAboveBubbleColumn), which keeps its fall.
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
assert!(near(vy, -1.5 + 0.1), "{vy}");
assert_eq!(fall, 2.0);
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
// A short move that leaves the column behind: the block it went
// through no longer intersects its box, so nothing pushes it.
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
// Beside the column.
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
}
#[test]
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
use crate::mobs::{Mob, MobTypes};
File diff suppressed because it is too large. Load diff
+49 -7
View File
@@ -115,10 +115,18 @@ pub(super) struct AdvState {
pub(super) announced: Vec<Text>,
/// The tree being read on another thread since the world opened
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
pub(super) pending_tree: Option<std::thread::JoinHandle<Arc<Tree>>>,
/// `inventory_changed`'s criteria by item, built for `tree`.
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
}
impl AdvState {
/// Drop the prefetched tree and the index built for the old one (a
/// `/reload` rebuilt the tree).
pub(super) fn reset_tree(&mut self) {
self.pending_tree = None;
self.inventory_index = None;
}
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
impl adv::World for View<'_, '_> {
@@ -261,9 +269,12 @@ impl Game<'_> {
if self.adv.tree.list.is_empty() {
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
Some(Ok(tree)) => tree,
_ => Tree::load(&self.options.resources.join("data")),
_ => {
let overlay = self.pack_files();
Tree::shared(&self.options.resources.join("data"), &overlay)
}
};
self.adv.tree = Arc::new(tree);
self.adv.tree = tree;
// With the tree, while the world loads: built on the first
// inventory change it cost that tick ~10 ms.
self.inventory_index();
@@ -278,7 +289,7 @@ impl Game<'_> {
{
return index.clone();
}
let items = self.res.items();
let items = self.items();
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
items
.item_tags
@@ -297,9 +308,15 @@ impl Game<'_> {
return;
}
let data = self.options.resources.join("data");
// The world's packs' advancements go over the game's own.
let files = super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
);
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
.spawn(move || Tree::load(&data))
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
.ok();
}
pub(super) fn load_advancements(&mut self) {
@@ -537,13 +554,13 @@ impl Game<'_> {
.rewards
.recipes
.iter()
.filter(|r| self.res.stations().recipe_index.contains(r))
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&self.res.stations().recipe_index, &rewards);
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = self
.res
@@ -1089,6 +1106,31 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
/// guest `as_guest` runs) picked up an item entity whose owner
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
/// what is left after a partial take. An owner that is that player also
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
/// gone, or another player, fires nothing here.
pub(super) fn trigger_thrown_picked_up_by_player(
&mut self,
stack: &ItemStack,
thrower: [i32; 4],
) {
let target = if thrower == self.acting_uuid() {
let me = self.player_entity();
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
me
} else if let Some(id) = self.mob_by_uuid(thrower) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
self.mob_entity(m)
} else {
return;
};
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
+3 -1
View File
@@ -486,6 +486,8 @@ impl Game<'_> {
// LivingEntity.deathTime while dead (the camera's death roll).
"death_time": self.death_time(),
"air": p.air,
// LocalPlayer.portalEffectIntensity: the Nether portal overlay.
"portal_effect": self.screen_fx.portal,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
"shield_cooldown": self.shield_cooldown,
@@ -598,7 +600,7 @@ impl Game<'_> {
Mode::Delete { world } => {
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
}
Mode::BackupBeforeUpgrade { world } => {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Error { message, .. } => {
+22 -7
View File
@@ -2,7 +2,11 @@
//! throwing their load to the player or note block, dancing to jukeboxes,
//! and the hand-over interaction (`Allay.mobInteract`).
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Species};
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
mobs::{Species, allay},
};
use horizon_core::random::RandomSource;
impl Game<'_> {
@@ -131,14 +135,20 @@ impl Game<'_> {
if m.extra.ints[1] <= 0 {
return;
}
// AllayAi.getItemDepositPosition: the liked note block, else the player.
let dimension = self.dim.id();
let noteblock = allay::noteblock_deposit(&mut self.mobs[i], dimension, |p| {
&*self
.res
.facts()
.get(self.world.block(p[0], p[1], p[2]))
.name
== "minecraft:note_block"
});
let player = self.allay_liked_player(i);
let m = &self.mobs[i];
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
Some(h) => horizon_core::geometry::Vec3::new(
f64::from(h[0]) + 0.5,
f64::from(h[1]) + 1.0,
f64::from(h[2]) + 0.5,
),
let target = match noteblock {
Some(p) => horizon_core::geometry::Vec3::new(p[0], p[1], p[2]),
None => match player.filter(|_| m.extra.tame) {
Some(p) => p,
None => return,
@@ -181,6 +191,11 @@ impl Game<'_> {
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
let allay = self.mob_uuid(self.mobs[i].id);
if let Some(thrown) = self.dropped.last_mut() {
thrown.thrower = Some(allay);
}
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
+25 -10
View File
@@ -137,16 +137,16 @@ impl ArmorStandAssets {
}
}
impl Game<'_> {
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
/// each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
/// overlay) of each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32)> {
self.stack_armor_layers(&self.armor)
}
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
pub(super) fn stack_armor_layers(
&self,
armor: &[Option<ItemStack>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
) -> Vec<(&Part, [f32; 4], u32, u32)> {
self.armor_layers(
armor
.each_ref()
@@ -155,7 +155,7 @@ impl Game<'_> {
}
/// `HumanoidArmorLayer` for another player's model: the armour it wears
/// (`extra.head` .. `extra.feet`).
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32, u32)> {
let e = &m.extra;
self.armor_layers(
[
@@ -172,7 +172,7 @@ impl Game<'_> {
fn armor_layers(
&self,
worn: [Option<(&str, Option<&ItemStack>)>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
) -> Vec<(&Part, [f32; 4], u32, u32)> {
let assets = &self.armor_stand_assets;
let mut out = Vec::new();
for (item, layer) in worn
@@ -206,13 +206,27 @@ impl Game<'_> {
continue;
};
let color = layer_color(*undyed, stack);
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
// glint on the layer and its trim when `stack.hasFoil()`.
let item_glint = self
.res
.items()
.enchantments
.props
.get(item)
.is_some_and(|p| p.glint);
let overlay = if stack.is_some_and(|s| s.has_foil(item_glint)) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
.and_then(|s| assets.trim_texture(&asset, kind, s))
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
{
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF, overlay));
}
}
out
@@ -224,7 +238,7 @@ impl Game<'_> {
pub(super) fn prepare_trims(&mut self) {
use horizon_client::hud::TRIM_SLOTS;
let assets = &self.armor_stand_assets;
let items = self.res.items();
let items = self.items();
let mut wanted: Vec<TrimTexture> = Vec::new();
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
let stacks = self
@@ -820,6 +834,7 @@ impl Game<'_> {
horizon_client::mobs::ElytraAnimation::RESTING,
false,
packed,
self.wings_overlay(a.equipment[4].as_ref()),
out,
);
self.cull_entity_vertices(out, entity_start);
@@ -866,7 +881,7 @@ impl Game<'_> {
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
/// its slot (the worn piece comes back to the hand).
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
let Some(info) = self.res.items().get(&stack.item) else {
let Some(info) = self.items().get(&stack.item) else {
return false;
};
let (index, slot) = match info.equip_slot.as_deref() {
+1 -1
View File
@@ -14,7 +14,7 @@ impl Game<'_> {
else {
return Vec::new();
};
layer_textures(&self.res.stations().banners, &b.layers)
layer_textures(&self.stations().banners, &b.layers)
}
}
+10
View File
@@ -67,6 +67,16 @@ impl Game<'_> {
for head in left {
self.player_beds.retain(|b| *b != (dim, head));
self.set_bed_occupied(head, false);
// AbstractBedBlock.onStopSleeping: the Overworld's straw_bed_rule
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
let bed = self.world.block(head[0], head[1], head[2]);
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
if dim == Dim::Overworld
&& straw
&& let Err(e) = self.destroy_straw_bed(head)
{
log::warn!("could not break the straw bed at {head:?}: {e}");
}
}
}
@@ -33,6 +33,10 @@ impl Game<'_> {
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let time = self.world.game_time;
// PotentSulfurBlock.triggerEvent.
if self.potent_sulfur_eruption_event((pos[0], pos[1], pos[2])) {
return;
}
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.be_anims
@@ -1,6 +1,6 @@
//! `Level.tickBlockEntities` for the block entities ticked together here
//! (hoppers, crafters, jukeboxes, beacons, creaking hearts, spawners, trial
//! spawners, vaults, beehives and furnaces): only those whose state has a
//! spawners, vaults, beehives, furnaces and potent sulfur): only those whose state has a
//! ticker, in vanilla's list order (`horizon_client::block_entities`), in
//! chunks that tick blocks. Sculk, brewing stands and pistons tick in their
//! own passes.
@@ -64,6 +64,14 @@ impl Game<'_> {
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
Some(BlockEntity::PotentSulfur(_)) => {
self.tick_potent_sulfur(pos)?;
// A window that simulates the world itself is its own
// client; a server's clients tick it on their own.
if self.options.execution_role.is_client() {
self.client_tick_potent_sulfur(pos);
}
}
_ => {}
}
}
@@ -105,6 +113,14 @@ impl Game<'_> {
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
// Every state but dry has a ticker.
"minecraft:potent_sulfur" => {
let dry = registry
.state(state)
.and_then(|s| s.properties.get("potent_sulfur_state"))
.is_some_and(|v| v == "dry");
if dry { Ticking::Idle } else { Ticking::Yes }
}
"minecraft:jukebox" => {
let record = registry
.state(state)
@@ -141,7 +157,11 @@ impl Game<'_> {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let Some(lit) = f.tick(self.res.recipes(), self.res.items(), self.res.providers()) else {
let Some(lit) = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
) else {
return Ok(());
};
let state = self.world.block(pos.0, pos.1, pos.2);
+3 -2
View File
@@ -601,9 +601,10 @@ impl Game<'_> {
self.dismount(i);
return Ok(());
}
let mut b = self.boats[i].clone();
// The boat steers with the input `LocalPlayer.rideTick` latched on the
// previous tick; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let b = self.tick_boat(b, true);
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
+16 -3
View File
@@ -622,8 +622,15 @@ impl Game<'_> {
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
/// A click on the open page (`MultiLineEditBox.onClick`): a double click
/// selects the word before the cursor and leaves the cursor where it is.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32, double: bool) {
if double {
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.select_word_at_cursor();
}
return;
}
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
@@ -989,6 +996,12 @@ impl Game<'_> {
let b = HudBatch::new(size);
let (w, left) = (b.width, ((b.width - 192.0) / 2.0).floor());
let (mx, my) = self.cursor;
// MouseHandler: a click within 250 ms of the last one on this screen is a double click.
let now = std::time::Instant::now();
let double = self
.book_last_click
.is_some_and(|at| now.duration_since(at).as_millis() < 250);
self.book_last_click = Some(now);
let inside =
|x: f32, y: f32, bw: f32, bh: f32| mx >= x && my >= y && mx < x + bw && my < y + bh;
let click = |game: &mut Self| {
@@ -1029,7 +1042,7 @@ impl Game<'_> {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
self.book_page_click(left + 36.0, 32.0, mx, my, double);
}
}
Some(BookScreen::View {
+4 -1
View File
@@ -170,7 +170,10 @@ impl Game<'_> {
"minecraft:brushable_block",
);
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let loot = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
);
decorated_pot::unpack_loot(
&mut tag,
loot.as_deref(),
+1 -1
View File
@@ -44,7 +44,7 @@ impl Game<'_> {
return false;
}
let stack = self.slot_mut(slot).clone();
let items = self.res.items();
let items = self.items();
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
return false;
};
+1 -1
View File
@@ -32,7 +32,7 @@ impl Game<'_> {
let Some(held) = self.held().cloned() else {
return Ok(None);
};
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
let Some(time) = self.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
return Ok(None);
};
// A client of a server predicts the placing; the server decides.
+54 -2
View File
@@ -77,11 +77,20 @@ impl TimeMarker {
}
}
}
/// `ChatComponent.Draft`: the text the chat box kept, and whether it was a command.
pub struct ChatDraft {
pub text: String,
pub command: bool,
}
/// UI and command state that lives beside the game.
#[derive(Default)]
pub struct UiState {
pub chat: Chat,
pub chat_screen: Option<ChatScreen>,
/// `ChatComponent.latestDraft`.
pub chat_draft: Option<ChatDraft>,
/// `ChatScreen.isDraft`: the box opened with the draft and is unchanged since.
pub chat_draft_shown: bool,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
@@ -466,10 +475,25 @@ impl Game<'_> {
self.ui.registries.players = names;
}
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
/// /), else with its prefix.
pub(super) fn open_chat_method(&mut self, prefix: &str) {
let command = prefix == "/";
let draft = self
.ui
.chat_draft
.as_ref()
.filter(|d| !command || d.command)
.map(|d| d.text.clone());
self.open_chat(draft.as_deref().unwrap_or(prefix));
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
}
pub(super) fn open_chat(&mut self, initial: &str) {
if self.player.is_none() {
return;
}
self.ui.chat_draft_shown = false;
self.refresh_dynamic_registries();
let size = self
.renderer
@@ -506,6 +530,13 @@ impl Game<'_> {
self.wake_up();
return;
}
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.keep_chat_draft(&text);
self.ui.chat_draft_shown = false;
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -513,6 +544,21 @@ impl Game<'_> {
self.capture(true);
}
}
/// `ChatScreen.removed`: the text left in the box becomes the draft when
/// Save Chat Drafts is on, else (and when blank) the draft is discarded. A
/// draft still shown unchanged stays as it is.
fn keep_chat_draft(&mut self, text: &str) {
let unchanged =
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
if !self.settings.file.bool("saveChatDrafts") || text.trim().is_empty() {
self.ui.chat_draft = None;
} else if !unchanged {
self.ui.chat_draft = Some(ChatDraft {
text: text.to_owned(),
command: text.starts_with('/'),
});
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
@@ -648,7 +694,11 @@ impl Game<'_> {
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Close => {
// Escape: the box still holds its text, which close_chat keeps as the draft.
self.ui.chat_screen = Some(screen);
self.close_chat();
}
ChatAction::Submit(message) => {
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
@@ -657,6 +707,8 @@ impl Game<'_> {
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
// The screen is taken here, so close_chat sees no text: a sent
// message discards the draft (DONE).
self.close_chat();
}
if !message.is_empty() {
@@ -983,7 +1035,7 @@ impl Game<'_> {
.hover_item
.as_deref()
.and_then(horizon_client::item_data::stack_from_hover)
.filter(|s| self.res.items().get(&s.item).is_some())
.filter(|s| self.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pos: Pos,
pub(super) pos: Pos,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
@@ -112,7 +112,6 @@ impl CommandBlockScreen {
self.previous.set_value(&shown);
}
}
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
registry.state(state)
}
@@ -241,6 +240,40 @@ impl Game<'_> {
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
/// arrived while the screen is open; refresh the fields from the entity.
/// The mode and conditional live in the block state here (vanilla's
/// `CommandBlockEntity.Mode`).
pub(super) fn update_command_block_screen(&mut self) {
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
return;
};
let state = self.block_state_at(screen_pos).map(|s| {
(
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
conditional(s),
)
});
let block = self.command_block(screen_pos).cloned();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let Some(screen) = self.command_block_screen.as_mut() else {
return;
};
if let Some((mode, conditional)) = state {
screen.mode = mode;
screen.conditional = conditional;
}
if let Some(block) = block {
screen.command.suggest_command(&m, &c, &block.command);
screen.track_output = block.track_output;
screen.auto = block.auto;
screen.show_previous(&block);
}
}
/// `CommandBlock.setPlacedBy`: unless the item carried block entity data
/// (`defaults` false), output tracking follows `send_command_feedback` and
+9 -4
View File
@@ -1053,6 +1053,11 @@ impl Game<'_> {
&horizon_client::brewing::potion_name_key(item, s.potion()),
&[],
)
} else if item == "minecraft:shield"
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
{
// `ShieldItem.getName`: the banner's base colour names the shield.
Text::translate(&format!("item.minecraft.shield.{color}"), &[])
} else {
// Item.getDescriptionId: a block item's is the block's.
let (namespace, path) = item.split_once(':').unwrap_or(("minecraft", item));
@@ -1132,7 +1137,7 @@ impl Game<'_> {
};
let count = Self::int(ctx, "count").unwrap_or(1);
let targets = self.entities(ctx, "targets", true)?;
let max_stack = self.res.items().get(&id).map_or(64, |i| i.max_stack) as i32;
let max_stack = self.items().get(&id).map_or(64, |i| i.max_stack) as i32;
if count > max_stack * 100 {
return Err(CommandError::new(Text::translate(
"commands.give.failed.toomanyitems",
@@ -1941,7 +1946,7 @@ impl Game<'_> {
.items()
.block_tags
.get(tag)
.or_else(|| self.res.items().block_tags.get(&b.id))
.or_else(|| self.items().block_tags.get(&b.id))
.is_some_and(|set| {
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
})
@@ -2132,7 +2137,7 @@ impl Game<'_> {
let table = save::text(tag.get("LootTable"));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources);
let loot = structure_blocks::loot_data(&self.options.resources, &self.pack_files());
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
let biome = self.biome_name_at(at).unwrap_or_default();
Some(structure_blocks::roll_container(
@@ -2730,7 +2735,7 @@ impl Game<'_> {
.items()
.item_tags
.get(tag)
.or_else(|| self.res.items().item_tags.get(&predicate.id))
.or_else(|| self.items().item_tags.get(&predicate.id))
.is_some_and(|s| {
s.contains(item) || s.contains(item.trim_start_matches("minecraft:"))
});
@@ -182,7 +182,7 @@ impl Game<'_> {
return Ok(0);
};
let level = Self::int(ctx, "level").unwrap_or(1);
let enchantments = &self.res.stations().enchantments;
let enchantments = &self.stations().enchantments;
let Some(def) = enchantments.get(&id) else {
return Err(err(
"argument.resource.not_found",
@@ -208,8 +208,8 @@ impl Game<'_> {
}
continue;
};
let e = &self.res.stations().enchantments;
let compatible = e.can_enchant(self.res.items(), &id, &stack.item)
let e = &self.stations().enchantments;
let compatible = e.can_enchant(self.items(), &id, &stack.item)
&& stack
.crafting_enchantments()
.iter()
@@ -1048,6 +1048,23 @@ impl Game<'_> {
let Some(holder) = self.dialog_holder(dialog) else {
return;
};
self.send_dialog(targets, &holder);
}
/// `ServerPlayer.openDialog` for the dialog of a `show_dialog` click
/// event (a sign's): a registry id or the dialog itself, opened for the
/// player the game acts for.
pub(in crate::game) fn show_click_dialog(&mut self, dialog: &horizon_world::nbt::Tag) {
let holder = match dialog {
horizon_world::nbt::Tag::String(id) => self.dialog_holder(&id.to_string_lossy()),
direct => Some(DialogHolder::Direct(direct.clone())),
};
if let Some(holder) = holder {
self.send_dialog(&[Target::Player], &holder);
}
}
/// `ServerPlayer.openDialog` for each target: the packet to a guest, the
/// screen to the window's own player.
fn send_dialog(&mut self, targets: &[Target], holder: &DialogHolder) {
let packet = C::ShowDialog(play::ShowDialog {
dialog: holder.clone(),
});
@@ -1056,7 +1073,7 @@ impl Game<'_> {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.show_play_dialog(&holder),
Target::Player => self.show_play_dialog(holder),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
@@ -600,7 +600,7 @@ impl Game<'_> {
if let Some(Tag::Int(n)) = s.data().and_then(|d| d.other("minecraft:max_stack_size")) {
return (*n).clamp(1, 99) as u32;
}
self.res.items().get(&s.item).map_or(64, |i| i.max_stack)
self.items().get(&s.item).map_or(64, |i| i.max_stack)
}
/// `ResourceOrIdArgument.getItemModifier`: an inline modifier (SNBT) or
/// a registered one.
@@ -491,7 +491,7 @@ impl Game<'_> {
.inline_loot_table(&json, &mut self.world.random);
(drops, None)
} else {
if !self.res.items().has_loot_table(&table) {
if !self.items().has_loot_table(&table) {
return Err(err("argument.resource_or_id.failed_to_parse", &[&table]));
}
let held = if id == "loot.fish" {
@@ -499,14 +499,14 @@ impl Game<'_> {
} else {
None
};
let drops = match self.res.items().loot_sequence(&table) {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
self.res
.items()
.loot_table(&table, false, held.as_ref(), random)
}
None => self.res.items().loot_table(
None => world_items!(self).loot_table(
&table,
false,
held.as_ref(),
@@ -523,7 +523,7 @@ impl Game<'_> {
};
let ty = self.target_type(t);
let table = format!("minecraft:entities/{}", ty.trim_start_matches("minecraft:"));
if t == Target::Player || !self.res.items().has_loot_table(&table) {
if t == Target::Player || !world_items!(self).has_loot_table(&table) {
return Err(err(
"commands.drop.no_loot_table.entity",
&[&self.target_name(t).plain()],
@@ -534,14 +534,14 @@ impl Game<'_> {
None | Some(Some(Target::Player))
);
let held = self.slot_get(&super::items::Slot::Inv(self.selected));
let drops = match self.res.items().loot_sequence(&table) {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
self.res
.items()
.entity_drops(&ty, by_player, held.as_ref(), random)
}
None => self.res.items().entity_drops(
None => world_items!(self).entity_drops(
&ty,
by_player,
held.as_ref(),
@@ -556,21 +556,21 @@ impl Game<'_> {
let state = self.world.block(p[0], p[1], p[2]);
let name = self.data_block_name(p);
let table = format!("minecraft:blocks/{}", name.trim_start_matches("minecraft:"));
if !self.res.items().has_loot_table(&table) {
if !world_items!(self).has_loot_table(&table) {
return Err(err("commands.drop.no_loot_table.block", &[&name]));
}
let tool = self.tool_stack(ctx)?;
let drops = match self.res.items().loot_sequence(&table) {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
self.res.items().block_drops(
world_items!(self).block_drops(
self.pack.assets.registry,
state,
tool.as_ref(),
random,
)
}
None => self.res.items().block_drops(
None => world_items!(self).block_drops(
self.pack.assets.registry,
state,
tool.as_ref(),
@@ -637,7 +637,7 @@ impl Game<'_> {
&& cur.damage == left.damage
&& cur.extra == left.extra =>
{
let max = self.res.items().get(&cur.item).map_or(64, |i| i.max_stack);
let max = self.items().get(&cur.item).map_or(64, |i| i.max_stack);
let take = left.count.min(max.saturating_sub(cur.count));
if take > 0 {
cur.count += take;
@@ -32,7 +32,7 @@ impl Game<'_> {
/// Every recipe id of the recipe manager (what `/recipe` resolves and suggests).
pub(super) fn learnable_recipes(&self) -> Vec<String> {
self.res.stations().recipe_index.ids().cloned().collect()
self.stations().recipe_index.ids().cloned().collect()
}
pub(super) fn cmd_recipe(&mut self, id: &str, ctx: &Context) -> R<i32> {
let targets = self.entities(ctx, "targets", true)?;
@@ -82,7 +82,7 @@ impl Game<'_> {
/// many recipe book displays changed (a transmute recipe has one per
/// material count).
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
let index = &self.res.stations().recipe_index;
let index = &world_stations!(self).recipe_index;
if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
self.recipe_unlocked(&unlocked);
@@ -113,7 +113,8 @@ impl Game<'_> {
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"in_fire" | "hot_floor" => DamageKind::InFire,
"campfire" => DamageKind::Campfire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
@@ -170,23 +171,25 @@ impl Game<'_> {
let hurt = match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player => {
Target::Player | Target::Guest(_) => {
// `DamageSource(type, cause, by)`: the death message names the
// causing entity, else the direct one. A mob is noted by
// `hurt_player_from`, a player here.
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
let name = self.target_name(t);
self.note_direct_entity(&ty, name);
}
if self.spectator() {
false
} else {
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
let named = match cause.or(by) {
Some(t @ (Target::Player | Target::Guest(_))) => Some(self.target_name(t)),
_ => None,
};
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.for_player(target, |g| {
if let Some(name) = named {
g.note_direct_entity(&ty, name);
}
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
})
.unwrap_or(false)
}
Target::Mob(id) => {
let attacker = cause.or(by).and_then(|t| match t {
+12 -1
View File
@@ -22,7 +22,7 @@ impl Game<'_> {
}
/// The item's `compostable` layers provider.
fn compostable(&self, stack: &ItemStack) -> Option<serde_json::Value> {
self.res.items().get(&stack.item)?.compostable.clone()
self.items().get(&stack.item)?.compostable.clone()
}
/// `ComposterBlock.addLayer` and level event 1500: the provider is
/// evaluated in the composter's block-interaction loot context (its
@@ -66,6 +66,17 @@ impl Game<'_> {
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `ComposterBlock.useItemOn` on a client's level: whether `held` is
/// compostable and the composter at `pos` below level 8 (SUCCESS).
pub(super) fn composter_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
self.composter_level(pos).is_some_and(|level| level < 8)
&& held.is_some_and(|stack| {
self.res
.items()
.get(&stack.item)
.is_some_and(|i| i.compostable.is_some())
})
}
/// `HopperBlockEntity.addItem` into the composter's `InputContainer`
/// (levels 0 to 6; one item, compostable, from above): the remainder.
pub(super) fn composter_container_insert(
+13 -2
View File
@@ -227,6 +227,8 @@ impl Game<'_> {
self.dismount(i);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.dismount_cart(i);
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id).cloned() {
self.get_up_from_cushion(&c);
} else {
self.dismount_mount();
}
@@ -255,10 +257,19 @@ impl EmissionWorld for Emitter<'_, '_> {
fn consume_sound(&mut self, _: ItemStack) -> Result<String, Infallible> {
unreachable!("players play the consumable's own sound")
}
/// `Entity.playSound`: at the player's position.
/// `Player.playSound`: at the player's position, for everyone but the
/// player, whose own client plays it (`level.playSound(player, ...)`):
/// a guest's server turn leaves the guest out.
fn play_sound(&mut self, sound: String, volume: f32, pitch: f32) -> Result<(), Infallible> {
let at = self.0.feet();
self.0.audio.play(&sound, at, volume, pitch);
if self.0.lan_acting.is_some() {
self.0.audio.play_unshared(&sound, at, volume, pitch);
if let Some(at) = at {
self.0.guest_player_sounds.push((sound, at, volume, pitch));
}
} else {
self.0.audio.play(&sound, at, volume, pitch);
}
Ok(())
}
}
+4 -2
View File
@@ -448,8 +448,10 @@ impl Game<'_> {
ok()
}
"turn" => {
self.yaw += arg(1)?.parse::<f32>()?;
self.pitch = (self.pitch + arg(2)?.parse::<f32>()?).clamp(-90.0, 90.0);
let yaw = arg(1)?.parse::<f32>()?;
let pitch = arg(2)?.parse::<f32>()?;
// Degrees, as `Entity.turn` degrees.
self.turn_player(f64::from(yaw / 0.15), f64::from(pitch / 0.15));
ok()
}
"tp" => {
@@ -1,7 +1,8 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
use horizon_client::{hotbar, items::ItemStack, keybinds, text::Text};
use horizon_world::nbt::Tag;
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
@@ -10,13 +11,65 @@ impl Game<'_> {
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
/// row's nine stacks, or for an empty row a locked paper in that row's own
/// slot, named after the keys that save it (`inventory.hotbarInfo`).
pub(super) fn refresh_saved_hotbars(&mut self) {
if !self.creative_screen.tab().hotbar {
return;
}
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let rows = hotbar::load(&path, &known);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
let empty = || ItemStack::new("minecraft:air", 0);
let mut items = Vec::with_capacity(hotbar::GROUPS * hotbar::SIZE);
for (i, row) in rows.iter().enumerate().take(hotbar::GROUPS) {
if row.iter().any(Option::is_some) {
items.extend(row.iter().map(|s| s.clone().unwrap_or_else(empty)));
continue;
}
for j in 0..hotbar::SIZE {
if j != i {
items.push(empty());
continue;
}
let with = [
key("key.saveToolbarActivator"),
key(&format!("key.hotbar.{}", i + 1)),
];
let mut paper = ItemStack::new("minecraft:paper", 1);
let data = paper.data_mut();
data.set_other(
"minecraft:creative_slot_lock",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
data.set_other(
"minecraft:item_name",
Some(Tag::compound([
("translate", Tag::String("inventory.hotbarInfo".into())),
(
"with",
Tag::List(
with.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
])),
);
items.push(paper);
}
}
self.creative_screen.set_saved_hotbars(items);
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let known = |id: &str| self.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
+306 -37
View File
@@ -127,6 +127,27 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
.full_collision
}
}
/// `AbstractWindCharge` is pickable, so a thrown pearl's
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
projectiles
.iter()
.enumerate()
.filter(|(j, q)| {
*j != skip
&& !q.stuck
&& matches!(
q.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
)
})
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
.collect()
}
/// `FlameFeatureRenderer`: fire quads stacked up a burning entity's box,
/// turned to the camera around Y, at full block light. `at` is the entity's
/// feet relative to the camera.
@@ -257,6 +278,7 @@ impl Game<'_> {
snowy: has("minecraft:spawns_snow_foxes")
|| b.name.contains("snow")
|| b.name.contains("frozen"),
snow_fox: has("minecraft:spawns_snow_foxes"),
swamp: b.name.contains("swamp"),
ocean: has("minecraft:is_ocean"),
mushroom: b.name == "minecraft:mushroom_fields",
@@ -468,7 +490,7 @@ impl Game<'_> {
raining: weather && rain > 0.2,
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.res.items().block_tags,
block_tags: &self.items().block_tags,
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
@@ -477,7 +499,7 @@ impl Game<'_> {
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
@@ -514,9 +536,9 @@ impl Game<'_> {
};
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
/// Generation's animals for the chunks generated since, once lit: chunk
/// generation places them whether the level ticks or not.
pub(super) fn spawn_fresh_chunks(&mut self) {
let fresh: Vec<(i32, i32)> = self
.world
.chunks
@@ -530,6 +552,10 @@ impl Game<'_> {
}
self.spawn_generation_creatures(key.0, key.1);
}
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
self.tick_leads();
self.spawn_natural();
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
@@ -940,8 +966,10 @@ impl Game<'_> {
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
use horizon_client::mobs::{DamageKind, Who, drops};
use horizon_world::loot::{LootDamage, LootEntity};
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
else {
let Some(loot) = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
) else {
return;
};
let player = || {
@@ -1101,7 +1129,10 @@ impl Game<'_> {
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
use horizon_client::mobs::drops;
use horizon_world::loot::EntityLootParams;
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let loot = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
);
let this = self
.mobs
.iter()
@@ -1382,7 +1413,9 @@ impl Game<'_> {
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
}
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
MobEvent::Tamed { .. } | MobEvent::Buck { .. } | MobEvent::StabDismount { .. } => {
self.mount_event(&e)
}
MobEvent::Launch { target, up } => match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
@@ -1608,7 +1641,7 @@ impl Game<'_> {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
let drops = world_items!(self).block_drops_in(
self.pack.assets.registry,
old,
None,
@@ -1759,6 +1792,16 @@ impl Game<'_> {
horizon_client::mobs::animal_brain::set_breed_partner(m, partner);
}
}
MobEvent::Retreat { mob, from, at } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
horizon_client::mobs::piglin::receive_retreat(m, from, at);
}
}
MobEvent::StartRiding { rider, vehicle } => {
horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle);
}
// Applied after the fish's tick (`apply_brain_events`).
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
MobEvent::LeashBreak { mob, drop, sound } => self.break_leash(mob, drop, sound),
MobEvent::Vibration { from, to, ticks } => {
self.mob_vibration_particle(from, to, ticks)
@@ -1912,6 +1955,64 @@ impl Game<'_> {
self.spawn_orbs(at, amount);
}
}
/// `DyeColor.getMixedColor`: the colour of the crafting recipe that mixes
/// the two dyes, else one of the two picked at random.
fn dye_mix(&mut self, a: u8, b: u8) -> u8 {
if let Some(mixed) = self.dye_color_mix_in_recipes(a, b) {
return mixed;
}
if self.world.random.next_boolean() {
a
} else {
b
}
}
/// `DyeColor.findColorMixInRecipes`: the dye colour a two-dye crafting
/// recipe makes, when its result is a dye.
fn dye_color_mix_in_recipes(&self, a: u8, b: u8) -> Option<u8> {
let dye = |c: u8| {
let name = horizon_client::mobs::render::DYE_NAMES[usize::from(c) & 15];
Some(ItemStack::new(&format!("minecraft:{name}_dye"), 1))
};
let grid = [dye(a), dye(b), None, None];
let (_, result) = self.res.recipes().find(&grid, 2)?;
let name = result
.item
.strip_prefix("minecraft:")?
.strip_suffix("_dye")?;
let index = horizon_client::mobs::render::DYE_NAMES
.iter()
.position(|n| *n == name)?;
u8::try_from(index).ok()
}
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
/// across and 10 up and down get `NeutralMob.playerDied`, when the
/// `forgive_dead_players` rule is on. A mob's `Who::Player` is the guest
/// it is bound to in `bindings` (game/lan_mobs.rs), else the host's
/// player, so only the mobs whose binding is `conn` (`None` for the
/// host's player) hear it.
pub(super) fn tell_neutral_mobs_player_died(
&mut self,
feet: [f64; 3],
conn: Option<u32>,
bindings: &std::collections::HashMap<u32, u32>,
) {
// A client of someone else's world has the server's mobs only to show.
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
return;
}
let block = feet.map(f64::floor);
let area = Aabb::new(
Vec3::new(block[0], block[1], block[2]),
Vec3::new(block[0] + 1.0, block[1] + 1.0, block[2] + 1.0),
)
.inflate(Vec3::new(32.0, 10.0, 32.0));
for m in self.mobs.iter_mut() {
if bindings.get(&m.id).copied() == conn && m.bounds().intersects(area) {
horizon_client::mobs::ai::forgive_dead_player(m);
}
}
}
/// Animal.spawnChildFromBreeding
fn breed(
&mut self,
@@ -1983,7 +2084,24 @@ impl Game<'_> {
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
// else either parent's variant. The draws come from the mother's random.
let axolotl = if p.species() == Species::Axolotl {
let other = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
if m.random.next_int_bound(1200).unwrap_or(1) == 0 {
// Axolotl.Variant.getRareSpawnVariant: the one blue, still a draw.
let _ = m.random.next_int_bound(1);
4
} else if m.random.next_boolean() {
p.extra.variant
} else {
other
}
})
} else {
None
};
// A horse and a donkey have a mule.
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
@@ -1998,6 +2116,24 @@ impl Game<'_> {
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
// baby fox's mouth.
if p.species() == Species::Fox {
child.extra.main_hand = None;
}
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
// screams if the parent a coin toss picks does, or on a 2% roll.
if p.species() == Species::Goat {
child.extra.lost_horns = [false; 2];
horizon_client::mobs::finalize::goat_init_memories(child, &mut self.world.random);
let pick_parent = self.world.random.next_boolean();
let parent_screams = if pick_parent {
p.extra.screaming
} else {
q.as_ref().unwrap_or(&p).extra.screaming
};
child.extra.screaming = parent_screams || self.world.random.next_double() < 0.02;
}
// A llama's baby was drawn in the parent's tick, with the
// experience.
if let Some(o) = llama {
@@ -2046,11 +2182,12 @@ impl Game<'_> {
if p.extra.tame {
child.extra.tame = true;
child.extra.owner = p.extra.owner;
child.extra.color = if coin2 {
p.extra.color
} else {
other.extra.color
};
// The mix needs all of `self`, so it waits until `child`'s
// borrow of the mob list is over.
let mixed = self.dye_mix(p.extra.color, other.extra.color);
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
c.extra.color = mixed;
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
@@ -2058,7 +2195,7 @@ impl Game<'_> {
}
if p.species() == Species::Wolf {
// Wolf.getBreedOffspring: a parent's variant; tame parents
// give a tame baby with one of their collar colours.
// give a tame baby with their collar colours mixed.
let other = q.as_ref().unwrap_or(&p);
child.extra.variant = if coin {
p.extra.variant
@@ -2068,11 +2205,12 @@ impl Game<'_> {
if p.extra.tame {
horizon_client::mobs::tame::set_tame(child, true);
child.extra.owner = p.extra.owner;
child.extra.color = if coin2 {
p.extra.color
} else {
other.extra.color
};
// The mix needs all of `self`, so it waits until `child`'s
// borrow of the mob list is over.
let mixed = self.dye_mix(p.extra.color, other.extra.color);
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
c.extra.color = mixed;
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
@@ -2085,6 +2223,14 @@ impl Game<'_> {
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Axolotl.getBreedOffspring: the drawn variant, and the baby is persistent.
if let Some(variant) = axolotl {
child.extra.variant = variant;
child.persistent = true;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
if p.species() == Species::Nautilus && p.extra.tame {
child.extra.tame = true;
@@ -2101,8 +2247,13 @@ impl Game<'_> {
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
// Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes.
if p.species() == Species::Sheep {
child.extra.color = p.extra.color;
let other = q.as_ref().unwrap_or(&p);
let mixed = self.dye_mix(p.extra.color, other.extra.color);
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
c.extra.color = mixed;
}
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
@@ -2491,13 +2642,21 @@ impl Game<'_> {
.map(|g| g.conn)
.collect()
});
projectiles.retain_mut(|p| {
let mut keep_flags = vec![true; projectiles.len()];
for i in 0..projectiles.len() {
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
wind_charge_hitboxes(&projectiles, i)
} else {
Vec::new()
};
let p = &mut projectiles[i];
if vanish
&& matches!(p.kind, ProjectileKind::EnderPearl)
&& p.owner == Some(Who::Player)
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
{
return false;
keep_flags[i] = false;
continue;
}
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
@@ -2512,11 +2671,25 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = shooter;
}
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp) {
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
(false, false) => p.tick(&pw),
(false, true) => p.tick(&pw_guests),
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
(true, false, _) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true, _) => {
horizon_client::arrows::tick(p, &scene, &arrow_targets_guests)
}
(false, false, true) => p.tick(&pw),
(false, true, true) => p.tick(&pw_guests),
(false, pvp_shot, false) => {
let mut targets = if pvp_shot {
pw_guests.targets.clone()
} else {
pw.targets.clone()
};
targets.extend(winds);
p.tick(&ProjWorld {
w: if pvp_shot { pw_guests.w } else { pw.w },
targets,
})
}
};
if shooter.is_some() {
p.owner = Some(Who::Player);
@@ -2562,7 +2735,7 @@ impl Game<'_> {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
deflections.push([at.x, at.y, at.z]);
return true;
continue;
}
// AbstractWindCharge.tick: out of the world's top it bursts.
if wind && keep && p.position.y > 350.0 {
@@ -2574,12 +2747,19 @@ impl Game<'_> {
face: [0; 3],
},
));
return false;
keep_flags[i] = false;
continue;
}
if hit != Hit::None {
hits.push((p.clone(), hit));
}
keep
keep_flags[i] = keep;
}
let mut idx = 0;
projectiles.retain(|_| {
let k = keep_flags[idx];
idx += 1;
k
});
}
self.smoke_trails(trails);
@@ -2656,10 +2836,10 @@ impl Game<'_> {
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
damage_type: Some("minecraft:trident"),
items: Some(self.res.items()),
items: Some(world_items!(self)),
..Default::default()
};
self.res.stations().enchantments.modify_value(
world_stations!(self).enchantments.modify_value(
Some(&trident),
"minecraft:damage",
&ctx,
@@ -3543,6 +3723,69 @@ impl Game<'_> {
self.particles.add(&options, at, v);
}
}
/// `AgeableMob.mobInteract`: whether a click with `item` on `m` gets to
/// the golden dandelion check (`canUseGoldenDandelion`, which a tadpole
/// calls with `isBaby` true). A panda that is scared or on its back and an
/// armadillo in its shell answer in their own `mobInteract` first.
pub(super) fn age_lock_takes(&self, m: &Mob, item: &str) -> bool {
let species = m.species();
let baby = if species == Species::Tadpole {
true
} else if horizon_client::mobs::finalize::ageable(species) {
m.is_baby()
&& !(species == Species::Panda
&& (horizon_client::mobs::panda::scared(m)
|| horizon_client::mobs::panda::is_on_back(m)))
&& !(species == Species::Armadillo && horizon_client::mobs::armadillo::scared(m))
} else {
false
};
let cannot = self
.res
.mob_data()
.entity_has("minecraft:cannot_be_age_locked", &m.kind.id);
horizon_client::mobs::ai::can_use_golden_dandelion(
item,
baby,
m.extra.age_lock_particle_timer,
cannot,
)
}
/// `AgeableMob.setAgeLocked`: a golden dandelion on mob `index` flips its
/// age lock (a tadpole's or an ageable mob's own `setAgeLockedData`) and is
/// used up; a locked mob stays (`setPersistenceRequired`), and the lock's
/// use or unuse sound plays at its block for everyone near.
pub(super) fn set_age_locked(&mut self, index: usize) {
let m = &mut self.mobs[index];
if m.species() == Species::Tadpole {
horizon_client::mobs::aquatic::set_age_locked_data(m);
} else {
horizon_client::mobs::ai::set_age_locked_data(m);
}
self.consume_held();
let m = &mut self.mobs[index];
let locked = m.extra.age_locked;
if locked {
m.persistent = true;
}
// Level.playSound(null, blockPosition, ..): the middle of the block.
let at = [
m.position.x.floor() + 0.5,
m.position.y.floor() + 0.5,
m.position.z.floor() + 0.5,
];
let chunk = (
m.position.x.floor() as i32 >> 4,
m.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(chunk);
let sound = if locked {
"minecraft:item.golden_dandelion.use"
} else {
"minecraft:item.golden_dandelion.unuse"
};
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
pub(super) fn interact_mob(&mut self) -> Result<bool> {
let Some((index, distance)) = self.mob_hit() else {
@@ -3614,6 +3857,17 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
// AgeableMob.mobInteract: a golden dandelion on a baby freezes its
// growth, or lets it grow again.
if self.mobs[index].alive() && self.age_lock_takes(&self.mobs[index], &item) {
self.set_age_locked(index);
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
let creative = self.creative;
// The hand's slot (Player.interactOn passes the hand).
let selected = self.held_slot();
@@ -3772,7 +4026,7 @@ impl Game<'_> {
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
let stacks = self.res.items().loot_table(
let stacks = world_items!(self).loot_table(
&format!("minecraft:shearing/sheep/{color}"),
false,
None,
@@ -4088,7 +4342,7 @@ impl Game<'_> {
// adult remember it (`gotFish`), to swim to a treasure.
Species::Dolphin if fish => {
eat = Some(("entity.dolphin.eat", 1.0, 1.0));
if m.extra.age < 0 {
if horizon_client::mobs::ai::can_age_up(m) {
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
horizon_client::mobs::ai::age_up(m, secs, true);
} else {
@@ -4137,6 +4391,12 @@ impl Game<'_> {
Some(sound) => {
consume = true;
eat = sound;
// Axolotl.usePlayerItem: a tropical fish bucket is
// emptied into a water bucket in its place.
if species == Species::Axolotl && &*item == "minecraft:tropical_fish_bucket"
{
give = Some("minecraft:water_bucket");
}
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
@@ -4579,6 +4839,13 @@ impl Game<'_> {
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
packed = (15 * 16) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
@@ -4930,10 +5197,12 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::FallingStalactite => "minecraft:falling_stalactite",
K::OnFire => "minecraft:on_fire",
K::InFire => "minecraft:in_fire",
K::Campfire => "minecraft:campfire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::IndirectMagic => "minecraft:indirect_magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
K::SweetBerryBush => "minecraft:sweet_berry_bush",
+57 -43
View File
@@ -130,27 +130,15 @@ impl Game<'_> {
let bounds = aabb(c.bounds()).next_deflated();
blocks_in(bounds).any(|p| {
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
self.res.items().block_in_tag("minecraft:fire", &facts.name)
self.items().block_in_tag("minecraft:fire", &facts.name)
})
}
/// `CushionItem.useOn`: on the top face only, spawning at the click's
/// height above the clicked block (or the replaceable block itself).
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return false;
};
let Some(default_color) = info.cushion_color.as_deref() else {
return false;
};
let color_override = stack
.data()
.and_then(|d| d.other("minecraft:cushion/color"))
.and_then(|tag| match tag {
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
/// `CushionItem.useOn` up to `Cushion.canBePlacedAt`, which the client
/// checks too: on the top face only, at the click's height above the
/// clicked block (or the replaceable block itself). The position and
/// spawn box of a cushion that may go there.
pub(super) fn cushion_spawn(&self, hit: &Hit) -> Option<(Vec3, Aabb)> {
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
// (cauldrons, hoppers and composters) use the collision ray's actual height.
let mut adjusted = Hit {
@@ -159,7 +147,7 @@ impl Game<'_> {
point: hit.point,
};
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.res.items().block_in_tag(
if self.items().block_in_tag(
"minecraft:cushion_uses_collision_shape",
&self.res.facts().get(state).name,
) && let Some(eye) = self.eye_position()
@@ -186,7 +174,7 @@ impl Game<'_> {
}
let hit = &adjusted;
if hit.face != [0, 1, 0] {
return false;
return None;
}
// BlockPlaceContext: a replaceable clicked block is replaced.
let clicked = self
@@ -205,9 +193,30 @@ impl Game<'_> {
[position.x - 0.5, position.y, position.z - 0.5],
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
));
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
horizon_client::cushion::can_place(&PlacementWorld(self), bounds)
.then_some((position, bounds))
}
/// `CushionItem.useOn`: spawn the cushion where `cushion_spawn` allows,
/// unless another cushion is in the way.
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(info) = self.items().get(&stack.item).cloned() else {
return false;
}
};
let Some(default_color) = info.cushion_color.as_deref() else {
return false;
};
let color_override = stack
.data()
.and_then(|d| d.other("minecraft:cushion/color"))
.and_then(|tag| match tag {
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
let Some((position, bounds)) = self.cushion_spawn(hit) else {
return false;
};
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
if self
.cushions
@@ -255,6 +264,8 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// `CushionItem.useOn` returns SUCCESS: the hand swings.
self.swing_arm();
true
}
@@ -280,11 +291,9 @@ impl Game<'_> {
if occupied {
return false;
}
// `Player.startRiding` gives up the vehicle it already has; the
// client never sends that click, so pass instead.
if self.riding.is_some() {
return false;
}
// `Entity.startRiding`: a passenger first gets off what it rides
// (`stopRiding`), so a seated player moves to this cushion.
self.stop_riding();
self.riding = Some(id);
let p = self.cushions[i].position;
self.audio.play(
@@ -394,6 +403,26 @@ impl Game<'_> {
}
}
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
self.audio.play(
"minecraft:entity.cushion.get_up",
Some([c.position.x, c.position.y, c.position.z]),
1.0,
1.0,
);
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
let Some(c) = self
@@ -405,22 +434,7 @@ impl Game<'_> {
return false;
};
if keys.shift {
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
// `Cushion.removePassenger`: the get-up sound for a rider that
// left rather than one whose cushion was killed.
self.audio.play(
"minecraft:entity.cushion.get_up",
Some([c.position.x, c.position.y, c.position.z]),
1.0,
1.0,
);
self.get_up_from_cushion(&c);
return true;
}
let seat = c.seat();
@@ -0,0 +1,187 @@
//! `DebugStickItem`: selecting a block-state property with the left button and
//! cycling its value with the right button, as vanilla does.
//!
//! The selected property is the `minecraft:debug_stick_state` component, a
//! compound of block id to property name. Horizon keeps unmodelled components
//! in `StackData::other`, so the component travels through NBT unchanged.
use super::Game;
use horizon_client::{Result, items::ItemStack, text::Text};
use horizon_core::position::BlockPos;
use horizon_world::level::flags;
use winit::keyboard::KeyCode;
const DEBUG_STICK: &str = "minecraft:debug_stick";
fn is_debug_stick(stack: &ItemStack) -> bool {
&*stack.item == DEBUG_STICK
}
/// `Util.findNextInIterable` / `findPreviousInIterable` on an index.
///
/// A missing current value starts at the first value forward and the last
/// value backward; both wrap around.
fn relative_index(len: usize, current: Option<usize>, backward: bool) -> usize {
match (current, backward) {
(Some(0), true) => len - 1,
(Some(i), true) => i - 1,
(Some(i), false) => (i + 1) % len,
(None, true) => len - 1,
(None, false) => 0,
}
}
fn text(key: &str, args: &[&str]) -> Text {
let args: Vec<Text> = args.iter().map(|arg| Text::literal(*arg)).collect();
Text::translate(key, &args)
}
impl Game<'_> {
/// `DebugStickItem.useOn`: a right click cycles the value of the property
/// in `debug_stick_state` (the first property when none is selected yet).
/// `Some` is the vanilla result (`SUCCESS`/`FAIL`), `None` another item.
pub(super) fn debug_stick_use_on(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
if !self.held().is_some_and(is_debug_stick) {
return Ok(None);
}
Ok(Some(self.debug_stick_interact(pos, true)?))
}
/// `DebugStickItem.canDestroyBlock`: a left click selects the next
/// property and never destroys the block.
pub(super) fn debug_stick_can_destroy(&mut self, pos: [i32; 3]) -> Result<bool> {
if !self.held().is_some_and(is_debug_stick) {
return Ok(true);
}
let _ = self.debug_stick_interact(pos, false)?;
Ok(false)
}
/// `DebugStickItem.handleInteraction`: whether the interaction succeeded.
fn debug_stick_interact(&mut self, pos: [i32; 3], cycle: bool) -> Result<bool> {
// `Player.canUseGameMasterBlocks`: creative with game-master commands.
if !self.can_use_game_master_blocks() {
return Ok(false);
}
let state_id = self.world.block(pos[0], pos[1], pos[2]);
let Some(state) = self.pack.assets.registry.state(state_id).cloned() else {
return Ok(false);
};
if state.properties.is_empty() {
self.debug_stick_message(text(
"item.minecraft.debug_stick.empty",
&[state.name.as_str()],
));
return Ok(false);
}
let selected = self
.held()
.and_then(ItemStack::data)
.and_then(|data| data.debug_stick_property(&state.name));
// `Player.isSecondaryUseActive` / `isShiftKeyDown`: the acting
// player's sneak input. `keys` holds the host's, toggled sneak
// included; `enter_guest` fills it from the guest's sneak state.
let backward = self.keys.contains(&KeyCode::ShiftLeft);
if cycle {
// `cycleState`: a property selected on another block falls back to
// the first one. The property list is sorted by name, as vanilla's
// `ImmutableSortedMap`.
let (property, value) = {
let registry = &self.pack.assets.registry;
let property = selected
.as_deref()
.filter(|property| state.properties.contains_key(*property))
.map(str::to_owned)
.unwrap_or_else(|| state.properties.keys().next().unwrap().clone());
let values = registry.property_values(&state.name, &property);
if values.is_empty() {
return Ok(false);
}
let current = state.properties.get(&property).map(String::as_str);
let current = current.and_then(|value| values.iter().position(|v| *v == value));
let value = values[relative_index(values.len(), current, backward)].to_owned();
(property, value)
};
let mut next = state;
next.properties.insert(property.clone(), value.clone());
let Ok(next_id) = self.pack.assets.registry.resolve(&next) else {
return Ok(false);
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
// `Level.setBlock(pos, newState, 18)`: clients and the known shape,
// without neighbour updates.
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
at,
next_id,
flags::UPDATE_CLIENTS | flags::UPDATE_KNOWN_SHAPE,
);
});
self.process_level_events()?;
self.debug_stick_message(text(
"item.minecraft.debug_stick.update",
&[property.as_str(), value.as_str()],
));
} else {
// `getRelative(properties, property, backward)`; the property list
// is sorted, so its key order is vanilla's.
let keys: Vec<&String> = state.properties.keys().collect();
let current = selected
.as_deref()
.and_then(|selected| keys.iter().position(|key| key.as_str() == selected));
let property = keys[relative_index(keys.len(), current, backward)].clone();
let value = state.properties.get(&property).cloned().unwrap_or_default();
self.set_debug_stick_property(&state.name, &property);
self.debug_stick_message(text(
"item.minecraft.debug_stick.select",
&[property.as_str(), value.as_str()],
));
}
Ok(true)
}
/// `DebugStickState.withProperty(block, property)` on the acting hand.
fn set_debug_stick_property(&mut self, block: &str, property: &str) {
let Some(stack) = self.held_mut().as_mut() else {
return;
};
stack.data_mut().set_debug_stick_property(block, property);
stack.tidy();
}
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
fn debug_stick_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
}
#[cfg(test)]
mod tests {
use super::relative_index;
#[test]
fn relative_index_wraps_like_util() {
assert_eq!(relative_index(4, None, false), 0);
assert_eq!(relative_index(4, None, true), 3);
assert_eq!(relative_index(4, Some(0), false), 1);
assert_eq!(relative_index(4, Some(0), true), 3);
assert_eq!(relative_index(4, Some(2), false), 3);
assert_eq!(relative_index(4, Some(2), true), 1);
assert_eq!(relative_index(4, Some(3), false), 0);
assert_eq!(relative_index(4, Some(3), true), 2);
}
}
@@ -74,7 +74,10 @@ impl Game<'_> {
("z", Tag::Int(pos[2])),
])
});
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let loot = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
);
unpack_loot(
&mut tag,
loot.as_deref(),
+75 -26
View File
@@ -531,7 +531,17 @@ const BROADCAST: &[&str] = &[
impl Game<'_> {
/// DedicatedServer.initServer and MinecraftServer.runServer.
// The epoch starts at world open; failures before that send no lifecycle event.
pub(super) fn run_dedicated(&mut self) -> Result<()> {
let result = self.run_dedicated_inner();
if let Err(error) = &result {
self.native_lifecycle_world_failed(&error.to_string());
self.native_lifecycle_close_failed(&error.to_string());
}
result
}
fn run_dedicated_inner(&mut self) -> Result<()> {
// This thread ticks the server (MinecraftServer.spin's priority).
horizon_threads::favour_current();
self.join_world_rest()?;
@@ -609,13 +619,9 @@ impl Game<'_> {
// MinecraftServer.initializeKeyPair (only online mode uses it).
log("INFO", "Generating keypair");
let online = if props.online_mode {
let key = horizon_protocol::rsa::RsaPrivateKey::generate(1024)
.map_err(|e| format!("Failed to generate key pair: {e}"))?;
Some(Arc::new(OnlineMode {
public_der: key.public_der(),
key,
prevent_proxy: props.prevent_proxy_connections,
}))
Some(Arc::new(OnlineMode::generate(
props.prevent_proxy_connections,
)?))
} else {
None
};
@@ -651,6 +657,7 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
self.native_lifecycle_open_started();
self.load_world(WorldRequest::new(
&folder,
Some(&level_name),
@@ -676,7 +683,8 @@ impl Game<'_> {
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
@@ -767,6 +775,8 @@ impl Game<'_> {
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
}
log(
"INFO",
@@ -781,6 +791,8 @@ impl Game<'_> {
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
}
// The dedicated server's open is complete before its first tick.
self.native_lifecycle_world_ready();
let mut state = ServerState {
lists,
console,
@@ -848,10 +860,39 @@ impl Game<'_> {
self.stop_dedicated()
}
/// A dedicated server of its own, not single player's server thread
/// (which runs with `options.dedicated` too).
pub(super) fn standalone_server(&self) -> bool {
self.options.dedicated.is_some() && self.serving.is_none()
}
/// Load the spawn area and find the world spawn as single player does,
/// without keeping a player there.
/// without keeping a player there. A dedicated server only finds the world
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
/// one joins.
pub(super) fn settle_spawn(&mut self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(600);
if self.standalone_server() {
if self.level.spawn.is_none() {
let [x, y, z] = loop {
self.update_world();
if let Some(pos) = self.find_world_spawn()? {
break pos;
}
if Instant::now() > deadline {
return Err("the spawn area did not load".into());
}
std::thread::sleep(Duration::from_millis(2));
};
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
self.level.world_spawn.get_or_insert([x, y, z]);
self.streaming.extra.clear();
}
self.spawn = self
.level
.spawn
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
return Ok(());
}
while self.player.is_none() {
self.update_world();
self.spawn_player()?;
@@ -864,24 +905,28 @@ impl Game<'_> {
Ok(())
}
/// The chunks to keep loaded: the players' (lan_update_streaming), or
/// the spawn chunk while nobody is online.
/// The chunks to keep loaded: the players' (lan_update_streaming). While
/// nobody is online a dedicated server keeps none (vanilla has no spawn
/// ticket), an integrated one the spawn chunk's.
pub(super) fn update_dedicated_streaming(&mut self) {
let center = self
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
})
.or_else(|| {
self.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
});
if let Some(c) = center {
let guest = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
});
if self.standalone_server() {
self.streaming.radius = if guest.is_some() {
self.settings.render_distance
} else {
-1
};
}
let spawn = self
.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
if let Some(c) = guest.or(spawn) {
self.streaming.center = c;
}
}
@@ -1452,6 +1497,8 @@ impl Game<'_> {
}
fn stop_dedicated(&mut self) -> Result<()> {
// The close request starts before player and world saves.
self.native_lifecycle.begin_close();
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
@@ -1479,6 +1526,8 @@ impl Game<'_> {
self.free_player_beds()?;
self.save_game()?;
self.log_saved_levels();
// Only a successful final save closes the epoch; storage is still held.
self.native_lifecycle_world_closed();
self.lan_close(false);
Ok(())
}
+31 -2
View File
@@ -11,7 +11,7 @@ use horizon_client::{
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
text::{ClickEvent, Text},
ui,
};
use horizon_world::nbt::Tag;
@@ -544,6 +544,7 @@ impl Game<'_> {
};
let [_, ay, _, ah] = layout.area;
let mut action = None;
let mut text_click: Option<ClickEvent> = None;
open.focus = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
@@ -592,11 +593,39 @@ impl Game<'_> {
} if ui::hovered(cursor, x, y, w, h) => {
action = Some(Some(index));
}
// AbstractStringWidget.setComponentClickHandler: the click event
// of the styled text under a centred message line.
Part::Line(line, x, y)
if text_click.is_none()
&& cursor.0 >= x
&& (0.0..9.0).contains(&(cursor.1 - y)) =>
{
let content = line.resolve_legacy();
let mut left = cursor.0 - x;
for (c, style) in content.chars() {
left -= self.gui.atlas.font.advance(c, style);
if left < 0.0 {
text_click = style.click.clone();
break;
}
}
}
_ => {}
}
}
let Some(action) = action else {
return Ok(());
// A text click runs its event (DialogScreen.runAction) with no button sound.
let click = match text_click {
Some(ClickEvent::OpenUrl(url)) => Click::OpenUrl(url),
Some(ClickEvent::ChangePage(page)) => Click::ChangePage(page),
Some(ClickEvent::SuggestCommand(command)) => Click::SuggestCommand(command),
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
};
let after = open.dialog.after_action;
return self.run_dialog_action(Some(click), after);
};
self.audio.play_button_click();
let Some(index) = action else {
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff. Show more