Compare commits

...
Author SHA1 Message Date
Ludovic Marie 535132df77 union: #2603 2026-10-27 06:34:35 +01:00
Ludovic Marie 3a379a3a25 union fixup: crystals and the dragon have score holder names 2026-10-27 06:31:51 +01:00
Ludovic Marie 295b42c813 union fixups: glint through the headless renderer, world presets in the superflat screen and probes, public gateway_exit 2026-10-27 06:30:53 +01:00
Ludovic Marie 3132f00b92 union: #2606 2026-10-27 06:28:32 +01:00
Ludovic MarieandClaude Opus 5.5 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +01:00
Ludovic Marie c75c2d6c98 union: #2638 2026-10-27 06:28:08 +01:00
Ludovic Marie 00ac19c727 union: #2634 2026-10-27 06:28:08 +01:00
Ludovic Marie 4a8abbc03b union: #2619 2026-10-27 06:28:08 +01:00
Ludovic Marie bf0b572cd6 union: #2615 2026-10-27 06:28:08 +01:00
Ludovic Marie e76557a9b4 union: #2613
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:28:08 +01:00
Ludovic Marie 7de7148ca3 union: #2610 2026-10-27 06:27:54 +01:00
Ludovic Marie 3c3f79c399 union: #2608 2026-10-27 06:27:54 +01:00
Ludovic Marie 02d7b51fbe union: #2607 2026-10-27 06:27:54 +01:00
Ludovic Marie f25904a14c union: #2606 2026-10-27 06:27:54 +01:00
Ludovic Marie 7ce0ceedbe union: #2605 2026-10-27 06:27:54 +01:00
Ludovic Marie 7e2388a7ba union: #2603 2026-10-27 06:27:54 +01:00
Ludovic Marie 580238a97b union: #2592
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-27 06:27:53 +01:00
Ludovic Marie a0b56f9f7f union: #2586 2026-10-27 06:27:40 +01:00
Ludovic Marie ae86b4c1d1 union: #2585
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-10-27 06:27:39 +01:00
Ludovic Marie 44bf73f545 union: #2583
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
2026-10-27 06:27:27 +01:00
Ludovic Marie 15e250796d union: #2581
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 06:27:15 +01:00
Ludovic Marie 4281a04419 union: #2579 2026-10-27 06:26:41 +01:00
Ludovic Marie 359c3ed4b0 union: #2576 2026-10-27 06:26:41 +01:00
Ludovic Marie 6291dce546 union: #2575
# Conflicts:
#	tools/lan_e2e.py
2026-10-27 06:26:40 +01:00
Ludovic Marie 07922f7d61 union: #2574 2026-10-27 06:26:31 +01:00
Ludovic Marie d5632dff8f union: #2573 2026-10-27 06:26:31 +01:00
Ludovic Marie bf24926abc union: #2570
# Conflicts:
#	crates/horizon-client/src/title/create_world.rs
2026-10-27 06:26:30 +01:00
Ludovic Marie d788f6c879 union: #2565 2026-10-27 06:25:55 +01:00
Ludovic Marie e031729321 union: #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:25:54 +01:00
Ludovic Marie 0c7291a1ef union: #2563 2026-10-27 06:25:37 +01:00
Ludovic Marie 3c2110f5f7 union: #2562
# Conflicts:
#	crates/horizon-client/src/menu.rs
2026-10-27 06:25:36 +01:00
Ludovic Marie 365de3179b union: #2561 2026-10-27 06:25:23 +01:00
Ludovic Marie ac0f6ecf7c union: #2559 2026-10-27 06:25:23 +01:00
Ludovic Marie 06c5b0793c union: #2550 2026-10-27 06:25:23 +01:00
Ludovic Marie dc13bdc1b7 union: #2544
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-10-27 06:25:23 +01:00
Ludovic Marie 7aa9bfd8f8 union: #2543 2026-10-27 06:24:56 +01:00
Ludovic Marie 0269f59a5c union: #2541 2026-10-27 06:24:51 +01:00
Ludovic Marie 583e6a08b9 union: #2536 2026-10-27 06:24:51 +01:00
Ludovic Marie 9d51f087e6 union: #2506
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-27 06:24:51 +01:00
Ludovic Marie cc6abf23d9 union: #2493
# Conflicts:
#	crates/horizon-client/src/structure_blocks.rs
2026-10-27 06:24:38 +01:00
Ludovic Marie a338cb6a8b union: #2482 2026-10-27 06:24:16 +01:00
Ludovic Marie 816fded3f9 union: #2414 2026-10-27 06:24:16 +01:00
Ludovic Marie c029c1d75b union: #2413 2026-10-27 06:24:16 +01:00
Ludovic Marie 19007dd7c5 union: #2320 2026-10-27 06:24:16 +01:00
Ludovic Marie f079421319 union: #2317 2026-10-27 06:24:16 +01:00
Ludovic Marie 08aae6ff95 union: #2315 2026-10-27 06:24:16 +01:00
Ludovic Marie f78c62dc79 union: #2313 2026-10-27 06:24:16 +01:00
Ludovic Marie f73b366237 union: #2312 2026-10-27 06:24:16 +01:00
Ludovic Marie fa7f79836f union: #2308 2026-10-27 06:24:16 +01:00
Ludovic Marie 01421a2ebe union: #2307
# Conflicts:
#	crates/horizon-client/src/game/packs.rs
2026-10-27 06:24:15 +01:00
Ludovic Marie 43de279bc7 union: #2304 2026-10-27 06:23:42 +01:00
Ludovic Marie e891eb498b union: #2302 2026-10-27 06:23:42 +01:00
Ludovic Marie 5a83d2a954 union: #2298 2026-10-27 06:23:42 +01:00
Ludovic Marie 308c4ee5b7 union: #2297 2026-10-27 06:23:42 +01:00
Ludovic Marie 82c9fa195c union: #2296 2026-10-27 06:23:42 +01:00
Ludovic Marie 7a44918872 union: #2292 2026-10-27 06:23:42 +01:00
Ludovic Marie 7018d4d830 union: #2272 2026-10-27 06:23:42 +01:00
Ludovic Marie c8cb00eb5f union: #2214 2026-10-27 06:23:42 +01:00
Ludovic Marie 4a65763916 union: #2130 2026-10-27 06:23:42 +01:00
Ludovic MarieandClaude Opus 5.5 a1d3693ff5 Light region: check every neighbour before building a stand-in column
A light job whose 3x3 still misses a neighbour retries on a later
update; it no longer converts an edge neighbour's proto into a column
on the main thread first. The saved_proto doc comment goes back on its
method.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:22:57 +01:00
Ludovic MarieandClaude Opus 5.5 03d8ffe2c6 Sign editor: send the lines as edited, sign or not
AbstractSignEditScreen.setMessage writes messages[line] and the sign;
removed() sends messages. The editor keeps its lines up to date, so a
sign removed while it is open still gets the player's text (review by
rdbt's agent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:22:28 +01:00
Ludovic MarieandClaude Opus 5.5 6cb5e4954e An occupied horse gives no inventory or feeding either
AbstractHorse.mobInteract (and Horse/ZombieHorse/AbstractChestedHorse)
hand an isVehicle horse to Mob.mobInteract before the inventory, the
feeding and the ride, so the passenger check goes before all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:51 +01:00
Ludovic MarieandClaude Opus 5.5 2ba33bd146 jumpOutOfFluid: only entities a mob collides with block the hop
EntityGetter.getEntityCollisions keeps the entities that canBeCollidedWith
the mob: among mobs that is a living shulker, not every mob nearby.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 4f6e4e72b1 Keep a client's effects until the server removes them
26.3's client LivingEntity.tickEffects only runs tickClient on each effect;
the server's ClientboundRemoveMobEffectPacket removes one. The join reset
stays. The AGENTS.md wording is the maintainer's to change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:20:42 +01:00
Ludovic Marie 91e332a5eb Merge pull request #2621 from ludovic111/feat/launcher-import-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:10:22 +01:00
Ludovic Marie a8fe843daa Merge pull request #2620 from ludovic111/feat/launcher-import
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:01:09 +01:00
levzzz 0ee20da35c Merge remote-tracking branch 'upstream/main' into fix/pot-back-face-culling 2026-10-27 09:49:34 +05:00
Ludovic Marie 37d31da543 Merge pull request #2473 from kacpherek/fix/piglin-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Reviewed and approved; verify passed. The two parity failures (server_player_sculk, remote_fireworks) are the load flakes fixed by #2629, which merged after this run started (03:30Z); both pass on the Mac mini at this head. Thanks @kacpherek!
2026-10-27 05:49:18 +01:00
Ludovic Marie c5165a98cf Merge pull request #2618 from ludovic111/feat/launcher-export-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:48:52 +01:00
levzzzandminimax-m3.1-flash b7749dd0d2 Fix a needless borrow in the decorated pot's test
Clippy's needless_borrow: the registry is already a reference, so the
extra & is dereferenced right away.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:38:03 +05:00
levzzzandminimax-m3.1-flash ddfa71a46f Merge the End crystal and dragon selectors into the cross-level selectors
Both pull requests reshape Game::select in game/commands.rs, so the
cross-level branch now carries the End crystal and dragon candidates as
well: the crystals and the dragon are listed by level_candidates, and
level_holds knows them, so an unbounded selector finds them in the level
that holds them.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:33:56 +05:00
Ludovic Marie e1f7ad93ee Merge pull request #2617 from ludovic111/feat/launcher-export
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:30:25 +01:00
Ludovic Marie 7731b4274a Merge pull request #2616 from ludovic111/feat/launcher-datapack-browser
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:21:08 +01:00
Ludovic Marie 7df621fdb5 Merge pull request #2614 from ludovic111/feat/launcher-mods-per-instance
🤖 Agent: launcher-features-1: base main after #2612 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:19:29 +01:00
masa-masa-0314andZCode 18b544770f Inventory: frame strips by the mcmeta's frame size
`decode_one` cropped an animated block or item strip as `h > w -> w x w`,
ignoring the mcmeta's `animation.width`/`height`: a pack texture that
names its frame dimensions, or lays its frames along a row, packed the
whole image as "frame 0" and was then drawn as a square frame over a
rect sized for the image. The first frame now comes from
`AnimationMetadataSection.frameSize`, resampled to its longer side the
way the world's sprite arrays keep a frame square, and the sprite's GUI
size is that square too.

The atlas also gains the regression test the review asked for: a
synthetic two-frame block strip whose mcmeta starts on the second frame
registers in `sprite_animations`, and the atlas holds that frame at the
sprite's rect; a row strip with explicit frame dimensions packs only its
left frame.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 12:17:54 +08:00
asownerytandClaude Opus 5.5 21fd3a6b06 Turn End portal travellers as their teleport does
Checked against the decompiled 26.3 sources: EndPortalBlock's transition
out of the End is Relative.ROTATION plus the respawn data's yaw and pitch,
so a mob or item turns by the world spawn's angles instead of keeping its
own. Into the End it faces west and keeps its pitch, and calculateAbsolute
clamps the pitch to +-90 either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:13:36 +13:00
Ludovic Marie 3eb3d1fc5f Merge pull request #2612 from ludovic111/feat/launcher-datapacks
🤖 Agent: launcher-features-1: base main after #2609 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:10:09 +01:00
Ludovic Marie 3052cc41db Merge pull request #2629 from ludovic111/fix/window-harnesses-under-load
🤖 Agent: playhorizon: remote.sh runs on CPUs 0-3 (CI keeps 4-7); world_reopen and server_player_sculk count ticks; 6 of 6 under load.
2026-10-27 05:10:05 +01:00
Ludovic Marie 43f5388038 Merge pull request #2464 from kacpherek/fix/damage-command-player-source
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/damage` builds `DamageSource(type, cause, by)` with the location or the direct entity's position for the knockback, and the causing mob gets the kill credit. It touched the same code as #2452, so the merged tree uses your `hurt_player_from` for both. Main merged in at f1f587e7c after #2452 landed (world_cmds.rs and creatures.rs: one `hurt_player_from` path that also records the direct entity, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 05:02:10 +01:00
SenjinandClaude Opus 5.5 620dfc6e0d HDR: let light sources shine the same under the vignette
The lift's threshold read the frame, which the vignette darkens toward
the screen's edges, so a jack o'lantern lit up fully in the middle of
the screen and only at its eyes and mouth further out. Read it from the
world copy instead; the gain still applies to the frame, so the vignette
darkens highlights as before.

Telling the vignetted world from UI also failed on pixels with a channel
at 0 (a pumpkin's stem): each channel's ratio was compared to their
mean, and a 0 channel's ratio is 0. Hold the channels to one factor, the
ratio of their sums, allowing a step of 8-bit rounding.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:01:43 +01:00
Ludovic Marie 08492a3007 Merge pull request #2609 from ludovic111/feat/launcher-update-all
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 04:50:37 +01:00
Ludovic Marie 3d0ef4da75 Merge pull request #2441 from kacpherek/fix/effect-swirls
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickEffects` draws the swirls from `DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID`, only for visible effects, and your follow-up keeps `hideParticles` out of both, which matches. Main merged in at 0ab24d080 after #2432 landed (sprint dust and effect swirls both kept in client_ambient.rs and lan.rs, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:48:54 +01:00
asownerytandClaude Opus 5.5 dfb28e42d0 Send items and mobs through End portals
The non-player portal path only knew Nether portals, so a cow or a dropped
item standing in an end_portal block never travelled (#1515). It now also
finds End portal blocks (EndPortalBlock.entityInside, its 6/16..12/16
entity-inside shape met along the move), and an entity through one waits as
an arrival with a fixed destination (getPortalDestination): the End's spawn
point above a rebuilt obsidian platform, facing west, or out of the End the
top of the Overworld's shared spawn column. Motion and pitch are kept, the
portal cooldown applies, and saved arrivals keep their End plans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:46:07 +13:00
Ludovic MarieandClaude Opus 5.5 a2f62b1545 parity_hosts: playhorizon's workers run on CPUs 0-3, 3 harnesses at a time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:42:19 +01:00
Ludovic MarieandClaude Opus 5.5 cb20bb508b remote.sh: runs get CPUs 0-3 by default; benchmarks hold them with --exclusive
playhorizon's CI runner has CPUs 4-7, and remote.sh pinned agents' runs there
too: CPUs 4-7 sat at 100% while 0-3 idled, and window harnesses in pull
requests' parity jobs lost ticks. Runs share a lock per CPU set; an
--exclusive run (a benchmark) holds it alone. world_reopen also waits for
the world to load before its first count, as after each reopen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:31:40 +01:00
Ludovic Marie 4168590221 Merge remote-tracking branch 'origin/main' into fix/window-harnesses-under-load 2026-10-27 04:30:35 +01:00
Ludovic MarieandClaude Opus 5.5 2a7b79dfb6 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:26:15 +01:00
Ludovic MarieandClaude Opus 5.5 f1f587e7c2 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:24:30 +01:00
Ludovic Marie 51447edda4 Merge pull request #2604 from ludovic111/feat/launcher-test-seams
🤖 Agent: launcher-features-1 checked this: base main after #2602 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:23:13 +01:00
Ludovic Marie 4018fa9559 Merge pull request #2452 from kacpherek/fix/magic-death-message
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `DamageSource.getLocalizedDeathMessage` (the causing entity, else the direct one: the potion or cloud) and `indirectMagic`. I pushed one fix: with no entity at all, vanilla sends the one-argument message and `TranslatableContents.decompose` shows a template it can't fill as written, so I removed the fallback to `magic` / `generic`. Main merged in at e338690bf after #2442 landed (potions.rs: `splash` keeps both the owner and the block): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:23:09 +01:00
Ludovic Marie 95e7cd69dd Merge pull request #2602 from ludovic111/feat/launcher-packs-ui
🤖 Agent: launcher-features-1 checked this: base main after #2601 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:16:39 +01:00
Ludovic Marie 5a6ae76ec3 Merge pull request #2486 from ludovic111/fix/parity-reap-orphans
🤖 Agent: parity.py reaps the harness processes a failed or timed-out run leaves behind; parity passed on playhorizon.
2026-10-27 04:16:36 +01:00
Ludovic Marie 4a523b5ce8 Merge pull request #2484 from kacpherek/fix/2463-break-block-lagback
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: breaking the block under you no longer sends a position correction the client didn't need. Fixes #2463. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:16:33 +01:00
Ludovic Marie eae5b13fc6 Merge pull request #2483 from kacpherek/fix/held-item-outline
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the block outline uses the shape for the held item (light, scaffolding) and the shulker box's lid, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:39 +01:00
Ludovic Marie 1ad1e957a3 Merge pull request #2477 from kacpherek/fix/polar-bear-attack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `PolarBear` (its goals, `PolarBearMeleeAttackGoal`'s rearing and warning sound, `PolarBearHurtByTargetGoal`, `PolarBearAttackPlayersGoal`, the stand animation and `DATA_STANDING_ID`). Left for later: the height doesn't grow while standing, the anger isn't saved, and anger at LAN guests isn't tracked. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:35 +01:00
Ludovic Marie b5f1e65162 Merge pull request #2467 from kacpherek/fix/execute-on-origin-leasher
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `execute on origin` (the thrower or shooter) and `on leasher`; your follow-up records the thrower of a guest's drops and the player behind a lead. Left for later: a fence knot leasher isn't an entity in Horizon, so it finds nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:31 +01:00
Ludovic Marie 39910efabf Merge pull request #2466 from kacpherek/fix/autosave-indicator
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Gui.renderSavingIndicator` (the fade in and out around the autosave), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:28 +01:00
Ludovic Marie b0103bd62f Merge pull request #2465 from kacpherek/fix/baby-walk-position
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the baby leg animation uses the interpolated `walkAnimation` position, not the tick value, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:24 +01:00
Ludovic Marie 44eb7008f1 Merge pull request #2461 from kacpherek/fix/guest-text-commands
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/tellraw`, `/title` and `/playsound` reach a server's players as their packets, and your follow-up plays a remote sound in the packet's category (`handleSoundEvent`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:11 +01:00
Ludovic Marie 179e410923 Merge pull request #2460 from kacpherek/fix/bonus-chest
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `BonusChestFeature.place` (shuffled columns, the chest with the `spawn_bonus_chest` loot table, a torch on each side that survives). I pushed one fix: `setInitialSpawn` passes the level random and the feature sets the blocks with flags 2 (`UPDATE_CLIENTS`). Left for later: it is skipped when the spawn chunk is not lit yet. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:07 +01:00
Ludovic Marie b0a6e63ce7 Merge pull request #2459 from kacpherek/fix/jigsaw-distance-padding-defaults
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `JigsawStructure`'s codec defaults (`max_distance_from_center` 80, the default `dimension_padding`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:04 +01:00
Ludovic Marie 4517d75a65 Merge pull request #2458 from kacpherek/fix/drowned-thrown-trident
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ThrownTrident` (8 damage, `dealtDamage`, the 0.99 water inertia, the hit and ground sounds, `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`) and the drowned's `TridentAttackGoal`. I pushed one fix: the damage goes through `EnchantmentHelper.modifyDamage` of the trident, so Impaling adds only for `#sensitive_to_impaling` (its `minecraft:damage` requirement), not for every mob that swims. Fixes #2447. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:00 +01:00
Ludovic Marie 4b042eae28 Merge pull request #2457 from kacpherek/fix/template-block-entity-nbt
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeInWorld` loads each block's template NBT into its block entity, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:11:57 +01:00
Ludovic Marie 30c6a4ac7b Merge pull request #2456 from kacpherek/fix/particle-count-zero
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ClientPacketListener.handleParticleEvent` with count 0 makes one particle moving at max speed times the delta. I pushed one fix: both are floats in the packet, multiplied as floats before the widening. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:46 +01:00
Ludovic Marie ce73764585 Merge pull request #2455 from kacpherek/fix/horse-tail-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractHorse.aiStep`'s 1-in-200 `moveTail` and its 8-tick `tailCounter`, with the model's tail swing, match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:41 +01:00
Ludovic Marie 59d0b45349 Merge pull request #2454 from kacpherek/fix/score-text-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ScoreContents` resolution in `/tellraw` and `/title`; your follow-up gives armor stands, frames, paintings, boats, carts and others their UUID name, as `getScoreboardName` does. Left for later: the `*` holder resolves to the acting player, not to each recipient. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:37 +01:00
Ludovic Marie 7373ee8da5 Merge pull request #2453 from kacpherek/fix/entity-shadows-option
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the Entity Shadows option gates `EntityRenderDispatcher`'s shadow pass, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:33 +01:00
Ludovic Marie a6af0d647f Merge pull request #2445 from kacpherek/fix/2384-sort-cursor-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the client's `AbstractContainerMenu.clicked` predicts the sort and tweak clicks like any other, so the last sorted item stays under the cursor until the server answers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 8b87d128e6 parity: world_reopen and server_player_sculk count ticks, not request round trips
On playhorizon (frames drawn on the CPU, control requests answered slowly)
world_reopen read the minecarts before the mineshaft's last one arrived (3
then 4 on every reopen), and the sculk walk polled the sensor with separate
requests that missed its 30-tick active window. The minecart list is read
once it stops changing; both clients sample the sensor every tick from one
ctl script, alongside the walk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:08:32 +01:00
Ludovic Marie 8a2c7eee4a Merge pull request #2444 from kacpherek/fix/enchanted-hit
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.attack` sends the enchanted hit (animate action 2, `MAGIC_CRITICAL_HIT`) when the weapon's enchantments add damage, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:17 +01:00
Ludovic Marie 8a572c9239 Merge pull request #2443 from kacpherek/fix/mob-death-poof
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickDeath` sends entity event 60 at 20 ticks and the client's `makePoofParticles` matches. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:12 +01:00
Ludovic Marie 6ea9f9580c Merge pull request #2440 from kacpherek/fix/bundle-selected-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BundleItem` / `ClientBundleTooltip` mouse-wheel selection (`ServerboundSelectBundleItemPacket`, cleared when the cursor leaves the slot, now on a server's client too). Left for later: the selected item's name line in the tooltip. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:09 +01:00
Ludovic Marie e9bcd10869 Merge pull request #2435 from kacpherek/fix/landing-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.checkFallDamage` sends the landing `block` particles (`150 * scale` of them, scaled by the fall distance, at speed 0.15) where the fall ends, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:05 +01:00
Ludovic Marie 3bd504c6c4 Merge pull request #2433 from kacpherek/fix/llama-spit-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LlamaSpit.recreateFromPacket` adds seven spit particles at 0.4 + 0.1 i of its movement, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:01 +01:00
Ludovic Marie a0343bceab Merge pull request #2431 from kacpherek/fix/explosion-emitter-size
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerExplosion.isSmall` (radius under 2, or the blocks kept) picks the small particle, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:41 +01:00
Ludovic Marie 25ad1db6d3 Merge pull request #2430 from kacpherek/fix/item-tooltip-lines
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ItemStack.addDetailsToTooltip`: the order here (instrument, bees, container, banner patterns, written book, charged projectiles with their indented details, fireworks, potion with `When Applied:`, jukebox, trim, stored and plain enchantments, dyed, lore, attributes, unbreakable, block state, spawner, then the advanced durability, id and component count) is vanilla's, and the crossbow lines match `ChargedProjectiles.addProjectileTooltip`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:38 +01:00
Ludovic Marie 65cc4c6f6b Merge pull request #2429 from kacpherek/fix/lan-player-pose-flags
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `Entity.DATA_SHARED_FLAGS_ID` bits (on fire, crouching, swimming, invisible, glowing, fall flying) and the pose for LAN players; your follow-up also sends the crouching pose under a low ceiling, which matches `Player.updatePlayerPose`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:34 +01:00
Ludovic Marie 8892f0fabf Merge pull request #2427 from kacpherek/fix/creaking-heart-explosion
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `CreakingHeartBlock` / `CreakingHeartBlockEntity.removeProtector` on an explosion kills the bound creaking. Left for later: no XP for a heart broken by a player's TNT (Horizon doesn't track the TNT's owner yet). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:31 +01:00
Ludovic Marie d6fc0f6e43 Merge pull request #2424 from kacpherek/fix/note-block-particle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NoteBlock.triggerEvent` adds the note particle 1.2 above the block with note / 24 as its colour, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:27 +01:00
Ludovic Marie 517cbe76bd Merge pull request #2423 from kacpherek/fix/entity-broadcast-range
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ChunkMap.TrackedEntity.getEffectiveRange` ends in `scaledRange`, which applies `entity-broadcast-range-percentage`, as done here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:02:02 +01:00
Ludovic Marie 236371d06d Merge pull request #2422 from kacpherek/fix/honey-block-slide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `HoneyBlock` (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects` with its 1-in-5 sound and particle rolls, entity event 53). Your follow-up runs it for a server's player too, with the sound to the others and event 53 to everyone, which is how vanilla splits it. Left for later: mobs don't slide yet, and there is no `honey_block_slide` criterion. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:58 +01:00
Ludovic Marie 06c2371360 Merge pull request #2421 from kacpherek/fix/bee-wither-rose-eyeblossom-contact
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WitherRoseBlock`'s effect (wither for 40 ticks on the mobs inside it) and the eyeblossom's poison on bees. Since then you added `MobEffectInstance.update`'s rule (a stronger effect replaces, an equal one keeps the longer duration, a weaker one changes nothing), which matches. Left for later: a weaker, longer effect is not kept hidden to resume afterwards (`hiddenEffect`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:55 +01:00
Ludovic Marie b3a1f66c35 Merge pull request #2419 from kacpherek/fix/animate-tick-extras
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's particle providers and `animateTick`s: honey drips (`DripParticle`), the poplar and azalea leaf particles, falling dust under gravity blocks and the campfire food smoke match vanilla's rates, sprites and velocities. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:51 +01:00
Ludovic Marie cca3b735cc Merge pull request #2462 from kacpherek/fix/mob-block-layers
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SnowGolem.DATA_PUMPKIN_ID` and `MushroomCow.DATA_TYPE` with their layers (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 e338690bf9 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 0ab24d0805 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:55 +01:00
Ludovic Marie dfa907d28e Merge pull request #2601 from ludovic111/feat/launcher-packs-core
🤖 Agent: launcher-features-1 checked this: base main, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 03:57:27 +01:00
Ludovic Marie d9dc1c866b Merge pull request #2442 from kacpherek/fix/splash-potion-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LevelEvent` 2002 / 2007 (`LevelEventHandler`: the item crumbs, then the coloured spell or instant effect particles) and the 1053 / 1054 break sounds. It conflicted with main in `game.rs` (the zombie door sounds 1019 / 1021 landed there), so I merged main in, keeping both lists. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:51 +01:00
Ludovic Marie ec8d5f1e06 Merge pull request #2432 from kacpherek/fix/sprint-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Entity.canSpawnSprintParticle` and `spawnSprintParticle` (the block under `getOnPosLegacy`, clamped into it, fences and walls taken from the supporting block) match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:48 +01:00
Ludovic Marie a9dcea59e7 Merge pull request #1901 from ludovic111/feat/server-mods-commands
🤖 Agent: the parity stop at c5534f0d7 was friends_integration, which main broke through #2501 and #2598 fixes, plus remote_fireworks and server_player_sculk (load on the old runner; both pass on main on the Mac mini). None of the three is from this PR. Merged main (fde2dfffe, a clean merge); pr_ready --quick passed: 1357 tests, clippy, launcher.
2026-10-27 03:56:44 +01:00
Ludovic Marie 4ba8105ecc Merge pull request #2104 from rdbtCVS/fix/1482-evoker-fangs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the server sends entity event 4 once its warmup ends (`EvokerFangs.tick`), `handleEntityEvent(4)` starts the client's attack and plays the sound locally, and the client only counts `lifeTicks` down for the animation, while the server deals the damage and discards. Moving the host's sound to `play_unshared` matches that, since each client plays its own. It conflicted with #2094 (both hook entity events) once that merged, so I merged main into it, keeping both calls. `lan_e2e.py --check fangs` passes in both modes. It conflicted again with main in `tools/lan_e2e.py` (new checks on both sides), so I merged main in once more, keeping every check; clippy is clean. Fixes #1482. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-27 03:56:41 +01:00
Ludovic Marie 226b42a046 Merge pull request #2449 from RoyalPixelsDE/fix/end-portal-without-players
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EnderDragonFight.tick` scans the state and places the exit portal only when `dragonEvent.getPlayers()` is not empty, so a force-loaded End with nobody in it gets no portal, as here; the new `forced_end` harness compares both servers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:13 +01:00
Ludovic Marie 12381ec668 Merge pull request #2439 from RoyalPixelsDE/fix/generated-block-entities
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WorldGenRegion.setBlock` gives a block with a block entity a `DUMMY` entry that becomes `newBlockEntity`'s default, so the deep dark's sensors, catalysts and shriekers and potent sulfur get theirs, while placed data is kept. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:10 +01:00
Ludovic Marie 8376200697 Merge pull request #2438 from RoyalPixelsDE/fix/end-crystals-at-generation
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SpikeFeature` places the end crystals during decoration, so they are saved with the chunk that generates them, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:07 +01:00
Ludovic Marie 2dd5792ae3 Merge pull request #2436 from RoyalPixelsDE/fix/brushable-fall
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FallingBlockEntity` with a `BrushableBlock` breaks it where it lands (`onBrokenAfterFall`'s sound and level event). Left for later: the game event uses the block position, not the bounding-box centre. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:03 +01:00
Ludovic Marie 94d063684c Merge pull request #2434 from RoyalPixelsDE/fix/forced-decoration-order
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ForceLoadCommand` adds its FORCED tickets x-outer, z-inner, all at once, so the decoration order follows from that; the new `forced_order` reproduces it with the same ticket simulation as `vanilla_order`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:00 +01:00
Ludovic Marie 9d8ecc533d Merge pull request #2450 from RoyalPixelsDE/fix/structure-starts-split
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a chunk's `STRUCTURE_STARTS` are its `getAllStarts`, whether or not a piece reaches it; `starts_at` now gives those, as vanilla saves them. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:51:42 +01:00
Ludovic Marie e2428e8735 Merge pull request #2437 from RoyalPixelsDE/fix/structure-mob-finalize
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeEntities` and the pieces' markers call `finalizeSpawn(level, difficulty, STRUCTURE, null)` inside the `WorldGenRegion` with its random, and the save keys now follow `addAdditionalSaveData`. Your rebased head is the reviewed change plus the comment cleanup, on current main. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:49:24 +01:00
Ludovic Marie bad8c9eba4 Merge pull request #2474 from kacpherek/fix/unavailable-options
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: these three options have no system in Horizon yet, so they are drawn inactive with the same notice the other unavailable options use, and clicks do nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:41 +01:00
Ludovic Marie 70cba407b5 Merge pull request #2472 from kacpherek/fix/music-frequency
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `MusicManager.MusicFrequency` and the option's listener (`setMinutesBetweenSongs`). I pushed one fix: in `SoundOptionsScreen` Music Frequency starts its own row (beside Music Toast), not beside Show Subtitles. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:38 +01:00
Ludovic Marie 291239c90c Merge pull request #2470 from kacpherek/fix/execute-eye-height-positioned-as
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `EntityAnchorArgument` and `execute positioned as` (it keeps the anchor). I pushed one fix: a small armor stand's eyes are at 0.9875 (`BABY_DIMENSIONS.withEyeHeight`), not half the type's, and the types' eye heights are floats widened as `getEyeY` does. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:36 +01:00
Ludovic Marie dfd0aa0266 Merge pull request #2469 from kacpherek/fix/high-contrast-pack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the High Contrast option adds or removes the `high_contrast` pack (`Options.updateResourcePacks`), and your follow-up keeps the pack screen's selection in step. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:32 +01:00
Ludovic Marie f6e156a4ef Merge pull request #2468 from kacpherek/fix/left-handed-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `HumanoidModel.setupAttackAnimation` and `AnimationUtils.swingWeaponDown` use the main arm of a left-handed mob, and `PiglinModel`'s crossbow and admiring poses mirror with it. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 b8b353c92e launcher: import a file dropped anywhere on the window
A file dropped on the window goes where it belongs:
- a shared instance becomes a new instance, which gets selected on Home;
- a Java modpack goes to the Modpacks import;
- a resource or shader pack goes into the selected instance;
- a data pack points to the world rows.

A note in the corner says what happened, until it is closed. The Packs
section and the world rows still take their own drops, and now stop the
drop there so it isn't imported twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 e28bcba3d3 launcher: import a shared instance, or route any dropped file
`horizon_launcher::import` works out what a file is from its contents:
- a shared instance: a .mrpack needing no mod loader (as `instance
  export` writes) or a game-folder zip;
- a Java modpack: a .mrpack with a loader, or a CurseForge zip;
- a resource or shader pack;
- a data pack.

A shared instance becomes a new instance named after the pack, with
"(2)" if the name is taken. Its overrides/ (or the zip's entries) are
copied in, but only the files an instance keeps, and only paths that
stay inside its folder. Its .mrpack files are downloaded from Modrinth
and checked against their SHA-512. If anything fails, the half-made
folder is removed.

CLI and MCP: `horizon-launcher instance import <file> [--instance <i>]`
does what dropping the file on the window will do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:31:52 +01:00
Ludovic Marie e91678b994 Merge pull request #2589 from ludovic111/fix/remote-fireworks-slow-screens
🤖 Agent: remote_fireworks passes 3 of 3 on playhorizon (frames drawn on the CPU) with the earlier shots and the newly-red count.
2026-10-27 03:26:40 +01:00
Ludovic MarieandClaude Opus 5.5 4482ff63f3 launcher: Export… on Home's Instance row
The Instance row gets Export…, which opens a panel to pick .mrpack or
.zip and switch on the worlds to include. The file goes to exports/ in
the launcher's data folder under a free name, and the panel offers Show
in folder. The panel closes when another instance is picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:25:19 +01:00
Ludovic MarieandClaude Opus 5.5 a9e5214ae4 launcher: export an instance as a .mrpack or a zip
`horizon-launcher instance export [<instance>] [--format mrpack|zip]
[--world <w>]... [--out <file>]` (and its MCP tool) writes what makes an
instance, or a port: options.txt, horizon-*.txt, servers.dat, resource
and shader packs, horizon-mods/ and config/, plus the worlds named. It
never includes logs, screenshots, backups, caches or account files. A
world leaves out playerdata/, stats/ and advancements/, and its level.dat
is written without the player's UUID.

A .mrpack follows Modrinth's published format: modrinth.index.json
lists the resource packs the browser installed from Modrinth that are
unchanged, by their download with SHA-1 and SHA-512 asked from
Modrinth's API. The rest goes under overrides/. An instance with nothing
to share is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:24:53 +01:00
Ludovic Marie 9e359f230d Merge pull request #2599 from ludovic111/feat/launcher-screenshots
🤖 Agent: launcher-features-1 — item 6 (screenshots gallery); base retargeted to main after #2597 merged, merges cleanly, CI green.
2026-10-27 03:17:15 +01:00
Ludovic MarieandClaude Opus 5.5 2a7420651e launcher: find data packs on Modrinth and install one into a world
The Mods page gains a Data packs tab: Modrinth's data packs for 26.3,
each marked Partly with what Horizon runs of a data pack today
(functions, function tags, predicates, item modifiers). Install puts the
project's data pack file (not its mod build), checksum checked, into the
datapacks/ of a world picked on the tab, and says what of that zip runs
and what isn't read yet. A pack with nothing Horizon runs (worldgen
only) is refused. The world's list on Home shows it.

CLI and MCP: `mods search --kind datapack`, `mods install <pack> --world
<world>`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:15:49 +01:00
Silas LunsfordandGLM 5.3-Flash c3a12bf792 client: retune the entity-shadow depth bias to kill its rim flicker
The shadow quads lie exactly on block tops like vanilla's, but terrain
and shadows are separate draws: their fp32 depth interpolations disagree
by a few dozen ulps of the fragment depth, and at a constant bias of
16 ulps the rim re-rolled the fight with the block top as the camera
drifted a subpixel - the visible speckle. Subpixel yaw glides at three
camera pitches (70, 45, 20 down) measured ~88/~60/~26 strong flip
pixels a frame with the shadow, all on the rim, none without it.
A constant bias of 128 ulps covers the spread at any resolution while
staying far below what the 0.001 a model's feet sit higher amounts to,
so shadows never bleed onto the feet.

Co-Authored-By: GLM 5.3-Flash <[email protected]>
2026-10-26 21:11:44 -05:00
FusselTVandClaude Opus 5.5 d601715126 Streaming: the last finished ring is lit against decorated neighbours
A chunk was lit only once all eight neighbours were finished, so the
outermost finished ring of a loaded area (a /forceload square, a /pregen
band's edge, a player's last ring) stayed unlit: saved as FULL with no
light at all, and with no generation animals, which wait for light.
Vanilla's LIGHT step needs its neighbours only at INITIALIZE_LIGHT
(ChunkPyramid), so its last FULL ring is lit against decorated chunks.

A light job now reads a neighbour that will not finish (past the area)
from its decorated proto chunk (ChunkGenerator::light_view). A chunk
saved unlit is saved again once lit, and one whose light changes when a
neighbour finishes later is saved again. `execute if loaded` needs the
chunk lit, as vanilla's needs it ENTITY_TICKING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:08:53 +01:00
Ludovic MarieandClaude Opus 5.5 9cb2779608 launcher: install mods and packs into the selected instance
The Mods page and `mods` commands installed everything into the default
game folder whichever instance was picked. Mods, resource packs and
shader packs now go into the selected instance's folder, and the
Installed list, updates, Remove and the mod switches are that
instance's; picking another instance reads its list. The page says which
instance it installs into when there are several. `mods list|install|
update|remove|enable|disable` take `--instance` (default: the selected
one). Java mods' checks stay shared in the default folder for the
search results.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:02:09 +01:00
Ludovic Marie 783577d2de Merge pull request #2597 from ludovic111/feat/launcher-play-time
🤖 Agent: launcher-features-1 — item 5 (play time per instance); base retargeted to main after #2596 merged, merges cleanly, CI green.
2026-10-27 02:55:07 +01:00
socksproxandDevin e70ead5bfe Multiplayer parity: four client/server movement and use divergences
- A remote client no longer tramples farmland or turtle eggs locally:
  BlockLandingWorld.is_server_level/is_client_side now reflect the
  player's client_side flag, as vanilla's fallOn gates trampling and
  entity events on `level instanceof ServerLevel`. The server's block
  change and teleport drive the change, so a player no longer ends up
  inside the grown block (phasing through when farmland turns to dirt).
- key.swapOffhand, key.drop and a mount's inventory key on a server now
  wait for the tick's input phase instead of sending a packet straight
  from the window event, like the click queue: no action packet leaks
  between CLIENT_TICK_END and a later pong.
- remote_use_item treats a refused consume like vanilla's
  InteractionResult.FAIL: a full player who right-clicks food no longer
  starts a use the server will refuse (the eat/noslow desync), the off
  hand gets its turn, and no use cooldown starts.
- The host's "moved too quickly" check subtracts the guest's
  server-simulated velocity like vanilla's expectedDist term, so a
  fast legitimate arrival (e.g. landing with elytra) isn't flagged.

Co-Authored-By: Devin <[email protected]>
2026-10-27 02:51:55 +01:00
Ludovic Marie 8458872d07 Merge pull request #2596 from ludovic111/feat/launcher-quick-play
🤖 Agent: play into a world / join a server + servers.dat list (item 4 of launcher-features-1), on main after #2593: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (servers.dat round trip keeps every field and hidden entries); screenshots in the PR.
2026-10-27 02:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 aa6fdac501 Launcher: data packs into a world
datapacks.rs lists a world's datapacks/ (zips and folders with
pack.mcmeta, as the game discovers them; off when datapacks.json lists them
disabled), adds a data pack zip under a free name (refusing resource packs:
no data/ folder) and removes one to the trash, forgetting it in
datapacks.json. The game turns a new pack on when the world opens.

Home: each world gets "Data packs", which opens its packs under the row
with Add data pack… and Remove; a zip dropped on a world goes into it.
CLI: `worlds datapack-list|datapack-add|datapack-remove <world>`;
datapack-remove needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:49:42 +01:00
Ludovic Marie cd520ce61d Merge pull request #2593 from ludovic111/feat/launcher-worlds
🤖 Agent: worlds in the launcher (item 3 of launcher-features-1), on main after #2591: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (vanilla-style backup layout, copy naming); screenshots in the PR.
2026-10-27 02:48:44 +01:00
Ludovic MarieandClaude Opus 5.5 cac63dedca Launcher: Update all for installed packs
The Mods page's Installed list gets "Update all (N)" when newer versions
were found: every pack with one is updated in turn on a thread (each
download checked as an install is), with "1 of 2: …" progress, and one that
fails is named without stopping the others. The CLI already does this with
`mods update` and no pack name (MCP: mods_update).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:42:09 +01:00
DanielGodriNetoandClaude 9ae37fcb50 feat(threads): measure thread CPU time on Windows from cycle counts
thread_cpu() returned None on Windows. Implement it from thread cycle counts,
inside horizon-threads so horizon-client keeps forbid(unsafe_code).

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:40:34 -03:00
socksproxandDevin b39c4b8dd4 mobs: jumpOutOfFluid wants a dry, entity-free box
Entity.isFree is noCollision(this, box) && !containsAnyLiquid(box); the
check only looked at block collisions, so a mob pressed against a wall by
water flow hopped out where vanilla refuses, and it ignored other
collidable entities entirely.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:36:53 +01:00
Ludovic MarieandClaude Opus 5.5 3c257bb3e6 AGENTS.md: launcher test runs set HORIZON_LAUNCHER_TRASH and HORIZON_LAUNCHER_CLIPBOARD
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:31 +01:00
Ludovic MarieandClaude Opus 5.5 2ef155348f Launcher: keep test runs away from the real trash and clipboard
desktop_seams.rs holds the launcher's two hard-to-undo desktop effects.
HORIZON_LAUNCHER_TRASH=<folder> makes Delete (worlds, screenshots, packs)
move into that folder instead of the system's trash, and
HORIZON_LAUNCHER_CLIPBOARD=<file> makes every Copy in the window write into
that file instead of the clipboard. Test runs set both inside their scratch
folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:32:14 +01:00
DanielGodriNetoandClaude 8866e618b5 perf(light): cache touched sections during relight
Relight now runs over a Region that clones each touched section's Arc once,
copies its light on first write and writes back only sections whose light
changed. The increase loop reads state and light in one lookup. Output is
identical; a randomized border test checks it against a full relight.

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:31:59 -03:00
socksproxandDevin 7732ce986f Drop a client's remembered effects when it joins a world
A rejoining player kept the previous session's effect list, so the HUD
could show effects the server no longer had (nothing applied in combat,
no icon was earned). Vanilla's new LocalPlayer starts empty and gets its
effects from UpdateMobEffect; match that, and let the client-side tick
drop effects whose duration ran out, as its removeIf does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:31:11 +01:00
Ludovic Marie f12e457a25 Merge pull request #2591 from ludovic111/feat/launcher-instance-version
🤖 Agent: version per instance (item 2 of launcher-features-1), now on main after #2590: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (pin/unpin test); screenshots in the PR.
2026-10-27 02:28:43 +01:00
Ludovic MarieandClaude Opus 5.5 e5c3d7cff7 Launcher: Packs section on Home, with drag and drop
Home lists the selected instance's resource and shader packs with a switch
each (disabled while the game runs: it rewrites options.txt itself), Remove
after a confirmation, and Add pack… or a .zip/.hshader dropped on the
section. switch() now takes any id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 ddaa514f3a Launcher: resource and shader packs per instance (library and CLI)
packs.rs lists an instance's resource packs (resourcepacks/, a .zip or a
folder with pack.mcmeta) and shader packs (shaderpacks/: Horizon's own and
the shaders/ format, as the game's Shader Packs page lists them), and turns
them on or off the game's own way: resourcePacks in options.txt (a pack
turned on goes on top) and pickedShaderPack in horizon-graphics.txt (one at
a time); every other line of both files stays. add() copies a dropped file
into the right folder under a free name; remove() turns it off and moves it
to the trash.

CLI: `packs [list|add|on|off|remove] [--kind] [--instance]`; `packs remove`
needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic Marie 55cc84718a Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:24:14 +01:00
FusselTVandClaude Opus 5.5 07869cd3ea 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 02:20:10 +01:00
Ludovic Marie fde2dfffef Merge pull request #2598 from ludovic111/fix/friends-guest-spot
🤖 Agent: fixes parity-main's friends_integration (red since 4be5c8eb5). Cause: #2501 spawns new players at a random spot near the spawn, as vanilla does, and test_friends.py assumed the guest stood on the spawn itself. Main failed 4/4 on the Mac mini, main with #2501 reverted passed 2/2, and this branch passed 4/4 on the server thread and 1/1 in-frame. Python only; harness-only, skip-release.
2026-10-27 02:14:59 +01:00
Ludovic Marie 155f52af58 Merge pull request #2590 from ludovic111/feat/launcher-instances-ui
🤖 Agent: launcher instances in the window (item 1 UI of launcher-features-1), now on main after #2587: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally; screenshots in the PR.
2026-10-27 02:11:48 +01:00
Ludovic Marie fd6fdcbf75 Merge pull request #2587 from ludovic111/feat/launcher-instances
🤖 Agent: launcher instances core (item 1 of launcher-features-1). Verify and the three-platform launcher build passed; fmt, clippy -D warnings and all launcher tests pass locally, including the migration test (no existing file moves); CLI and play checked in scratch homes.
2026-10-27 02:10:10 +01:00
Ludovic MarieandClaude Opus 5.5 0d5ed8122e Launcher: screenshots gallery per instance
Home shows the selected instance's newest screenshots (what F2 saves in its
screenshots/ folder), all of them on request, and opens one over the window
with Newer/Older (and the arrow keys), Copy (the picture to the clipboard),
Open, Show in folder and Delete (to the trash, after a confirmation).

CLI: `screenshots [list|delete] [--instance]`; `screenshots delete` needs
confirm over MCP. worlds::to_trash is shared by worlds and screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:09:39 +01:00
Ludovic Marie fd1d6a2762 Merge pull request #2595 from ludovic111/feat/game-quick-play
🤖 Agent: game quick-play flags (item 4 of launcher-features-1): pr_ready --quick passed (1281 horizon-client tests, clippy, fmt); verify passed and CI's parity scope selected no harness for this argument-parsing change; checked by hand with the real game (missing world → title screen, existing world opens).
2026-10-27 02:03:26 +01:00
Ludovic Marie 4fe8f71530 Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:03:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea0cc65aea Launcher: play time per instance
Every run of the game started from the window or `horizon-launcher play`
adds its length to its instance's play time in instances.json (by id, so a
renamed instance keeps it), with when it ended. Home's Instance row shows
"3 h 06 min in total · last played today"; `instance list` shows it too,
and its JSON gives played_secs and last_played_ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:02:15 +01:00
Ludovic Marie a0ef14f1e2 Merge pull request #2582 from ludovic111/perf/parity-one-client-profile
🤖 Agent: harnesses build horizon-client in one profile; 6 harnesses checked fresh on playhorizon and the Mac mini.
2026-10-27 01:58:38 +01:00
Ludovic MarieandClaude Opus 5.5 4b761abcb2 Launcher: play straight into a world or server, and the Multiplayer list
Each world on Home gets Play (the game starts with --world <folder>), and
Home lists the selected instance's servers from the game's own servers.dat:
Join starts the game with --server <address>, Add server and Remove change
the file. server_list reads and writes it as vanilla's ServerList does
(uncompressed NBT, a new file then servers.dat_old) and writes every entry
back with all its fields, the hidden Direct Connection ones too.

CLI: `multiplayer [list|add|remove] [--instance]`, `play --server`;
MCP: play takes `server`; `multiplayer remove` needs confirm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:56:41 +01:00
Ludovic Marie 0a75323caa Merge pull request #2584 from ludovic111/perf/parity-manifest-aware
🤖 Agent: crate splits' workspace-only manifest changes no longer select every harness (354 to 209 on #2160 and #2215); other PRs select as before.
2026-10-27 01:49:27 +01:00
Ludovic Marie 4f5ed6a19b Merge pull request #2588 from ludovic111/chore/workspace-members-per-line
🤖 Agent: merging. Formatting only: Cargo.toml's workspace members one per line (38, including #2160's), so new crates stop conflicting. tomllib parses it identical to main and Cargo.lock is unchanged, so no parity run is needed; verify and launcher pass.
2026-10-27 01:45:25 +01:00
socksproxandDevin (SWE-2 High) e8891849a4 AGENTS.md: name posters by GitHub handle, never a real name
The "names its own person" rule was ambiguous about which identity an
agent should attribute a post to; handles are the public identity.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:43:34 +01:00
Ludovic MarieandClaude Opus 5.5 d12dc5d1e1 Game: vanilla's --quickPlaySingleplayer and --quickPlayMultiplayer
The launcher starts the game straight into a world or a server. Vanilla's
names for that (client Main, QuickPlay) now work too: --quickPlayMultiplayer
is --server; --quickPlaySingleplayer opens a world of saves/ only if it
exists, as QuickPlay.joinSingleplayerWorld does, and shows the title screen
otherwise (--world also creates one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:43:25 +01:00
Ludovic MarieandClaude Opus 5.5 c70e2fae74 Launcher: worlds of an instance, with backup, copy and delete
Home lists the selected instance's worlds from their level.dat (name, mode,
version, last played) with their size and picture. Back up zips one into
backups/ as vanilla's Make Backup does; Copy makes "<name> (1)" with that
name in its level.dat; Delete moves it to the system's trash after a
confirmation (the file manager call on macOS, no Finder permission);
Open folder. `horizon-launcher worlds list|backup|copy|delete`, and their
MCP tools (delete needs confirm). level.dat is read with horizon-world's NBT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:31 +01:00
Ludovic MarieandClaude Opus 5.5 64f9ff4529 Merge origin/main into chore/workspace-members-per-line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:20 +01:00
Ludovic Marie f5b7bdba1d Merge pull request #2160 from ludovic111/refactor/horizon-command
🤖 Agent: merging the first crate split (horizon-command + horizon-client-core, pure move, re-exported under the old paths). CI parity passed at 17f8419ac (head merged into main), verify and launcher pass, approved by rdbt's review agent; landed by hand while the REST pool refills.
2026-10-27 01:36:58 +01:00
socksproxandDevin ee0af20e16 Cap the view scale's render distance by the server's
streaming.radius reverts to the client setting in apply_settings, so
an options change on a server with a lower view distance raised every
entity's render range past vanilla's. min(render_distance,
remote.view_distance) is Options.getEffectiveRenderDistance.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:27:41 +01:00
Ludovic MarieandClaude Opus 5.5 90f2755245 Launcher: a Horizon version per instance
An instance plays the launcher's version, which follows updates, unless it is
kept on another installed one: the Play page's picker pins the selected
instance (picking the version marked "follows updates" unpins it), and
`instance version <instance> <tag|latest>` does it from the CLI. play,
`play --instance` and MCP play use the pin; a pack's Play uses its instance's.
The sidebar shows a pinned instance's version, or that it is not installed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:25:31 +01:00
Ludovic MarieandClaude Opus 5.5 a76456b036 Launcher: instances in the sidebar, and a dialog to make and edit them
The sidebar lists the instances by group with their icons (a texture from the
player's prepared files, drawn as pixels, or a colour); clicking one selects it
for Play. + makes one, empty or a copy of the selected one (copied in the
background); Home's Instance row edits its name, group and icon, copies it, and
opens its folder. The dialog's name and group are the launcher's shared text
fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:21:56 +01:00
Ludovic MarieandClaude Opus 5.5 6087b01f66 test_friends: put the guest on cleared, flat ground before it walks
Since #2501 a player new to the world spawns at a random free spot near the
world spawn, as vanilla's PlayerSpawnFinder does. The guest often stood against
a hill, so walking forward moved it less than a block, or terrain hid the block
it had to aim at: friends_integration failed on every run on the Mac mini.

The host now clears a floor beside itself and teleports the guest there before
the movement checks, and the block check waits for the guest to stand still.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:21:06 +01:00
Ludovic MarieandClaude Opus 5.5 1d0d827f3a Launcher: instances, each with its own game folder
The game's folder stays the default instance, "Horizon", where it is; imported
and registry modpacks are listed as instances in their own folders; new ones
go to instances/<slug>/ in the game's folder, empty (the player's options and
offline name) or a copy of another (never sessions, logs or telemetry). Names,
icons and groups live in the launcher's instances.json, so nothing moves.
Play, `play --instance` and MCP play start the selected instance, which
follows the launcher's data collection answer and No AI. Crash reports are
read from the run's own folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:13:35 +01:00
Ludovic MarieandClaude Opus 5.5 844441874a docs: workspace crate features in a split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:11:27 +01:00
Ludovic Marie b6dd6c35ae Merge remote-tracking branch 'origin/main' into perf/parity-manifest-aware 2026-10-27 01:10:36 +01:00
Ludovic MarieandClaude Opus 5.5 1af4d0e432 parity: a workspace crate's features count through the sources they gate
A manifest line defining or turning on a workspace crate's feature no longer
makes the manifests count, if every source naming the feature (or one it turns
on) changed too, as in a split, where the new crate's files are all new.
#2215 (horizon-items with a test-support feature) now selects 209 harnesses
instead of 354.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:10:36 +01:00
Ludovic Marie 68c9597237 Merge remote-tracking branch 'origin/main' into perf/parity-one-client-profile 2026-10-27 01:09:18 +01:00
Ludovic MarieandClaude Opus 5.5 bedba4a3d9 lan_e2e fireworks: count newly red pixels, take the later shots 4 ticks after the burst
On playhorizon (frames drawn on the CPU) a screenshot takes ~0.6 s: with a
16-tick wait the later shots came after the sparks' lifetime, and the setup's
own red chat lines, gone by then, made the host's count negative.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:08:50 +01:00
levzzzandminimax-m3.1-flash 9ab0e2b6a8 Cover the slime block's push and bounce in the LAN harness
A piston pushing a slime block moves the body in front of it and bounces
it: the block's swept collision shape pushes the body out of the way, and
the slime block sets the body's velocity along the push axis
(`PistonMovingBlockEntity.moveCollidedEntities`, the `causeBounce` branch).
The new `piston-slime` LAN case checks both for the host and the guest, with
a plain block as the control: a plain block only pushes the body 0.81
blocks, the slime block pushes and bounces it 2.5.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:08:40 +05:00
minimax-m3.1-flash 498de72da2 Frog: walk at vanilla's speed
Vanilla's frog uses a SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true), so
a walking frog covers 0.1 blocks a tick (its movement speed attribute is 1.0
and the controller's outside-water speed is 0.1). Horizon gave every generic
animal the plain ground controller, which drives the mob at the attribute
itself: a frog walked ten times too fast, jittered, teleported and flew
(#2213).

The regression test fails without the change.
2026-10-27 05:07:45 +05:00
Ludovic MarieandClaude Opus 5.5 0db65d7a61 Cargo.toml: one workspace member per line
Each new crate then adds one line instead of rewriting the whole list, so
PRs that add crates at the same time stop conflicting (the crate split's
#2160 conflicted on it three times).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:06:57 +01:00
levzzzandminimax-m3.1-flash 7250944a7c Test the decorated pot item's back-face culling too
The pot in the hand and dropped on the ground is drawn by the same
`DecoratedPotRenderer` model, through the item path, so its quads carry
the cull as well. The test fails without it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:20 +05: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
levzzz 04672c9613 Merge remote-tracking branch 'upstream/main' into fix/selector-end-crystals 2026-10-27 05:04:30 +05:00
levzzzandminimax-m3.1-flash fa4f38f824 commands: an unbounded selector reaches the other loaded levels
`Game::select` gathered its candidates from the level in place only, so
`@e[tag=...]` never found an entity in another dimension the server
still holds: vanilla's `EntitySelector` searches every loaded level
unless the selector carries a spatial constraint.

The selector now searches each loaded level in turn, and the commands
that act on the entity it found (`/data get entity`, `/kill`, `/tp`)
act on it in the level that holds it. The experience orbs and the
projectiles are named by their entity id, like the other entities, so
the entity a command acts on is the one the selector found.

Fixes #1524

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:01:07 +05:00
Joansitoh 9cebcc8d7f Launcher: a paste keeps the spaces at its ends
Pasting " --world Test" after an argument lost its leading space and
joined the two arguments. The first line of the clipboard is inserted as
it is.
2026-10-27 00:59:55 +01:00
levzzzandminimax-m3.1-flash ac33fb74ec Add the decorated pot's back-face culling regression test
`DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
whose pipeline culls back faces. The pot's `top` and `bottom` are flat
boxes, so without that cull both sides of each plane are drawn and they
z-fight: the pot renders wrong (kacpherek's #2099 draws the cull).

The test renders a plain pot and checks that all of vanilla's 28 submitted
quads are kept and that every one of them is marked for the cull, so the
behaviour cannot silently regress. It fails without the cull.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:57:27 +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
levzzz 14dac749e3 Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight 2026-10-27 04:50:53 +05:00
levzzz 10d0132a53 Merge remote-tracking branch 'upstream/main' into fix/mining-dig-stop-check 2026-10-27 04:50:40 +05:00
levzzz 209fa169cd Merge remote-tracking branch 'upstream/main' into fix/villager-job-site-trades 2026-10-27 04:50:35 +05:00
levzzz 17fa6e826c Merge remote-tracking branch 'upstream/main' into fix/potion-contents-in-the-creative-inventory 2026-10-27 04:50:23 +05:00
levzzz 66bec37b55 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 04:50:18 +05:00
levzzz 0fe747ad51 Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 04:48:57 +05:00
Hugo GueguenandClaude Opus 5.5 e38e25c1a8 Renderer: a headless constructor and frame readback
Renderer::new_headless draws with no window into the offscreen target
--offscreen already uses, so it needs no display server. With
capture_frames set, each rendered frame is copied to a reusable mappable
buffer, and read_capture returns the oldest one as tightly packed rows,
waiting only for that frame's submission: two frames stay in flight.
window becomes an Option; the windowed constructor and new_headless share
one open. Nothing changes for the game: no copy is made unless
capture_frames is set, and screenshots use the same copy helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:42:10 +01:00
minimax-m3.1-flash 5be7c02d0c Cover the mining dig stop in the LAN harness (#1340, #1908)
A block the client breaks at once (a creative break, a dead bush) sends no STOP or ABORT, so the server has to forget the dig itself. Without it the server keeps mining the emptied cell: the block put back there is destroyed again (#1340) and the mining sound keeps playing at it (#1908, the cell's sound group is stone).

The new dig_stop check breaks a stone block in creative and a dead bush in survival for the host and the guest, then puts the block back and waits: it must stay, and no mining sound may play.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
minimax-m3.1-flash e11471cda0 Run the LAN harness headless
HORIZON_E2E_HEADLESS launches the games with --offscreen --no-pause, so the suite runs on a machine with no focused window: without it the games pause on the focus loss and wait_ready never completes. macOS keeps its offscreen launch.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
levzzzandminimax-m3.1-flash 731a35612a Lock the benchmark's load gate: a scene waits for the chunks
The benchmark's readiness rule waits for the resident chunk count to reach
the view's target before "steady" means loaded, so a scene that starts with
an empty view (the integrated server has not sent its first chunk yet) is
measured after the world is really there. The rule had no test, so a change
could silently drop it. This adds one: a fake control that keeps the counts
steady and the queue empty for a second and a half, and only then lets the
chunks arrive, must not end the wait before they do. Without the gate the
wait ends after the quiet second and the test fails.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:01 +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
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

- Fox, Rabbit and Goat.addAdditionalSaveData: Type, RabbitType,
  IsScreamingGoat, HasLeftHorn and HasRightHorn are saved from the model
  and read back (an unknown rabbit type is brown, horns default to
  present, as vanilla reads them).
- The fields Horizon does not model (a fox's Trusted, Sleeping, Sitting,
  Crouching, a rabbit's MoreCarrotTicks, a horse's EatingHaystack and
  Bred) keep a loaded value or get vanilla's default.
- A loaded horse or llama gets its health clamped to its max health, as
  LivingEntity.setHealth does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:17 +01:00
levzzz 5ac1c82eb9 Merge kacpherek's PR #2099: the decorated pot culls its back faces
The decorated pot's model parts are submitted with RenderTypes.entitySolid,
whose pipeline culls back faces; the pot's top and bottom planes are flat
boxes, so without the cull both sides of each plane are drawn and they
z-fight. This keeps all of vanilla's 28 submitted quads and culls the back
faces, like entitySolid does.
2026-10-27 04:36:29 +05:00
Alexander KrylovandHermes Agent abdaf71ecd Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-27 02:36:00 +03:00
levzzzandminimax-m3.1-flash e184ab7a42 A villager that loses its job site offers its new profession's trades
The reported sequence in #1639: a villager works at a bench, the bench is
destroyed and it takes another bench's job, and then it does not open a
trading menu.

`Villager.setVillagerData` drops the offers (`offers = null`) when the
profession changes, so the next `AbstractVillager.getOffers` rolls the new
profession's trade set. The brain reaches that through `set_profession`,
which now also marks the offers as not made (#2009); this test covers the
whole reported sequence, from the lost job site to the new profession's
trades, so a villager that keeps the old profession's offers marked as made
cannot pass it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:35:26 +05:00
levzzzandminimax-m3.1-flash 6384234e37 Stop a piston push at the world border
`move_pushed` resolved the pushed movement with `SceneCollisions`, which
supplies no world-border collision, so a piston next to the border could push
a player through it even though ordinary movement stops at the border.
`Entity.move` collects the border's walls in
`collectCollidersIgnoringWorldBorder` whenever
`WorldBorder.isInsideCloseToBorder` holds, and `CollisionGetter.borderCollision`
adds `WorldBorder.getCollisionShape` to them. The push now resolves through
`EntityCollisions::with_border`, which applies that same rule to the pushed
body's own x and z, so a body pushed into the border's wall stops there.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:32:16 +05:00
Carlos Nunez 3caf28c4d6 Merge remote-tracking branch 'upstream/main' into tmp/merge-2298
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 19:27:09 -04: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
levzzzandminimax-m3.1-flash 4c9075a072 Keep the thrown-item reproduction for #1762
A server's client ticks a projectile itself between the entity tracker's
updates, as vanilla's Entity no-op interpolation handler does; before #2086 it
only eased towards the server's position, so a thrown egg, ender pearl or wind
charge hung in the air for half a second. The client tick is now a named
function with unit tests for that flight, and the parity harness keeps the
reproduction the issue asked for: a connected client throws each of them and
samples their position twice as often as the tracker's interval. The LAN
harness can also run headless now (HORIZON_E2E_HEADLESS), so the games render
offscreen and never pause on the focus loss on a machine with no focused
window.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:08:31 +05:00
levzzzandminimax-m3.1-flash 7ae20a5b29 A potion from the creative inventory keeps its contents in the hotbar
#1832 reported that a potion taken from the creative inventory and put in
the hotbar turned into the uncraftable potion. The hand-over keeps the
stack as it is on `main` (#1796 and #1874), so this adds the test that
keeps it that way: the item tab's stack is the real one, the creative
screen hands it over unchanged, and the hotbar slot holds the brew.

Fixes #1832

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:07:36 +05:00
levzzzandminimax-m3.1-flash 67b6e75c0b The container screen's press state does not survive the screen
AbstractContainerScreen keeps `isQuickCrafting` and the drag it belongs to
on the screen, so every screen starts with its own state. Horizon keeps
both on `Game`, and #2052 only dropped them when the pointer was grabbed
again, so a press whose release never arrived (the pointer left the
window) kept the drag running on the next screen: `Clicks::press` answers
`None` for every later press, so the Shift quick move and the clicks on
the inventory went dead.

The press state now goes with the pointer: `Game::capture` drops it
whichever way the pointer changes hands (a screen opens or closes, the
window loses focus), and `Clicks::cancel_drag` names the drop.

Fixes #1737

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:01:42 +05:00
Louis NordbøandClaude Opus 5.5 64cd55cf74 Finish a use on the server's USE_ITEM_COMPLETE, and eat on the client too
On a server client (single player's server thread, LAN guests, dedicated
servers) the window ended a consumable's use on its own timer and never ate
the food: its FoodData waited for the server's next ClientboundSetHealth.
With the button still held, the next startUseItem found the food bar below
20 and began another bite, so the eating animation went on after the bar
was full (#2471).

Vanilla's client never completes a use by itself: LivingEntity.updateUsingItem
completes it only on the server, and ServerPlayer.completeUsingItem first
sends the player entity event 9 (USE_ITEM_COMPLETE). Player.handleEntityEvent(9)
then runs completeUsingItem on the client, where Consumable.onConsume calls
the FoodProperties listener on both sides: the client's FoodData eats at once.

- The server sends event 9 to the acting guest when a consumable, a potion or
  a spyglass use completes. Like the guest's packets, the events are queued
  during their turn (kept across nested turns) and sent with their entity id.
- The client keeps using until that event, then shows the last crumbs, eats
  the food (the next ClientboundSetHealth replaces it) and starts the item's
  use_cooldown, which ItemStack.use only starts at once for an item with no
  use time.
- What vanilla's client checks so that a use can't outlive the server's:
  MultiPlayerGameMode.useItem passes on an item cooling down (chorus fruit
  held through its cooldown), LivingEntity.updatingUsingItem stops when the
  hand no longer holds the item the use began with (ItemStack.isSameItem),
  a respawn is a new player with no use, and Consumable.startConsuming
  starts none for a consumable with no use time.
- ItemCooldowns tick where Player.tick ticks them: after LivingEntity.tick's
  use on the server and after Minecraft.handleKeybinds on the client. Both
  sides ticked them first, so a guest's entry ended a tick later on the
  server than on their client, whose held retry was refused and never ended.

lan_e2e's new eat_full check eats melon slices up to a full bar with the
button still held: before, a new bite had started (use ticks 26, 2, 9, 15);
now nothing is in use. Held on two chorus fruits, both are eaten across the
cooldown. consume-release holds its completed use a little longer, as the
use now ends with the server's event.

Fixes #2471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:59:03 +01:00
levzzzandminimax-m3.1-flash 8909e0510c commands: selectors find the End crystals and the Ender Dragon
Horizon's command targets enumerated the ordinary entity collections
only, so `@e[type=end_crystal]` and `@e[type=ender_dragon]` matched
nothing: the End crystals and the dragon live in their own lists, and
neither carried an entity id a command could name.

The crystals and the dragon now take an id from the entity counter when
they are created, as EndCrystal's and EnderDragon's constructors do; the
selector offers them as candidates; `/data get entity` reads them and
`/kill` removes them the way `Entity.kill` does. `/summon
minecraft:end_crystal` creates one, as vanilla does.

Fixes #1531

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:55:39 +05:00
Louis NordbøandClaude Opus 5.5 5b5ebfcce3 Walk turtles at their own pace so their feet stop flailing (#1776)
Turtles used the default MoveControl, which sets the speed straight to
speedModifier * movement speed (0.25 on a stroll). Vanilla gives them
TurtleMoveControl: updateSpeed halves the speed every tick on land (never
below 0.06), and in water lifts the turtle 0.005 and cuts the speed away
from home (0.08) or as a baby (0.06); tick then eases the speed an eighth
of the way to the wanted one, faces the wanted point with the body, lifts
towards it and never jumps. On land that settles at 0.08375, so a
strolling turtle covered about nine times the ground per tick that
vanilla's does (the input is speed squared). TurtleModel swings the legs
by 8 * walkSpeed at walkPos * 5, so the walk animation fed by that motion
spun the feet erratically.

This only fixes the move control. Swimming still differs from vanilla:
Turtle.travelInWater (moveRelative(0.1F), 0.9 drag, sinking 0.005 without
a target unless heading home within 20 blocks of it), TurtlePathNavigation
and the turtle goal set are not ported yet; turtles keep the generic
fluid travel and goals for now.

Vanilla: Turtle$TurtleMoveControl.tick / updateSpeed, Turtle.<init>,
TurtleModel.setupAnim, LivingEntity.updateWalkAnimation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:50:24 +01:00
Ludovic MarieandClaude Opus 5.5 17f8419acd Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 1b1571ed6a parity: a crate split's manifest changes don't select every harness
Cargo.lock is a shared input and every harness depends on its crates'
Cargo.toml, so a crate split (#2160) selected all 352 harnesses although
its moved code reaches 123. With --changed, Cargo.lock and Cargo.toml
changes that only add, remove or move workspace crates (no registry or git
package, version, feature, dependency or build setting changed across them,
compared with the merge base) are left out; the moved code counts through
its own sources. Any other manifest change still counts, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 a891037c9f parity: harnesses build horizon-client in the fast profile only
Under parity.py the release and fast profiles have identical settings
(PROFILE: no LTO, 16 codegen units, incremental), yet 38 harnesses built
horizon-client in release and 16 others in fast: every run where the client
changed compiled it twice. They all use fast now. Release stays for
horizon-world's probes, which no fast harness builds.

Measured on playhorizon (4 CPUs), all 13 probe builds after 78 merges:
1578 s, of which the release client (bin + client_probe) took 485 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:24:54 +01:00
levzzzandminimax-m3.1-flash ef0714a99a Lock in the sprint state that outlives the sprint key
Sprint is a state, not a held key: LocalPlayer.aiStep starts it with the
sprint key and stops it only on a vanilla stop condition, so releasing the
key keeps it while forward is held. The server-side overwrite that rewrote
the flag from the held keys is gone, so the underwater swim, the sprint
jump and the flight speed follow the state on main. This adds the
end-to-end regression tests that were missing for those three paths: the
swim speed with the key released, the jump impulse with the key released,
and the flight speed that follows the sprint state and not the sprint key.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:22:24 +05:00
masa-masa-0314andZCode 66607c9eab Items: glint enchanted armour on armour stands
ArmorStandRenderer draws worn armour through the same
HumanoidArmorLayer as a living entity, so its pieces should use the
armour glint when ItemStack.hasFoil() is true. The stand's equipment
loop now checks the stack's enchantment_glint_override component, then
the item's default override, then its enchantments component (vanilla's
Item.isFoil), and draws both the armour and its trim layer with overlay
13. The existing ARMOR_GLINT path supplies the model UV and samples
enchanted_glint_armor through ARMOR_ENTITY_GLINT_TEXTURING.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 06:14:36 +08: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
FusselTVandClaude Opus 5.5 266c7a2cd9 Worldgen: finalize structure mobs at placement, as vanilla
Structure mobs (bastion piglins, brutes and hoglins, ocean ruin drowned,
village and mansion mobs, witch huts, monuments) were tagged and finalized
at load by the cut-down spawning routine: no STRUCTURE reason (piglin
weapons rerolled), no Mob step (follow range bonus, left-handedness), no
baby hoglins, no brain memories, no drowned nautilus mounts, and draws
from each mob's own random. The decoration region now calls a hook the
game installs in the generator workers, which runs mobs::finalize with
Reason::Structure, the region random in placement order and the level's
difficulty with no inhabited time; jockeys come back as the vehicle with
its Passengers. A mob the hook cannot finalize keeps its tag and is
finalized on load as before, as are mobs of older saves; other entities
drop the tag. The generator gets the level's difficulty as soon as the
level opens, so the spawn area's mobs use it too. mob_to_nbt writes Invulnerable and LeftHanded always, IsBaby
for zombies and piglins, Age for hoglins, the finalized attributes and
memories, and drops the template keys vanilla never saves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:22 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
masa-masa-0314andZCode 43e407562f Items: animate the armour glint from enchanted_glint_armor
Enchanted worn armour lost its animation: mobs/render.rs gave every
armour vertex [0, 0] for glint_uv, while the entity shader samples the
glint at that coordinate for overlay 7, so the armour showed a single
texel instead of the scrolling pattern.

Armour layers now carry overlay 13 (ARMOR_GLINT) and the layer's model
UV. entity.wgsl applies ARMOR_ENTITY_GLINT_TEXTURING (scale 0.16) and
samples misc/enchanted_glint_armor through the same repeating, linear
GlintSampler as items, matching ARMOR_CUTOUT_NO_CULL_GLINT. The scroll
clock (millis * glintSpeed * 8 modulo the offsets' common period) rides
in globals.time.w, so armour and items move together.

Addresses the review comment on #2296.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:54:10 +08:00
levzzz bafd010865 Merge remote-tracking branch 'upstream/main' into fix/piston-entity-push 2026-10-27 02:22:04 +05:00
FusselTVandClaude Opus 5.5 a55c825467 Minecarts save HasTicked and Fire 0
AbstractMinecart saves Entity.firstTick as HasTicked, and a cart's fire
stays 0: Entity.move sets it to -getFireImmuneTicks(), which is 0 for
everything but players. A mineshaft's chest minecart was saved without
HasTicked and with Fire -1; vanilla's generated chunk saves it with
HasTicked 1 and Fire 0, and a cart that has ticked with HasTicked 0 and
Fire 0.

The mineshaft writes both as the proto chunk saves a new cart, and a
cart keeps first_tick from its save until its first tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
FusselTVandClaude Opus 5.5 6c716f457b Mobs: farm animals and frogs keep their variant; sound variants are saved
finalizeSpawn picks the variant of a pig, cow, chicken or frog and the
sound variant of a pig, cow, chicken, cat or wolf, but the save wrote
neither for these: pigs, cows, chickens and frogs came back temperate
after a reload, and every cat and wolf was saved with the classic sounds
whatever it rolled. Chickens also lost their egg timer.

Every mob with a variant component now saves and loads `variant`
through finalize::species_variant_component / variant_index (the cat and
wolf code used its own copy). The sound variant is kept as an index into
the species' registry (None is the default, classic) and is saved and
read back; Chicken also saves `EggLayTime`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
Ludovic Marie c5534f0d7b Merge remote-tracking branch 'origin/main' into feat/server-mods-commands
# Conflicts:
#	crates/horizon-mods/src/lib.rs
2026-10-26 22:14:09 +01:00
masa-masa-0314andZCode 024415e09d Inventory: animate every animated block and item sprite
The shared atlas packs animated block and item textures as their first
frame, and only fire re-drew itself: an animated block held or previewed
in the GUI (sculk, magma) sat still, while the same block animated in
the world. The atlas now keeps every animated block or item sprite's
frame list and re-uploads the current frame into its rect as the
animation ticks, with the mcmeta's interpolation mixing the frame into
the next one the way the world's sprite arrays already do, so GUI items
animate like vanilla's stitched atlas.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:11:35 +08:00
Ludovic MarieandClaude Opus 5.5 d3a662b760 Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 3f0e70882c Death messages: no fallback for a message with no entity behind it
DamageSource.getLocalizedDeathMessage sends the one-argument message, and
TranslatableContents.decompose shows a template it cannot fill as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 54ae097faa Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:46 +01:00
Ludovic MarieandClaude Opus 5.5 1b0937abff Piglin celebration: the dance roll and wasHurtBy as in PiglinAi
wantsToDance draws from RandomSource.createThreadLocalInstance(gameTime),
not the piglin's random, and wasHurtBy ignores another piglin's hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:05 +01:00
Ludovic MarieandClaude Opus 5.5 1fe0f5fcb9 Sound options: Music Frequency starts its own row, as in SoundOptionsScreen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 34f57dbcac Trident: Impaling through the enchantment's damage effect
ThrownTrident.onHitEntity runs EnchantmentHelper.modifyDamage on the
trident, so Impaling adds to the 8 for the entity types in
#sensitive_to_impaling (its minecraft:damage requirement), not for every
mob that swims or breathes water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 83b544f04f Bonus chest: draw from the level random and set blocks with UPDATE_CLIENTS
MinecraftServer.setInitialSpawn passes level.getRandom() to
BonusChestFeature.place, which sets the chest and torches with flags 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:57:31 +01:00
Carlos NunezandClaude Sonnet 5.5 b90f9ec1ef Make an End gateway's block entity the source of its exit
The side file kept an entry for a gateway after Horizon created its exit,
so removing the exit with /data remove block ... exit_portal, or placing
a different gateway there, sent the player to the old exit again.

Once the gateway's chunk is loaded, gateway_job moves an entry the side
file still holds into the block entity (unless the block entity has an
exit of its own) and drops the entry. set_gateway_exit writes only the
block entity when the chunk is loaded, and leaves an entry in the side
file only when the chunk is already unloaded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:57:07 -04:00
Ludovic MarieandClaude Opus 5.5 c4d0cb1ec0 A small armor stand's eyes are at 0.9875, and eye heights are floats
ArmorStand.BABY_DIMENSIONS keeps its own eye height rather than half the
type's, and EntityDimensions.eyeHeight is a float widened by getEyeY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:55:51 +01:00
Ludovic MarieandClaude Opus 5.5 1258c2473b Multiply the particle speed and delta as floats, as handleParticleEvent does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:54:53 +01:00
Kacpherek 3bb6e40304 Clear a bundle's selection when the cursor leaves it on a server's client too 2026-10-26 21:46:13 +01:00
Kacpherek eab8d0f911 Slide on honey for the server's player and show the slide effects to multiplayer clients 2026-10-26 21:45:02 +01:00
Kacpherek d7fece5825 Keep hideParticles of mob effects: no swirls drawn or sent for them 2026-10-26 21:42:52 +01:00
Kacpherek bab4251715 Give armor stands, frames, paintings, boats, carts and other entities a scoreboard name 2026-10-26 21:37:47 +01:00
Kacpherek 9eb913affe Show the crouching pose of a guest who is not holding sneak 2026-10-26 21:37:33 +01:00
Kacpherek c808fae288 Keep a stronger active effect when a weaker one is added 2026-10-26 21:37:30 +01:00
951b88a3c2 Read an End gateway's exit from its block entity
Horizon had no End gateway block entity: game/end.rs kept each gateway's
exit in its own side file and gateway_job only looked there, so a
gateway set with /setblock end_gateway{exit_portal:[I;..],ExactTeleport:1b}
(or the exit worldgen writes) was ignored, and /data get block said the
block was not a block entity.

TheEndGatewayBlockEntity stores Age, exit_portal and ExactTeleport.
BlockEntity::EndGateway now holds them with vanilla's NBT and the
spawnable-bounds check. gateway_job reads the block entity's exit first
and keeps the side file and the 512-block End-spawn rule as fallbacks,
so older saves and gateways without data behave as before. The exits
Horizon creates are stored in the block entity as well, a new exit keeps
the gateway's own ExactTeleport (setExitPosition), and a command that
only changes a block entity's NBT now marks its chunk modified so the
change is saved.

Fixes #1517.

Implementation by Codex (GPT-6.1 Sol), reviewed and extended by Claude.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:36:53 -04:00
Ludovic MarieandClaude Opus 5.5 84f8205bdd Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:30:40 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek 56bf45cee5 Keep the pack screen selection in step with High Contrast 2026-10-26 21:23:03 +01:00
Kacpherek abb4e7b5cd Record the thrower of guest drops and the player behind a lead 2026-10-26 21:21:40 +01:00
Kacpherek 0e96fb6d2a Disable unavailable options in the Graphics Advanced screen 2026-10-26 21:21:06 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
levzzzandCodex dca6ebba0e Merge upstream main into piston branch
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:15:46 +05:00
Ludovic MarieandClaude Opus 5.5 3755bb7d77 parity: stop harnesses on SIGTERM, and reap orphaned harness JVMs
Nine vanilla Java servers from harness runs had been orphaned on the Mac mini for 2 to 5 days. Each harness runs in
its own session (killpg on a timeout), so when a CI job was cancelled, its harnesses and their servers outlived
parity.py.
- parity.py: SIGTERM and SIGHUP exit through the finally blocks, which now stop the local executor's harnesses
  too, not only the remote hosts'. The worker (--worker) stops its harnesses the same way.
- runner-gc.sh (every 6 h from launchd): stops Java processes whose parent is gone (launchd, init or
  systemd --user) and whose command line names a runner's checkout or a worktree's .local/, once ORPHAN_HOURS
  (default 3) old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:10:41 +01:00
Kacpherek 94748162bc Handle a dig packet before the move queued behind it
The client breaks the block under its feet and falls in the same tick. The
hub checked that move against the block that was still there and sent the
player back. Moves queued behind a dig packet now wait for the next tick,
after the break.
2026-10-26 21:08:59 +01:00
Kacpherek 06b046d8fc Light, scaffolding and shulker box outlines follow the held item and the lid 2026-10-26 21:07:17 +01:00
levzzzandCodex 6af81b600f Push entities with moving pistons
Move players, mobs and common objects when piston block entities advance, applying collision and the per-tick piston limit. Exercise host, guest, inventory-open and mob pushes in both single-player paths, and compare the push geometry with vanilla.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:01:31 +05:00
Kacpherek c85f6f2e39 Give polar bears their attack AI and rearing pose
Polar bears only panicked and strolled. They now follow vanilla's goals: melee attack that rears up and growls before each strike, hurt-by and cub-defending targets, anger at the player, and fox hunting. A hurt cub calls the adults. The standing flag is synched and the client animates it.
2026-10-26 20:50:13 +01:00
Kacpherek 34dabe4afd Grey out Chunk Fade-in, Improved Transparency and Chunk Builder 2026-10-26 20:41:49 +01:00
Kacpherek 36c06d6133 Let a piglin celebrate a killed target and dance over a hoglin 2026-10-26 20:41:38 +01:00
Kacpherek ad6f2320b0 Wire the Music Frequency option to the music manager 2026-10-26 20:40:06 +01:00
Kacpherek 4201fd23c0 Use real eye heights in /execute and keep the anchor on positioned as 2026-10-26 20:39:05 +01:00
Kacpherek 46b71954e1 Make the High Contrast option turn its resource pack on and off 2026-10-26 20:38:38 +01:00
Kacpherek b92a53e47c Swing and pose the main arm of a left-handed mob 2026-10-26 20:37:52 +01:00
Kacpherek ffc343aadd Make execute on origin and leasher find their entity 2026-10-26 20:37:39 +01:00
Kacpherek 4cf9ffd847 Draw the autosave indicator 2026-10-26 20:36:40 +01:00
Kacpherek e23fcb1040 Scale a baby's walk position when it is read, as vanilla does 2026-10-26 20:35:46 +01:00
Kacpherek 27b81d9a8f Give /damage on a player its source, knockback and kill credit 2026-10-26 20:35:42 +01:00
Kacpherek 7b3595b8f2 Draw the snow golem's pumpkin and the mooshroom's mushrooms 2026-10-26 20:34:18 +01:00
Kacpherek c417556834 Send /tellraw, /title and /playsound to LAN guests 2026-10-26 20:33:36 +01:00
Kacpherek bf62ccdabc Place the bonus chest in new worlds 2026-10-26 20:33:16 +01:00
Kacpherek 41d8b9e48a Jigsaw structures: default max distance vertical and padding top like vanilla 2026-10-26 20:32:20 +01:00
Kacpherek 0e658e3e62 Make drowned throw real tridents instead of arrows 2026-10-26 20:31:21 +01:00
Kacpherek 041ecb7b7a Structures: load the template's block entity NBT into every container and sign 2026-10-26 20:31:16 +01:00
Kacpherek 41ca489d4e Move /particle count 0 particles at speed times delta 2026-10-26 20:31:14 +01:00
Kacpherek 82d2b61a4a Swing a horse's tail now and then 2026-10-26 20:30:33 +01:00
Kacpherek f9b8b40a7d Show score components in /tellraw and /title 2026-10-26 20:30:13 +01:00
Kacpherek 08cd05ed6e Make the Entity Shadows option turn shadows off 2026-10-26 20:30:05 +01:00
Kacpherek f312aae7db Name the potion, cloud or thrower in magic death messages 2026-10-26 20:29:22 +01:00
masa-masa-0314andZCode Agent 317f30427e Superflat: keep an empty layer list on Re-Create
FlatSettings::from_tag rejected layers: [], so a world with every layer removed fell back to the classic bedrock/dirt/grass settings when re-created. An empty saved list is a valid void flat generator; a non-empty list whose entries all fail to parse is still rejected.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
kohad1ckandZCode Agent 91f1f839ab Superflat: the Customize screen edits layers and presets
Create New World greyed out Customize for a Superflat world because Horizon
had no preset editor. Bring vanilla's CreateFlatWorldScreen and
PresetFlatWorldScreen:

- the layer list (top first), Remove Layer and the layer heights;
- the preset chooser with the nine visible flat_world_preset entries, their
  display items and names;
- the share box and Select, parsing layers;biome and validating block and
  biome ids against the prepared registries;
- Done keeps the layers in WorldOptions, Cancel and Escape discard them.

world_loading writes them with write_world_gen_settings_with, so a new
world's world_gen_settings keeps the custom generator; Re-Create reads them
back for the editor.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
Kacpherek 591b7b74da Predict the sort and tweak clicks on a server like an ordinary click 2026-10-26 20:15:10 +01:00
Kacpherek d78238f765 Show the enchanted hit when a melee weapon's enchantments add damage
Player.attack shows magic crit particles whenever the enchantments added
damage. Horizon showed only the critical hit, for the window's player and
for a guest's attack.
2026-10-26 20:14:32 +01:00
Kacpherek 303281ed09 Puff a poof where a dead mob's body goes
A mob removes its body twenty ticks after dying and tells everyone with
entity event 60, which draws twenty poofs about it. Horizon removed dead
mobs silently.
2026-10-26 20:12:28 +01:00
masa-masa-0314andZCode 1c6c6c0b97 Items: sample the glint from its own repeating texture
Vanilla's GlintSampler repeats enchanted_glint_item per pixel, and the
GLINT transform's scale 8 spans about 8 repeats across an item. Folding
the UV per vertex into the shared atlas could only interpolate between
the four corners, so the pattern rendered as one wide diagonal band
instead of the texture's fine weave. The glint now binds as its own
128x128 texture with a repeat, linear, mip-free sampler (the texture's
mcmeta: clamp false, blur true) in the HUD and entity pipelines, and
`Glint` passes the unwrapped `GLINT_TEXTURING` UV through.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:11:47 +08:00
Kacpherek 18f8a6868b Burst a breaking potion into item crumbs and coloured spell particles
A thrown potion or experience bottle that breaks sends level events 2002 or
2007 with its colour and the splash sound (1053, 1054). Horizon spawned only
eight terrain crumbs, on the server's own side, so a server player saw
nothing.
2026-10-26 20:09:23 +01:00
Kacpherek 43d1f4b801 Draw the swirls of potion effects around players and mobs
A client draws the particles of an entity's visible effects itself, from
the synched effect particle list (LivingEntity.tickEffects). Horizon drew
none and sent no such list: now the window's own player, a server's mobs
and players, and the mobs and guests of a game in the frame all swirl.
2026-10-26 20:06:26 +01:00
masa-masa-0314andZCode a1761dcb65 Lightning: move the bolt offset to vertex attribute 7
The retained bolt offset and the entity vertex's new glint UV were both
declared at attribute location 6, and the lightning pipeline builds its
vertex buffers from the entity layout plus the bolt-offset instance
buffer, so Device::create_render_pipeline rejected the location clash
and the client crashed at startup.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
masa-masa-0314andZCode cf6b91a710 Items: draw the enchantment glint from enchanted_glint_item
Horizon invented the glint: the GUI drew sine bands in screen space and
the entity shader layered the same over held and dropped items. Vanilla
26.3 samples `misc/enchanted_glint_item` through `GLINT_TEXTURING`
(`item.vsh`/`item.fsh`, `TextureTransform.setupGlintTexturing`), so
items now carry the real texture:

- `ItemQuad.foil_uv` keeps the model UV the glint samples; `Glint` turns
  it into the atlas UV with the 26.3 transform (translation(-o0, o1) *
  rotateZ(PI/18) * scale) and `WorldSink` gives foil quads overlay 7 and
  the per-corner glint UV.
- The GUI glint is a separate additive HUD pass masked by the item's
  texture alpha (`item.fsh`'s ALPHA_CUTOUT 0.1 and GLINT path).
- `entity.wgsl` samples the glint at overlay 7/8 like vanilla's
  `color.rgb += glintColor.rgb * glintColor.rgb`.
- The Accessibility options Glint Speed and Glint Strength work: both
  sliders persist to `options.txt` (they were hidden placeholders), the
  GUI pass takes them per draw, and `Frame.glint_alpha` feeds the entity
  shader every frame instead of whatever the last HUD item set.
- GUI items use `GLINT_TEXTURING`'s scale 8, world items
  `ENTITY_GLINT_TEXTURING`'s scale 0.5.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Kacpherek 96b538d48f Throw up landing dust where a hard fall lands
A landing that hurts sends the block's crumbs from the server
(LivingEntity.checkFallDamage). Horizon built the event but dropped it, for
the window's player and for every server player.
2026-10-26 19:59:59 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Kacpherek 4efa49ae82 Raise sprint dust under running players
A sprinting player kicks up dust of the block under their feet every tick
(Entity.spawnSprintParticle). Horizon drew none, for the window's player
or for the other players it sees.
2026-10-26 19:58:22 +01:00
Kacpherek 85db9a23a9 Use the small explosion particle when an explosion keeps blocks 2026-10-26 19:57:54 +01:00
Kacpherek 00cb813dc8 Show the missing item tooltip lines in vanilla order
Custom names are italic. Unbreakable, dyed, written book author and
generation, music disc songs, goat horn instruments, loaded crossbow
projectiles, hive bees and honey, the potion "When Applied" block and
enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency)
now appear, and advanced tooltips (F3+H) end with the item id and the
component count. Attribute lines use the attribute's sentiment colour.
2026-10-26 19:57:18 +01:00
Kacpherek 9e848670db Send swimming, gliding, fire, invisible and glowing for LAN players 2026-10-26 19:57:11 +01:00
Kacpherek dbd24e2e16 A creaking heart blown up by an explosion makes its creaking die 2026-10-26 19:57:02 +01:00
Kacpherek 5a70691d63 Show the note particle above a note block when it plays 2026-10-26 19:56:39 +01:00
Kacpherek e7e6e5bc07 Scale entity tracking range by entity-broadcast-range-percentage
The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance.
2026-10-26 19:56:22 +01:00
Kacpherek 4ebfaef0d3 Slide slowly down the sides of honey blocks 2026-10-26 19:56:07 +01:00
Kacpherek 7112d0cd64 Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison 2026-10-26 19:55:27 +01:00
Kacpherek 818b377c02 Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke 2026-10-26 19:54:32 +01:00
mahirsn 2b2aecd975 fix(client): send sign editor text like vanilla
A new editor or a removed sign closes the open editor (Minecraft.setScreen, AbstractSignEditScreen.tick), and closing sends ServerboundSignUpdatePacket with the lines it opened with when the sign is gone. Sign lines resolve key binds and translations (Component.getString) instead of keeping only plain text.
2026-10-26 21:49:15 +03:00
mahirsn 766bfbcdfa fix(client): answer the server's held slot like vanilla
ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do.
2026-10-26 21:48:55 +03:00
FusselTVandClaude Opus 5.5 61df807b33 Structures: a chunk saves its starts also when no piece reaches it
A chunk's STRUCTURE_STARTS are the starts made in it
(StructureManager.getAllStarts), whether or not one of their pieces
reaches it. own_starts filtered the chunk's references, which only hold
starts with a piece in the chunk, so a start whose pieces all lie next
door (seed 8675309's giant ruined portal in chunk -26, 23) was not saved.
The proto chunk now keeps the starts made at its position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:32:44 +01:00
FusselTVandClaude Opus 5.5 ec81bea5bf Worldgen: blocks with a block entity get vanilla's default one
WorldGenRegion.setBlock gives every block that has a block entity a new
one, so vanilla saves the sculk sensors, catalysts and shriekers of the
deep dark and potent sulfur with their block entities. Horizon only
saved block entities that a structure or feature placed data for, and
these kinds have none, so a Horizon-made world had none of them (3,035
sculk sensors, 753 catalysts, 329 shriekers and 21 potent sulfur over
three 1,024-block seeds). A finished column now adds vanilla's defaults
(block_entity_nbt::canonical) for every such block: as the modelled
block entity where Horizon has one (sculk), as raw NBT otherwise.
Potent sulfur gets its block entity type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:53 +01:00
FusselTVandClaude Opus 5.5 1eb7a04a4e End: no exit portal while nobody is in the fight
With force-loaded levels ticking without players (#1525), Horizon placed
the exit portal as soon as the End's main island was loaded. Vanilla
only places it from scanState and the dragon's death, both with players
in the fight, so a pre-generated End has no portal until someone
arrives.

test_forced_end_parity force-loads the End's arena on vanilla's and
Horizon's dedicated servers with nobody online and compares what is
around 0, 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:44 +01:00
FusselTVandClaude Opus 5.5 55a066e88a End: spike crystals are saved with the chunk that generates them
SpikeFeature.placeSpike adds each crystal to the WorldGenRegion, so a
pre-generated End holds its ten crystals before anyone ticks it.
Horizon's decoration dropped spawn_end_crystal and the game placed the
crystals later, once the arena was loaded and ticking. An End generated
with ticks frozen, as a seed generator does, had none. The decoration
region now writes the crystal like any placed entity; the game's own
placement still skips a spike that has its crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 8c2ef15171 Falling suspicious sand and gravel break where they land
BrushableBlock.tick makes the falling block entity with disableDrop, so
vanilla breaks it on landing (onBrokenAfterFall: level event 2001 and
the block_destroy game event) and
places nothing. Horizon landed it as a plain block without its
archaeology block entity. Seen in pre-generated worlds: suspicious gravel
of cold ocean ruins that sits over water fell during the first ticks and
landed one block lower without its block entity, so with no loot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 705e3d10cf Force-loaded chunks decorate in vanilla's order
Chunks no player ticket reached were decorated nearest the centre first.
Vanilla's ForceLoadCommand adds a FORCED ticket per chunk, x-outer and
z-inner, and the FULL tasks run in that order, so a chunk decorates
before its neighbour at higher x or z. Both orders agree only in the +x,
+z quarter of the map. Elsewhere features crossing chunk borders
overwrote each other the other way round: a geode next door ate a trial
chamber's dispensers, sculk or a clay pool closed a dungeon so it was
never placed, and ore, stone and tree blobs swapped.

The forceload command now ranks the chunks it adds in vanilla's order
(decoration_order::forced_order, which shares its scheduler with
vanilla_order); chunks without a rank still go last, nearest first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
Ludovic MarieandClaude Opus 5.5 edc4a58397 Merge origin/main into refactor/horizon-command
Brings #2316 (the campfire block entity fix): CI's parity checks out this
branch's head, so dispenser and block_removal need it here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:22:41 +01:00
Carlos Nunez d75f767925 Merge remote-tracking branch 'upstream/main' into tmp-resolve-2298 2026-10-26 14:13:17 -04:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

Pages sent as a component list (Hypixel through ViaVersion) were dropped
by item_data::read_text, which only took a string or a compound, so the
book opened blank. ComponentSerialization.CODEC also takes a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:43:35 +03:00
Ludovic MarieandClaude Opus 5.5 a4764f5b0d Merge origin/main into refactor/horizon-command
Brings #2212 (namespaced furnace RecipesUsed), which save_roundtrip needs:
CI's parity checks out this branch's head, not a merge with main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:36:14 +01:00
Carlos Nunez d62a1e5d20 Merge remote-tracking branch 'upstream/main' into fix/leave-game-statistic
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 13:33:28 -04:00
Carlos NunezandClaude Sonnet 5.5 1862395e4b Keep a leaving guest's play state until their statistics are saved
lan_crafting_left removed the guest's GuestPlay from host.play before
the leave_game award and save_guest_records ran, so a normal guest
disconnect awarded nothing and wrote no statistics. It now takes the
crafting table and workstation state out of the entry and leaves the
entry for HostEvent::Left to remove after saving.

New lan_e2e check `leave`: a guest leaves twice and the host's saved
statistics show leave_game 1, then 2. It fails on the previous commit
(0, then [0, 0]) and passes here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 13:32:44 -04:00
socksproxandDevin 2c8b933be4 Apply vanilla's view scale to entity render distance
Entity.shouldRenderAtSqrDistance scales the bounding box's mean edge
by 64 * viewScale, and LevelExtractor sets viewScale to the entity
distance option times clamp(renderDistance / 8, 1, 2.5). Horizon used
the option alone, so above render distance 8 entities vanished sooner
than vanilla: a chicken at 32 blocks instead of 48 at the default 12.
RemotePlayer's box also counts ten times.

Fixes #2273

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:53 +01:00
socksproxandDevin 077da2e06b Queue the first skin upload as well as re-uploads
The completed-download push into ready never marked the location queued,
so an again() before the next take_ready() put the same skin in twice:
store_skin allocated two slots, and evicting one removed the sprite while
show_skin still found the other entry — the skin never uploaded again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:09 +01:00
Devin 5589edb118 Server packs: swallow gameplay input while the reload progress shows
The progress overlay only changed drawing: a pack pushed while playing
left movement, attack and use live behind it. Input events are now
dropped while the staged reload runs (as vanilla's LoadingOverlay does),
and start_pack_reload releases held keys and mouse buttons up front so
nothing keeps acting across the frame it covers.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:26:50 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f661443fa9 Read LAN passenger fixture output through the host
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:22:50 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 442d52a038 Release notes: preserve wrapped known issues
Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-26 17:19:04 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 004ad993d9 Reject boarding a horse with an existing mob passenger
Fixes #1759

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:16:15 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 42a57b8286 Merge main and keep both recipe book regressions
Preserve the settings persistence regression alongside main's independent recipe-target check.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:15:59 +00:00
socksproxandDevin 7a3a19697a Resolve dynamic head profiles, and re-upload evicted skins
Player heads whose profile carries only a name or only a UUID
(ResolvableProfile.Dynamic) resolved to nothing, so heads some servers
send that way kept the default skin. Adds skins::Resolver
(ProfileResolver.Cached: profiles/getByName then
session/getProfileById through the services discovery document, on
background threads) used by placed heads, head items and mannequins; the
profile-derived DefaultPlayerSkin stands in until the download lands,
with the name's offline UUID keying it for name-only profiles.

A skin evicted from the atlas's 128 slots could never return: the decoded
image was dropped and show_skin simply failed. The downloader now keeps
the images (and the disk cache backs them), and again queues the upload
again where the tab list, level players, mannequins and heads notice the
missing sprite.

profile_tag keeps id so id-only and name+id profiles survive into the
item data JSON, and the player-head item fallback matches vanilla's
slim/steve (was wide/steve).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:13:22 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol de141bb7cb Fix recipe book settings persistence on the server
Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:11:51 +00:00
SenjinandClaude Opus 5.5 8526305faa HDR: mark only the sun's and moon's discs, not their whole quads
The celestial pass wrote alpha 0 over each quad, so HDR output lifted a
square of bright daytime sky around the sun. It now draws premultiplied
(one, one), which looks the same, and its alpha is how much a texel adds;
the blend takes that off the frame's alpha, so only the discs shine. The
dim End sky, drawn by the same pass, stays unmarked. fs_celestial keeps
its own blend for lightning and additive entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:04:55 +01:00
Devin 5179f97582 Server packs: show a progress screen while a pushed pack's resources reload
The download toast already matched vanilla, but once a server pack was
downloaded the whole resource reload ran synchronously on the render
thread: the frame froze on "Joining world" for seconds where vanilla's
Minecraft.reloadResourcePacks shows a LoadingOverlay with a progress bar.

The reload is split like the startup loader: a worker builds the overlay
root, models and sprites, the menu bundle and the pack's tables while a
progress screen (Preparing resources -> Loading assets -> Loading pack
tables -> Applying resources) covers the frame, then the renderer swap
and resource application land on the render thread and
ServerPacks.reloaded drives the feedback replies, including the recovery
reload on failure. The pack screen's own reload reuses the same staged
load, so its behaviour is unchanged.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:01:04 +01:00
socksproxandDevin b37528f5d6 Pause menu: server links
ClientboundServerLinksPacket (configuration and play) decoded and kept,
the pause menu gains its "Server Links..." button when a server sends
links, opening the server_links dialog screen: scrollable link buttons,
ConfirmLinkScreen on click (chatLinks/chatLinksPrompt honoured), and the
custom-screens warning with its disconnect. A dedicated server sends its
`bug-report-link` property as the BUG_REPORT link, like vanilla.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 17:51:54 +01:00
Eshayat Al-WasiuandCodex 2a98f584df Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <[email protected]>
2026-10-26 22:40:40 +06:00
Carlos NunezandClaude Sonnet 5.5 e50eb7c71f Count leave_game for every player who leaves, owner included
Vanilla's PlayerList.remove awards Stats.LEAVE_GAME to a player before
saving them, and PlayerList.removeAll does it for everyone when the
server stops. Horizon only counted it in the in-frame path, so on the
default server thread the owner never got it, and guests who left or
were removed at shutdown did not either.

LanHost::award_leave_game counts it once per guest (a guest is saved
more than once on the way out) and runs where a guest leaves, when the
integrated server stops, and when LAN closes.

Part of #2095 (item 4 of the report). The signal handling and the
remote-world disconnect are #2116's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 12:36:46 -04:00
SenjinandClaude Opus 5.5 cfd2dc8392 HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise
HDR10 is what games usually present and what the display receives: a
compositor composes in it or converts to it anyway. The NVIDIA driver
offers it on Wayland next to scRGB. A 10-bit swapchain also halves the
last pass's bandwidth; the frame before it is 8-bit, so nothing is lost.

hdr.wgsl converts the same linear light to BT.2020 (BT.2087) and encodes
it with the ST 2084 curve. HORIZON_HDR_OUTPUT=pq or scrgb allows only
that one, for comparing them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
Kacpherek c987778e97 Decorated pot: keep every quad and cull back faces like entitySolid 2026-10-26 16:34:24 +01:00
Ludovic MarieandClaude Opus 5.5 7ac90ff0b8 Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:28:40 +01:00
Kacpherek 9841ce7824 Revert "Decorated pot: draw one face of its top and bottom planes"
This reverts commit 408a75aeb3.
2026-10-26 16:27:13 +01:00
Kacpherek 83a6f5c7b9 Revert "Decorated pot: say why only the outer faces stay"
This reverts commit d338bcd070.
2026-10-26 16:27:13 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 9e71837df9 Keep server logging from panicking on a closed stdout pipe
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 14:56:15 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 5eef2bea0c Clean up evoker summons after fang integration check
Remove the test arena's surviving vexes so later checks receive no stray attacks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 14:45:59 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 02897ac731 Merge main into fix/1482-evoker-fangs
Preserve both fang and sonic-wave integration checks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 13:54:53 +00:00
Ludovic MarieandClaude Opus 5.5 0a116d9523 Split horizon-command and horizon-client-core out of horizon-client
A pure move, the first slice of splitting the client lib into crates:

- horizon-command: the command/ module (Brigadier port, vanilla tree, argument types, selectors), as is.
- horizon-client-core: what command needs from the lib, with no other dependency inside it: text, lang, snbt,
  nbt_path, component, scoreboard, bossbar, gamerules, block_entity_nbt, fastutil_order; plus items moved out of
  lib modules that sit in the cycle, unchanged: ParticleType (particles.rs), the key display name (keybinds.rs),
  SOUND_SOURCES (settings.rs), uuid_text/uuid_from_text (save.rs), Registries/RegistryIds and parse_identifier
  (command/args.rs).

horizon-client re-exports every module and item under its old path (`horizon_client::text`,
`crate::command::Registries`, `particles::ParticleType`...), so no caller changes. Inside the moved files only
paths changed (`crate::snbt` -> `horizon_client_core::snbt` in command, `crate::command::args::parse_identifier`
-> `crate::identifier::parse_identifier`...), `sign_side` became pub, lang.rs includes the lang files from
horizon-client's assets, and gamerules' test that saves a Level moved to save.rs (it needs the client's save).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:36:05 +01:00
Ludovic MarieandClaude Opus 5.5 ebf3c1567c parity: follow code into new crates when choosing harnesses
Two changes so `parity.py --changed` keeps picking the right harnesses once code moves into a crate of its own:

- A harness recorded before the move names the old paths. A changed Rust file of a crate the recorded crates
  link (their path dependencies, transitively) but don't name now counts, and the reach search decides.
  Before, such a file matched no record and selected nothing.
- A library's root opens once reached code names the crate (`horizon_world::`, `pub use horizon_command as
  command`), instead of always. Every crate root was open, so any item of a new crate whose name matched a
  name used anywhere counted. On main the selection is the same for 15 of 16 sampled files; for
  horizon-world/src/nbt.rs one harness that reached it only through a name match drops out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:34:31 +01:00
Joansitoh 2f6f0e3e0f Launcher: Game arguments edits at the caret
The field only appended and popped the last character, and Space typed
nothing on Windows (it arrives without a character). A shared one-line
field, launcher/src/line_edit.rs, now holds the text, caret, selection
and undo history: arrows, Home/End, Ctrl+arrows by word, Shift to select,
mouse click, drag, double and triple click, Ctrl+A/C/X/V and Ctrl+Z / Ctrl+Y.
2026-10-26 13:16:35 +01:00
Ludovic MarieandClaude Opus 5.5 d338bcd070 Decorated pot: say why only the outer faces stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:31 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f98528bbfc Fix evoker fangs rendering on server clients
Track fang entities and deliver their start event to the existing client renderer. Exercise warmup, animation, authoritative positions and removal in the LAN harness.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:46:18 +00:00
Kacpherek 408a75aeb3 Decorated pot: draw one face of its top and bottom planes 2026-10-26 11:45:03 +01:00
Ludovic Marie 121731f261 Merge branch 'feat/server-mods-world' into feat/server-mods-commands
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-mods/src/runtime.rs
#	docs/api/mods.md
2026-10-26 10:09:47 +01:00
Ludovic MarieandClaude Opus 5.5 574d65a03c stability.md: server mods' row names world and typed commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:08:47 +01:00
Ludovic MarieandClaude Opus 5.5 c5bb51bb05 Mod API 0.3: typed server commands, in players' command trees
A server mod's registration now lists each command's arguments: a name, a
kind (word, text, integer with bounds, number, boolean, player, block-pos)
and whether it is optional. The server adds them to the command tree every
player receives, after vanilla's commands, so a player's chat box completes
and colours them as vanilla's. When a player sends one, the server parses
the line as vanilla's parsers do (horizon-mods' commands.rs: ~ coordinates
from the player, @s/@p, integer bounds) and calls the mod's command hook
with the values by name, or answers with vanilla's error without calling
the mod.

The hub keeps the server mods' commands as a root in the commands report's
format and resends players' trees when they change (CommandTree::
packet_with); the game sets it when its mods start, stop or reload, or a
hub opens. In-frame, the window's own chat box lists them by name.

SDK: Registration::command_with(name, [Argument::...]), Argument builders,
Args (text(), integer(), player(), block_pos() ...); command(name) is free
text, an optional text argument. Examples: welcome's /mined [player],
beacons' /beacon [height] and /ground [pos], with scripts for values and
errors.

Docs: docs/mods.md (typed commands, kinds table), docs/api/mods.md and
the regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:35:09 +01:00
391 changed files with 26469 additions and 2738 deletions

No files matched your search

+8 -5
View File
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
@@ -34,7 +35,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -118,16 +121,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
Generated
+19
View File
@@ -1372,6 +1372,8 @@ dependencies = [
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
@@ -1403,6 +1405,23 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-client-core"
version = "0.1.0"
dependencies = [
"horizon-world",
"serde_json",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-protocol",
"horizon-world",
]
[[package]]
name = "horizon-core"
version = "0.1.0"
+40 -1
View File
@@ -1,6 +1,45 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask", "mods/official/recipes"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
"crates/horizon-protocol",
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
resolver = "2"
[workspace.package]
+1 -1
View File
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
horizon-world = { path = "../horizon-world" }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[lints]
workspace = true
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
Tag::Float(v.to_bits())
}
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
let Tag::IntArray(v) = tag else { return None };
let p: [i32; 3] = v.as_slice().try_into().ok()?;
((-30_000_000..30_000_000).contains(&p[0])
&& (-20_000_000..20_000_000).contains(&p[1])
&& (-30_000_000..30_000_000).contains(&p[2]))
.then_some(p)
}
/// What a block entity reads and writes, in `saveAdditional` order.
struct Fields<'a> {
input: &'a Tag,
@@ -259,7 +269,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:end_gateway" => {
f.long("Age", 0);
f.keep("exit_portal", |t| {
is_pos(t)
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
});
f.keep("exit_portal", |t| gateway_exit(t).is_some());
if flag(tag.get("ExactTeleport")) == Some(true) {
f.put("ExactTeleport", boolean(true));
}
@@ -252,7 +252,7 @@ pub fn to_text_with(
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
crate::keybinds::display_name(key)
crate::key_display::display_name(key)
} else {
Text::empty()
};
@@ -292,27 +292,4 @@ mod tests {
assert_eq!(GameRules::load(&dir), rules);
let _ = fs::remove_dir_all(dir);
}
/// The world's save (Level.write) writes the rules as they are now, and a
/// world opened again reads them back (#74).
#[test]
fn level_save_keeps_changed_rules() {
let dir =
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut level = crate::save::Level::default();
level.write(&dir).unwrap();
// Rules changed while playing, then saved and loaded again.
let mut rules = GameRules::load(&dir);
assert_eq!(rules, GameRules::default());
assert!(rules.set("advance_time", Value::Bool(false)));
assert!(rules.set("advance_weather", Value::Bool(false)));
assert!(rules.set("random_tick_speed", Value::Int(0)));
level.game_rules = rules.by_name();
level.write(&dir).unwrap();
assert_eq!(GameRules::load(&dir), rules);
let read = crate::save::Level::read(&dir).unwrap();
assert_eq!(read.game_rules, rules.by_name());
assert_eq!(read.game_rule("advance_time"), 0);
let _ = fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,18 @@
//! Resource identifiers (`Identifier`).
/// Identifier.parse: `ns:path` or `path`, validated.
pub fn parse_identifier(raw: &str) -> Option<String> {
let (ns, path) = match raw.split_once(':') {
Some(("", path)) => ("minecraft", path),
Some((ns, path)) => (ns, path),
None => ("minecraft", raw),
};
let ns_ok = ns != ".."
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
@@ -0,0 +1,38 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
@@ -42,8 +42,12 @@ struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -238,6 +242,13 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
}
#[test]
+23
View File
@@ -0,0 +1,23 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
pub mod component;
pub mod fastutil_order;
pub mod gamerules;
pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,209 @@
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
//! implementation, by registry name, and their per-type flags.
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
/// Vanilla particle types with a client implementation here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ParticleType { $($variant),* }
impl ParticleType {
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
pub fn name(self) -> &'static str {
match self { $(ParticleType::$variant => $name),* }
}
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
match name { $($name => Some(ParticleType::$variant),)* _ => None }
}
}
};
}
particle_types! {
Block = "block",
BlockCrumble = "block_crumble",
Trail = "trail",
DustPillar = "dust_pillar",
FallingDust = "falling_dust",
Smoke = "smoke",
LargeSmoke = "large_smoke",
WhiteSmoke = "white_smoke",
Flame = "flame",
SoulFireFlame = "soul_fire_flame",
CopperFireFlame = "copper_fire_flame",
SmallFlame = "small_flame",
Lava = "lava",
DrippingWater = "dripping_water",
FallingWater = "falling_water",
DrippingLava = "dripping_lava",
FallingLava = "falling_lava",
LandingLava = "landing_lava",
DrippingHoney = "dripping_honey",
FallingHoney = "falling_honey",
LandingHoney = "landing_honey",
FallingNectar = "falling_nectar",
FallingSporeBlossom = "falling_spore_blossom",
SporeBlossomAir = "spore_blossom_air",
DrippingObsidianTear = "dripping_obsidian_tear",
FallingObsidianTear = "falling_obsidian_tear",
LandingObsidianTear = "landing_obsidian_tear",
DrippingDripstoneWater = "dripping_dripstone_water",
FallingDripstoneWater = "falling_dripstone_water",
DrippingDripstoneLava = "dripping_dripstone_lava",
FallingDripstoneLava = "falling_dripstone_lava",
Splash = "splash",
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
Underwater = "underwater",
Portal = "portal",
ReversePortal = "reverse_portal",
Enchant = "enchant",
Nautilus = "nautilus",
VaultConnection = "vault_connection",
Crit = "crit",
EnchantedHit = "enchanted_hit",
DamageIndicator = "damage_indicator",
Heart = "heart",
AngryVillager = "angry_villager",
HappyVillager = "happy_villager",
Composter = "composter",
Mycelium = "mycelium",
Dolphin = "dolphin",
EggCrack = "egg_crack",
Poof = "poof",
Explosion = "explosion",
ExplosionEmitter = "explosion_emitter",
CampfireCosySmoke = "campfire_cosy_smoke",
CampfireSignalSmoke = "campfire_signal_smoke",
Note = "note",
Dust = "dust",
TotemOfUndying = "totem_of_undying",
Firework = "firework",
EndRod = "end_rod",
Ash = "ash",
WhiteAsh = "white_ash",
CrimsonSpore = "crimson_spore",
WarpedSpore = "warped_spore",
CherryLeaves = "cherry_leaves",
PaleOakLeaves = "pale_oak_leaves",
TintedLeaves = "tinted_leaves",
Effect = "effect",
InstantEffect = "instant_effect",
EntityEffect = "entity_effect",
Witch = "witch",
Infested = "infested",
Cloud = "cloud",
Sneeze = "sneeze",
Snowflake = "snowflake",
Soul = "soul",
SculkSoul = "sculk_soul",
DragonBreath = "dragon_breath",
Gust = "gust",
SmallGust = "small_gust",
GustEmitterLarge = "gust_emitter_large",
GustEmitterSmall = "gust_emitter_small",
DustColorTransition = "dust_color_transition",
SculkCharge = "sculk_charge",
SculkChargePop = "sculk_charge_pop",
Shriek = "shriek",
SonicBoom = "sonic_boom",
Vibration = "vibration",
SweepAttack = "sweep_attack",
Glow = "glow",
WaxOn = "wax_on",
WaxOff = "wax_off",
ElectricSpark = "electric_spark",
Scrape = "scrape",
SquidInk = "squid_ink",
GlowSquidInk = "glow_squid_ink",
Item = "item",
ItemSlime = "item_slime",
ItemSnowball = "item_snowball",
ItemCobweb = "item_cobweb",
SulfurCubeGoo = "sulfur_cube_goo",
Flash = "flash",
Firefly = "firefly",
TrialSpawnerDetection = "trial_spawner_detection",
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
TrialOmen = "trial_omen",
RaidOmen = "raid_omen",
OminousSpawning = "ominous_spawning",
Spit = "spit",
Fishing = "fishing",
DustPlume = "dust_plume",
RedPoplarLeaves = "red_poplar_leaves",
OrangePoplarLeaves = "orange_poplar_leaves",
YellowPoplarLeaves = "yellow_poplar_leaves",
PauseMobGrowth = "pause_mob_growth",
ResetMobGrowth = "reset_mob_growth",
BlockMarker = "block_marker",
}
impl ParticleType {
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
/// spawned regardless of distance.
pub fn override_limiter(self) -> bool {
matches!(
self,
Self::BlockMarker
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
| Self::Gust
| Self::GustEmitterLarge
| Self::GustEmitterSmall
| Self::SonicBoom
| Self::SculkCharge
| Self::SculkChargePop
| Self::Vibration
| Self::Poof
| Self::Spit
| Self::SquidInk
| Self::SweepAttack
| Self::CampfireCosySmoke
| Self::CampfireSignalSmoke
| Self::GlowSquidInk
| Self::Glow
| Self::WaxOn
| Self::WaxOff
| Self::ElectricSpark
| Self::Scrape
| Self::TrialSpawnerDetection
| Self::TrialSpawnerDetectionOminous
| Self::VaultConnection
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
@@ -0,0 +1,188 @@
//! Registry contents and source state that commands validate and complete
//! arguments against (what the server's registry access holds).
use std::collections::BTreeMap;
/// Registry contents and source state used to validate and complete arguments.
#[derive(Clone, Debug, Default)]
pub struct Registries {
/// Permission level of the command source.
pub permission: u8,
pub items: Vec<String>,
pub item_tags: Vec<String>,
/// Block id -> (property, allowed values).
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
pub block_tags: Vec<String>,
/// Entity types that `summon` can create.
pub summonable: Vec<String>,
/// Every entity type (selector `type=`).
pub entity_types: Vec<String>,
pub effects: Vec<String>,
pub enchantments: Vec<String>,
pub structures: Vec<String>,
pub biomes: Vec<String>,
pub dimensions: Vec<String>,
pub clocks: Vec<String>,
pub timelines: Vec<String>,
pub time_markers: Vec<String>,
pub advancements: Vec<String>,
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
pub tracked: Vec<String>,
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
pub functions: Vec<String>,
/// `/data ... storage` ids.
pub storages: Vec<String>,
/// Boss bar ids and datapack predicate ids.
pub bossbars: Vec<String>,
pub predicates: Vec<String>,
pub item_modifiers: Vec<String>,
pub schedules: Vec<String>,
pub stopwatches: Vec<String>,
pub recipes: Vec<String>,
pub sounds: Vec<String>,
pub damage_types: Vec<String>,
pub attributes: Vec<String>,
pub loot_tables: Vec<String>,
pub templates: Vec<String>,
pub random_sequences: Vec<String>,
pub datapacks_enabled: Vec<String>,
pub datapacks_disabled: Vec<String>,
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
/// Blocks whose default state has a block entity.
pub block_entities: std::collections::BTreeSet<String>,
/// Block tag -> member blocks.
pub block_tag_members: BTreeMap<String, Vec<String>>,
/// Data component types with a codec (not transient).
pub persistent_components: Vec<String>,
/// Recipes `/recipe` can give (not special).
pub learnable_recipes: Vec<String>,
/// Advancement id -> criterion names.
pub advancement_criteria: BTreeMap<String, Vec<String>>,
/// World clock -> time markers shown in commands.
pub clock_time_markers: BTreeMap<String, Vec<String>>,
/// Trigger objectives the source may use (`/trigger` suggestions).
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
pub struct RegistryIds {
pub ids: Vec<String>,
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.ids;
}
match registry {
"minecraft:item" => &self.items,
"minecraft:entity_type" => &self.entity_types,
"minecraft:mob_effect" => &self.effects,
"minecraft:enchantment" => &self.enchantments,
"minecraft:worldgen/structure" => &self.structures,
"minecraft:worldgen/biome" => &self.biomes,
"minecraft:world_clock" => &self.clocks,
"minecraft:timeline" => &self.timelines,
"minecraft:attribute" => &self.attributes,
"minecraft:damage_type" => &self.damage_types,
"minecraft:sound_event" => &self.sounds,
"minecraft:predicate" => &self.predicates,
"minecraft:item_modifier" => &self.item_modifiers,
"minecraft:loot_table" => &self.loot_tables,
"minecraft:recipe" => &self.recipes,
"minecraft:advancement" => &self.advancements,
_ => EMPTY,
}
}
/// The tag ids of a registry (sorted).
pub fn tags(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.tags;
}
match registry {
"minecraft:item" => &self.item_tags,
"minecraft:block" => &self.block_tags,
_ => EMPTY,
}
}
}
@@ -2,9 +2,9 @@
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
use crate::{
command::Registries,
component,
fastutil_order::{HashSetOrder, OpenOrder},
registries::Registries,
text::{self, Style, Text},
};
use horizon_world::nbt::Tag;
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
})
.collect::<Option<Vec<i32>>>(),
Tag::String(s) => {
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
}
_ => None,
};
File renamed without changes.
@@ -0,0 +1,7 @@
//! Sound sources (`SoundSource`), by name.
/// SoundSource names in vanilla order.
pub const SOUND_SOURCES: [&str; 11] = [
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
"voice", "ui",
];
@@ -67,7 +67,7 @@ pub struct Style {
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
crate::command::args::parse_identifier(v.as_str()?)
crate::identifier::parse_identifier(v.as_str()?)
}
impl Style {
pub fn color(c: u32) -> Self {
+24
View File
@@ -0,0 +1,24 @@
//! UUIDs as vanilla's four-int form, to and from their text form.
pub fn uuid_text(u: [i32; 4]) -> String {
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
+2
View File
@@ -9,6 +9,8 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
ArrowSound::TridentHit => "trident_hit",
},
volume.to_bits(),
pitch.to_bits()
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
pose,
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
@@ -142,8 +142,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",
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
let name = i.map_or("classic", |i| {
reg.names.get(i as usize).map_or("?", |n| n.as_str())
)
});
format!("minecraft:{name}")
};
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
match s {
@@ -506,6 +506,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);
@@ -792,7 +792,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!(
@@ -5,6 +5,10 @@
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
_ => BookType::Smoker,
}
.index();
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
let packet = RecipeBookChangeSettings {
book_type: i as i32,
open: op["open"].as_bool().unwrap_or(false),
filtering: op["filtering"].as_bool().unwrap_or(false),
};
let mut wire = Writer::new();
RecipeBookChangeSettings::write(&packet, &mut wire);
extra = format!(
" packet={}",
wire.data
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
book.open[decoded.book_type as usize] = decoded.open;
book.filtering[decoded.book_type as usize] = decoded.filtering;
}
"save" => {
let tag = book.write();
+137 -11
View File
@@ -35,8 +35,9 @@ enum Animate {
Campfire {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
Leaves(Option<(T, f32)>),
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,6 +50,8 @@ enum Animate {
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
}
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
_ => Animate::None,
}
}
/// `DyeColor.getMapColor` of a concrete powder colour.
fn concrete_powder_color(dye: &str) -> u32 {
match dye {
"white" => 0xFFFFFF,
"orange" => 0xD87F33,
"magenta" => 0xB24CD8,
"light_blue" => 0x6699D8,
"yellow" => 0xE5E533,
"lime" => 0x7FCC19,
"pink" => 0xF27FA5,
"gray" => 0x4C4C4C,
"light_gray" => 0x999999,
"cyan" => 0x4C7F99,
"purple" => 0x7F3FB2,
"blue" => 0x334CB2,
"brown" => 0x664C33,
"green" => 0x667F33,
"red" => 0x993333,
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
fn redstone_color(power: u8) -> [f32; 3] {
let f = f32::from(power) / 15.0;
@@ -504,6 +543,53 @@ fn try_spawn_drip(
}
}
}
/// `BeehiveBlock.trySpawnDripParticles`.
fn beehive_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
return;
}
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
return;
};
if top < 1.0
|| tables
.impermeable
.get(state.0 as usize)
.copied()
.unwrap_or(false)
{
return;
}
let y = if bottom > 0.0 {
f64::from(pos[1]) + bottom - 0.05
} else {
let below = [pos[0], pos[1] - 1, pos[2]];
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
if (below_top < 1.0 || !ctx.sturdy(below))
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
{
f64::from(pos[1]) - 0.05
} else {
return;
}
};
let r = engine.random();
let (a, b) = (r.next_double(), r.next_double());
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
let p = [
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
y,
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -685,7 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -705,7 +791,7 @@ fn block_animate_tick(
let r = engine.random();
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
let options = if ty == T::TintedLeaves {
let c = ctx.foliage(pos);
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
} else {
@@ -977,6 +1063,13 @@ fn block_animate_tick(
}
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
let attempts = next_int(engine.random(), 1) + 1;
for _ in 0..attempts {
beehive_drip(tables, engine, ctx, state, pos);
}
}
Animate::RedstoneOre => {
for d in [
[0, -1, 0],
@@ -1209,21 +1302,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
}
add(engine, T::SmallFlame, p, [0.0; 3]);
}
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
/// which of a campfire's four slots hold an item.
pub fn campfire_particles<'a>(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
) {
for &(pos, state) in blocks {
if !tables.is_lit_campfire(state) {
continue;
}
let signal = ctx
let properties = ctx
.registry
.state(BlockStateId(state))
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
.map(|s| &s.properties);
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
let signal = prop("signal_fire") == Some("true");
// Direction.get2DDataValue: south, west, north, east.
let rotation = match prop("facing") {
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let r = engine.random();
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
@@ -1231,6 +1335,28 @@ pub fn campfire_particles<'a>(
make_campfire_smoke(engine, pos, signal, false);
}
}
// The smoke over each cooking item, at the slot's corner of the fire.
let occupied = cooking(pos);
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
if engine.random().next_float() >= 0.2 {
continue;
}
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
let (step, clockwise) = match (slot + rotation) % 4 {
0 => ([0.0, 1.0], [-1.0, 0.0]),
1 => ([-1.0, 0.0], [0.0, -1.0]),
2 => ([0.0, -1.0], [1.0, 0.0]),
_ => ([1.0, 0.0], [0.0, 1.0]),
};
let at = [
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
];
for _ in 0..4 {
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
+47 -4
View File
@@ -9,7 +9,7 @@ use crate::{
collisions::SceneCollisions,
mobs::Who,
motion::{FluidResources, LAVA, WATER},
projectiles::{Hit, Projectile},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
@@ -132,6 +132,10 @@ pub enum ArrowSound {
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
TridentHitGround,
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
TridentHit,
}
/// What a burning arrow does to the block it hits (`onProjectileHit`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -169,6 +173,9 @@ pub struct ArrowState {
pub was_eye_in_water: bool,
pub first_tick: bool,
pub has_been_shot: bool,
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
/// for over 4 ticks) and hits no more.
pub dealt_damage: bool,
pub removed: bool,
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
@@ -211,6 +218,7 @@ impl ArrowState {
was_eye_in_water: false,
first_tick: true,
has_been_shot: false,
dealt_damage: false,
removed: false,
random,
events: Vec::new(),
@@ -451,12 +459,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
let Some(mut st) = p.arrow.take() else {
return false;
};
let trident = matches!(p.kind, ProjectileKind::Trident);
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
EntityHit::Hurt => {
ticker.hit_sound();
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
@@ -501,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
p.z,
))
}
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
/// inertia, sounds and hit).
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
}
let physics = !self.st.no_physics;
let movement = self.p.velocity;
let block_pos = self.block_position();
@@ -549,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
self.st.in_ground_time = 0;
let original = self.position();
if self.st.was_touching_water {
// getWaterInertia, then the bubbles behind it.
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
let v = self.p.velocity;
for _ in 0..4 {
self.st.events.push(ArrowEvent::Particle {
@@ -687,6 +707,8 @@ impl Ticker<'_, '_, '_, '_> {
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
// ThrownTrident.findHitEntity: none once it has dealt damage.
let targets = if self.st.dealt_damage { &[] } else { targets };
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
@@ -757,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else {
ArrowSound::HitGround
};
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
sound,
volume: 1.0,
pitch,
});
}
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
/// trident keeps flying (it has dealt its damage) and falls.
fn trident_bounce(&mut self) {
self.st.dealt_damage = true;
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::TridentHit,
volume: 1.0,
pitch: 1.0,
});
// ProjectileDeflection.REVERSE.
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
self.p.yaw += rotation;
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
+1 -1
View File
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
+48
View File
@@ -450,6 +450,54 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
@@ -5,7 +5,11 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{hud::Rect, mesher::Vertex, world::World};
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -149,6 +153,8 @@ pub fn draw_part(
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -648,6 +654,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
layer: 0,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -734,7 +742,7 @@ pub struct Scene<'a> {
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1286,11 +1294,12 @@ impl BlockEntityRenderer {
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
let skin = if *kind == SkullType::Player {
(scene.skull)(pos)
} else {
texture
None
};
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1385,6 +1394,7 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1402,6 +1412,11 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1714,6 +1729,8 @@ impl BeamPart {
layer: 6,
color,
light: self.wrap,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1784,6 +1801,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
layer,
color: 0,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2108,4 +2126,70 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
/// z-fight: the pot renders wrong.
#[test]
fn the_decorated_pot_culls_its_back_faces() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
let state = registry.default_state("minecraft:decorated_pot").unwrap();
let sprites: HashMap<String, Rect> = [
"entity/decorated_pot/decorated_pot_base",
"entity/decorated_pot/decorated_pot_side",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.25,
v0: 0.125,
u1: 0.375,
v1: 0.25,
w: 16,
h: 16,
},
)
})
.collect();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
assert_eq!(
verts.len() / 4,
28,
"the pot submits all of vanilla's quads"
);
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
14200723, 8365974,
];
/// The 12 edges of the unit cube (outline data predating the export).
fn cube_edges() -> Vec<[f64; 6]> {
pub fn cube_edges() -> Vec<[f64; 6]> {
let mut edges = Vec::new();
for a in 0..3 {
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
+2
View File
@@ -49,6 +49,8 @@ pub fn geometry(
color: 0xffff_ffff,
light: stage_light(stage),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
+48 -8
View File
@@ -1,6 +1,7 @@
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
//! overrides on it.
use crate::item_data::BundleSelected;
use crate::items::{ItemStack, Items};
use horizon_world::nbt::Tag;
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
pub struct Contents {
items: Vec<ItemStack>,
weight: Weight,
/// `selectedItem`: an index into `items`, -1 for none.
selected: i32,
}
impl Contents {
@@ -104,9 +107,11 @@ impl Contents {
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
let inside = contents_of(items, stack)?.to_vec();
let weight = contents_weight(items, &inside);
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
Some(Self {
items: inside,
weight,
selected,
})
}
@@ -154,18 +159,26 @@ impl Contents {
amount
}
/// `removeOne`: the first stack (Horizon keeps no selected item).
/// `removeOne`: the selected stack, else the first; nothing is selected after.
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
if self.items.is_empty() {
return None;
}
let stack = self.items.remove(0);
let at =
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
let stack = self.items.remove(at);
self.weight = self
.weight
.sub(item_weight(items, &stack).times(stack.count));
self.selected = -1;
Some(stack)
}
/// `getSelectedItemIndex`.
pub fn selected(&self) -> i32 {
self.selected
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
@@ -200,11 +213,28 @@ impl Contents {
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
let data = bundle.data_mut();
data.bundle_contents = self.items;
data.bundle_selected = BundleSelected(self.selected);
bundle.tidy();
}
}
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
/// selected one already or outside the contents.
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
let Some(data) = bundle.extra.as_deref_mut() else {
return;
};
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
let selected = if data.bundle_selected.0 != index && inside {
index
} else {
-1
};
data.bundle_selected = BundleSelected(selected);
}
#[cfg(test)]
mod tests {
use super::Weight;
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
/// Each slot's top-left and the stack in it, in drawing order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The position in `slots` of the selected item, which is drawn highlighted.
pub selected: Option<usize>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
selected: None,
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut selected = None;
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
} else if slot < shown.len() {
// `extractSlot`: the cell of slot number n holds item `size - n`.
let index = shown.len() - 1 - slot;
if usize::try_from(contents.selected()) == Ok(index) {
selected = Some(slots.len());
}
slots.push((at, shown[index].clone()));
slot += 1;
}
}
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
selected,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
@@ -349,8 +389,8 @@ mod tooltip_tests {
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
// the bottom-right cell, the first in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
+11 -8
View File
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
}
}
impl crate::light::LightWorld for ChunkMap {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
self.columns.state(x, y, z)
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
self.columns.light(x, y, z)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
self.columns.set_light(x, y, z, packed);
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);
}
}
@@ -106,7 +103,13 @@ mod tests {
for column in map.values_mut() {
Arc::make_mut(column).light_loaded = true;
}
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
crate::light::LightWorld::set_section_light(
&mut map,
0,
0,
0,
crate::chunk::Light::Uniform(0),
);
assert_eq!(inserted, map.membership_revision());
map.remove(&(0, 0));
map.insert((1, 0), chunk);
+2
View File
@@ -179,6 +179,8 @@ impl Mesh {
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
+141 -5
View File
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
}
// Vanilla scopes this direction to Entity.move while a piston pushes it.
thread_local! {
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
}
/// Suppress the moving part of pistons moving in this direction during a push.
pub fn with_piston_noclip<T>(
direction: horizon_core::geometry::Direction,
f: impl FnOnce() -> T,
) -> T {
struct Reset(Option<horizon_core::geometry::Direction>);
impl Drop for Reset {
fn drop(&mut self) {
PISTON_NOCLIP.with(|value| value.set(self.0));
}
}
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
let _reset = Reset(previous);
f()
}
pub struct SceneCollisions<'w, 'r> {
pub world: &'w World,
pub binding: &'w BlockCollisionRegistry<'r>,
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
impl<'a, W> EntityCollisions<'a, W>
where
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
{
/// `EntityCollisions` for a body the world border also stops: the border
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
Self {
blocks,
entities: &[],
border: Some((border, at)),
}
}
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
/// with a border, at the world border's walls.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement,
max_step,
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.map(Some)
.map_err(io::Error::other)
}
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
}
}
/// A collision world without blocks: no chunk is resident, so only the
/// world border stops a body.
pub struct EmptyCollisions;
impl BlockCollisionWorld for EmptyCollisions {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
_: CollisionBlock,
_: BlockPos,
_: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
impl EntityMovementWorld for EmptyCollisions {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
Ok(false)
}
}
#[cfg(test)]
mod piston_noclip_tests {
use super::*;
use horizon_core::geometry::Direction;
#[test]
fn piston_noclip_is_scoped_and_restored_after_unwind() {
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
with_piston_noclip(Direction::East, || {
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
let _ =
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
});
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
}
}
#[cfg(test)]
mod border_movement_tests {
use super::*;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
/// a body inside close to the border collides with the border's walls.
#[test]
fn a_body_next_to_the_border_stops_at_its_wall() {
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
// The wall stops the body 0.4 blocks short of the whole move.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole way: {moved:?}"
);
}
}
@@ -124,6 +124,18 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -301,4 +313,35 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+31 -1
View File
@@ -98,10 +98,19 @@ pub enum BlockEntity {
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
@@ -190,6 +199,9 @@ impl Skull {
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
@@ -198,10 +210,21 @@ impl SkullSkin {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
Self {
download,
dynamic,
fallback,
}
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
@@ -366,6 +389,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -418,6 +444,7 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
}
@@ -436,6 +463,7 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -456,6 +484,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
@@ -480,6 +509,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
+26
View File
@@ -545,6 +545,32 @@ impl Enchantments {
}
out
}
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
/// (attribute, id, amount, operation, slot group), one per slot group the
/// enchantment lists.
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
let mut rows = Vec::new();
for (id, level) in stack.enchantments() {
let Some(e) = self.get(id) else { continue };
for a in e.effects["minecraft:attributes"]
.as_array()
.into_iter()
.flatten()
{
for slot in &e.slots {
rows.push((
a["attribute"].as_str().unwrap_or("").to_owned(),
a["id"].as_str().unwrap_or("").to_owned(),
f64::from(level_value(&a["amount"], *level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
slot.clone(),
));
}
}
}
rows
}
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
stack.map_or(0.0, |s| {
+71 -17
View File
@@ -14,7 +14,8 @@ struct Globals {
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
// w: the glint scroll clock (`Glint::scroll_ticks`).
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
@@ -27,6 +28,12 @@ struct Globals {
@group(0) @binding(2) var atlas_sampler: sampler;
@group(0) @binding(3) var lightmap: texture_2d<f32>;
@group(0) @binding(4) var lightmap_sampler: sampler;
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
@group(0) @binding(6) var glint_sampler: sampler;
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
// the same repeating, linearly filtered sampler as the item glint.
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -44,6 +51,11 @@ struct Out {
// entity.vsh's lightMapColor, multiplied in after the overlay.
@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).
@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,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -92,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
if normal.w != 0.0 {
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -103,18 +118,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
out.glint_uv = glint_uv;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(6) offset: vec3<f32>) -> Out {
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
@@ -166,6 +186,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
// common period of both offsets.
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
let ticks = globals.time.w;
let offset0 = (ticks % 110000.0) / 110000.0;
let offset1 = (ticks % 30000.0) / 30000.0;
let angle = 0.17453292;
let c = cos(angle);
let s = sin(angle);
let scaled = model * 0.16;
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
@@ -198,30 +239,43 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 { discard; }
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
let t = globals.misc.y * 0.05;
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
// 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.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -186,6 +186,8 @@ mod tests {
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
+455 -40
View File
@@ -88,6 +88,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.
@@ -180,7 +185,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
map.insert(key.into(), Tag::Byte(1));
@@ -188,6 +192,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
);
// Keys of older formats that structure templates still carry.
for key in [
"HandItems",
"ArmorItems",
"HandDropChances",
"ArmorDropChances",
"Dimension",
"HurtByTimestamp",
"Leashed",
] {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
@@ -197,7 +221,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("LeftHanded", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -206,26 +230,65 @@ 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" && 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.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
let species = m.species();
if let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
map.insert("variant".into(), string(&variant));
}
if species == crate::mobs::Species::Chicken {
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
}
let sounds = crate::mobs::finalize::sound_variants(species);
if !sounds.is_empty() {
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
let sound = sound.copied().unwrap_or("classic");
map.insert(
"sound_variant".into(),
string(&format!("minecraft:{sound}")),
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
"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(key.into()).or_insert(value);
}
}
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
@@ -241,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:wolf" => {
if let Some(owner) = m.extra.owner {
@@ -254,12 +313,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
map.remove(&"CannotHunt".into());
}
map.insert(
"IsImmuneToZombification".into(),
Tag::Byte(i8::from(m.extra.flag2)),
@@ -288,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -394,6 +457,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));
@@ -498,8 +563,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
)])
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
for (key, value) in [
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -532,6 +596,76 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
if matches!(
id,
"minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:piglin"
| "minecraft:zoglin"
) {
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.
@@ -614,6 +748,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
tag
}
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
/// finalizeSpawn set, over the saved list.
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
use crate::mobs::finalize::{self, Attr, Op};
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
for attr in Attr::ALL {
let modifiers: Vec<Tag> = a
.modifiers
.iter()
.filter(|x| x.attr == attr)
.map(|x| {
let op = match x.op {
Op::AddValue => "add_value",
Op::AddMultipliedBase => "add_multiplied_base",
Op::AddMultipliedTotal => "add_multiplied_total",
};
Tag::compound([
("id", string(x.id)),
("amount", Tag::Double(x.amount.to_bits())),
("operation", string(op)),
])
})
.collect();
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
continue;
}
let id = format!("minecraft:{}", attr.name());
let mut entry = vec![
("id", string(&id)),
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
];
if !modifiers.is_empty() {
entry.push(("modifiers", Tag::List(modifiers)));
}
let entry = Tag::compound(entry);
match list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
{
Some(t) => *t = entry,
None => list.push(entry),
}
}
map.insert("attributes".into(), Tag::List(list));
}
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
if m.extra.memories.is_empty() {
return;
}
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
("pos", Tag::IntArray(p.to_vec())),
]),
None => continue,
},
_ => Tag::Int(value as i32),
};
let mut memory = vec![("value", value)];
if ttl != i64::MAX {
memory.push(("ttl", Tag::Long(ttl)));
}
memories.insert(
format!("minecraft:{name}").as_str().into(),
Tag::compound(memory),
);
}
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::Compound(memories))]),
);
}
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
fn custom_name(tag: &Tag) -> Option<String> {
match tag.get("CustomName")? {
@@ -633,6 +850,22 @@ pub fn name_text(tag: &Tag) -> Text {
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
return None;
};
let range = attrs
.iter()
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
return None;
};
let bonus = modifiers
.iter()
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -658,7 +891,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
}
if let Some(bonus) = spawn_bonus(tag) {
use crate::mobs::finalize::{Attr, Op, add_modifier};
let id = "minecraft:random_spawn_bonus";
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -699,7 +943,17 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.left_handed = flag(tag, "LeftHanded");
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");
@@ -793,20 +1047,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -817,14 +1096,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
.iter()
.position(|(n, _)| *n == v)
.unwrap_or(0) as u8;
}
}
if matches!(
m.species(),
@@ -2425,6 +2696,150 @@ mod sheep_nbt_tests {
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
#[test]
fn farm_animals_keep_their_variant_and_sound_variant() {
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 mut m = Mob::new(
1,
types["minecraft:pig"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
);
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:classic")
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
types["minecraft:chicken"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
let registries = finalize::SpawnRegistries::load(&root);
for (species, registry) in [
(Species::Pig, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.wolf_sound),
] {
assert_eq!(
finalize::sound_variants(species),
registry.names,
"{species:?}"
);
}
}
#[test]
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_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);
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 new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
};
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
assert_eq!(number(tag.get("RabbitType")), Some(4));
assert_eq!(back.extra.variant, 4);
let mut goat = new("minecraft:goat");
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
let (back, tag) = round_trip(&goat);
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
assert_eq!(
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
[false; 2]
);
}
}
#[cfg(test)]
@@ -35,6 +35,18 @@ impl Damage {
..Self::default()
}
}
/// A block that starts to fall: anvils hurt, and a brushable block
/// breaks where it lands (BrushableBlock.tick: disableDrop).
pub fn falling(block: &str) -> Self {
if block.ends_with("anvil") {
Self::anvil()
} else {
Self {
cancel_drop: block.starts_with("minecraft:suspicious_"),
..Self::default()
}
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_brushable_blocks_break_where_they_land() {
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
}
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
+2
View File
@@ -48,6 +48,8 @@ pub fn flames(
color: 0xffff_ffff,
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
remaining -= 0.45;
+381 -52
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod bundles;
mod campfire;
mod campfire_douse;
mod cartography;
@@ -132,6 +133,7 @@ mod persist;
mod perspective;
mod pickups;
mod piglins;
mod piston_push;
mod player_equip;
mod postprocess;
mod potions;
@@ -156,6 +158,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -375,7 +378,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -430,6 +433,16 @@ impl Options {
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
// world that exists opens; else the title screen. --world also creates one.
"--quickPlaySingleplayer" => {
let folder = std::env::current_dir()?.join("saves").join(&value);
if folder.join("level.dat").is_file() {
o.world = Some(value);
} else {
eprintln!("Quick play: there is no world {value} in saves/");
}
}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
@@ -440,7 +453,7 @@ impl Options {
}
o.benchmark = Some(value);
}
"--server" => o.server = Some(value),
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
"--name" => o.player_name = Some(value),
"--session-file" => session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
@@ -497,6 +510,12 @@ enum Screen {
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
ServerLinks,
/// ConfirmLinkScreen for a server link (game/server_links.rs).
ConfirmLink,
/// DialogScreen.WarningScreen: the note about custom screens.
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
@@ -1265,6 +1284,8 @@ struct Game<'a> {
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
last_inv_click: Option<(std::time::Instant, usize, bool)>,
/// `lastQuickMoved`: what the last Shift click moved, which a Shift
@@ -1432,6 +1453,10 @@ struct Game<'a> {
titles: titles::Titles,
/// `Hud.toolHighlightTimer`: the selected item name.
tool_highlight: horizon_client::hud_overlays::ToolHighlight,
/// `Hud.autosaveIndicatorValue`.
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
autosaving: bool,
applied_modifiers: Vec<(String, String)>,
experience: Experience,
orbs: Vec<ExperienceOrb>,
@@ -1545,6 +1570,9 @@ struct Game<'a> {
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
/// and mannequins) resolve through the services in the background.
skin_profiles: RefCell<horizon_client::skins::Resolver>,
skin_slots: Vec<(String, usize)>,
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
trim_slots: Vec<(String, usize)>,
@@ -1658,12 +1686,13 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
lan_global_events: Vec<(i32, [i32; 3], i32)>,
/// `ContainerOpenersCounter`: how many players (the host and the guests)
@@ -1681,6 +1710,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -1715,6 +1747,8 @@ struct Game<'a> {
restore_player_state: bool,
stats_ui: stats_screen::StatsUi,
adv_ui: advancements_screen::AdvUi,
/// The Server Links screens' state (game/server_links.rs).
links_ui: server_links::LinksUi,
camera_type: perspective::CameraType,
fangs: fangs::FangState,
/// The local player as a model (third person, the inventory).
@@ -1811,9 +1845,46 @@ impl Game<'_> {
fn reach(&self) -> f64 {
if self.creative { 5.0 } else { 4.5 }
}
/// Outline shapes that depend on more than the block state, as one box
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
/// block while the main hand holds the block's own item
/// (`CollisionContext.isHoldingItem`), and `ShulkerBoxBlock.getShape`
/// grows with the lid (`ShulkerBoxBlockEntity.getBoundingBox`).
fn context_outline(&self, pos: [i32; 3], state: BlockStateId) -> Option<[f64; 6]> {
let name = &self.res.facts().get(state).name;
if matches!(&**name, "minecraft:light" | "minecraft:scaffolding") {
return self
.held()
.is_some_and(|s| *s.item == **name)
.then_some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
}
if !name.ends_with("shulker_box") {
return None;
}
let progress = self.be_anims.shulkers.get(&pos).map_or(0.0, |s| s.progress);
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
let d = facing_offset(facing.as_deref().unwrap_or("up"));
let mut b = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
for a in 0..3 {
match d[a] {
1 => b[a + 3] += 0.5 * f64::from(progress),
-1 => b[a] -= 0.5 * f64::from(progress),
_ => {}
}
}
Some(b)
}
/// A block's outline boxes in world space (`getShape` plus the random offset).
fn outline_boxes(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if let Some(b) = self.context_outline(pos, state) {
return vec![std::array::from_fn(|i| f64::from(pos[i % 3]) + b[i])];
}
let f = self.res.facts().get(state);
let o = if f.outline_offset {
self.pack
@@ -2108,6 +2179,33 @@ impl Game<'_> {
}
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
/// 0's `colorAsTerrainParticle` (white when the block has none).
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
/// particles at the entity.
fn honey_slide_particles(&mut self, at: [f64; 3]) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let Some(honey) = self
.pack
.assets
.registry
.default_state("minecraft:honey_block")
else {
return;
};
let block = [
at[0].floor() as i32,
at[1].floor() as i32,
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
for _ in 0..5 {
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
at,
[0.0; 3],
);
}
}
}
fn particle_sprite(&self, state: BlockStateId, pos: [i32; 3]) -> Option<(String, u32)> {
let sprite = Self::block_particle_sprite(&self.pack, state)?;
let chunks = &self.world.chunks;
@@ -2325,11 +2423,18 @@ impl Game<'_> {
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
];
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
if thrown_from_hand && self.lan_acting.is_none() {
let uuid = self.level.ensure_player_uuid();
if thrown_from_hand {
let uuid = self.acting_player_uuid();
self.dropped.last_mut().unwrap().thrower = Some(uuid);
}
}
/// The UUID of the player acting: the guest whose turn it is, else the host.
pub(super) fn acting_player_uuid(&mut self) -> [i32; 4] {
match self.lan_acting_uuid {
Some(guest) => horizon_client::lan::playerdata::uuid_ints(guest),
None => self.level.ensure_player_uuid(),
}
}
fn tick_items(&mut self) {
let mut items = std::mem::take(&mut self.dropped);
// ServerLevel.tick: only entities in entity-ticking chunks tick.
@@ -2826,20 +2931,29 @@ impl Game<'_> {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
self.process_level_events()
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
fn sync_moving_pistons(&mut self) {
let pistons = &mut self.world.moving_pistons;
pistons.clear();
pistons.extend(
self.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
);
let previous = std::mem::take(&mut self.world.moving_pistons);
let current: Vec<_> = self
.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
.collect();
self.world.moving_piston_fallback = previous
.into_iter()
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
.collect();
self.world.moving_pistons = current;
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
/// the side effects of each (a shrieker summoning a warden, drops, game
@@ -2939,6 +3053,17 @@ impl Game<'_> {
pos,
data,
} => self.smash_dust(pos, data),
// LevelEventHandler 2002 and 2007: a potion's splash.
LevelEvent::LevelEvent {
id: 2002,
pos,
data,
} => self.potion_splash_particles(pos, data, false),
LevelEvent::LevelEvent {
id: 2007,
pos,
data,
} => self.potion_splash_particles(pos, data, true),
LevelEvent::LevelEvent {
id: id @ 3002..=3005,
pos,
@@ -2974,6 +3099,8 @@ impl Game<'_> {
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
// LevelEvent.SOUND_SPLASH_POTION and its instant twin.
1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)),
_ => None,
};
if let Some((name, volume)) = sound {
@@ -2983,7 +3110,7 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
1029 | 1030 | 1042 | 1043 | 1044 => {
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
1019 | 1021 => {
@@ -2993,7 +3120,7 @@ impl Game<'_> {
}
_ => 1.0,
};
if matches!(id, 1019 | 1021 | 1029 | 1030) {
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
// Clients play the anvil sound from its level event;
// capturing it also sends a duplicate sound packet.
self.audio.play_unshared(name, c, volume, pitch);
@@ -3042,11 +3169,9 @@ impl Game<'_> {
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage: if self.res.facts().get(state).name.ends_with("anvil") {
horizon_client::falling_block::Damage::anvil()
} else {
Default::default()
},
damage: horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
),
saved: None,
});
}
@@ -3149,6 +3274,31 @@ impl Game<'_> {
LevelEvent::BlockEntityEvent { pos, a, b, .. } => {
self.block_entity_event([pos.x, pos.y, pos.z], a, b)
}
// NoteBlock.triggerEvent: a NOTE particle above, coloured by
// the note (`note / 24.0` in the x speed).
LevelEvent::BlockEvent {
pos,
state,
kind: "note_particle",
} => {
use horizon_client::particles::{ParticleOptions, ParticleType};
let note = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("note")?.parse::<f64>().ok())
.unwrap_or(0.0);
self.particles.add(
&ParticleOptions::simple(ParticleType::Note),
[
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 1.2,
f64::from(pos.z) + 0.5,
],
[note / 24.0, 0.0, 0.0],
);
}
LevelEvent::GrowTree { .. }
| LevelEvent::PlaceFeature { .. }
| LevelEvent::BlockEvent { .. } => {}
@@ -3524,7 +3674,7 @@ impl Game<'_> {
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
self.creaking_heart_broken(pos);
self.creaking_heart_broken(pos, true);
let hive = match self.world.block_entities.get(&key) {
Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)),
_ => None,
@@ -4083,6 +4233,7 @@ impl Game<'_> {
if &*stack.item == "minecraft:spyglass" {
// SpyglassItem.USE_DURATION, then finishUsingItem.
if ticks + 1 >= 1200 {
self.send_use_item_complete();
self.stop_spyglass();
self.using = None;
} else {
@@ -4104,6 +4255,7 @@ impl Game<'_> {
}
if ticks >= duration {
self.using = None;
self.send_use_item_complete();
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
@@ -4316,6 +4468,12 @@ impl Game<'_> {
// The world's spawn from the start, not only once saved: a bed set
// before the first save must not become it.
self.level.world_spawn.get_or_insert([x, y, z]);
// The "Bonus Chest" create-world option, placed once with the spawn.
if horizon_client::save::WorldGen::read(&self.world_root)
.is_some_and(|g| g.bonus_chest)
{
self.place_bonus_chest([x, y, z])?;
}
}
let s = self
.level
@@ -4419,7 +4577,11 @@ impl Game<'_> {
self.world.weather.tick_levels();
self.tick_weather_effects();
}
if self.ticks.is_multiple_of(6000) {
self.autosaving = self.ticks.is_multiple_of(6000);
if let Some(serving) = &self.serving {
serving.set_saving(self.autosaving);
}
if self.autosaving {
self.save_game_with(false)?;
}
if running {
@@ -4630,9 +4792,32 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
position,
count,
speed,
}) => self.landing_dust(None, state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::HoneySlide {
sound, particles, ..
} => {
if sound {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
if particles {
self.honey_slide_particles(feet);
// The server's event 53 reaches the guests too.
let id = self.lan.as_ref().map(|h| h.host_entity);
if let Some(id) = id {
self.lan_entity_event(id, 53, feet);
}
}
}
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
@@ -4735,6 +4920,7 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.share_spawn_conditions();
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -4742,6 +4928,7 @@ impl Game<'_> {
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
self.cooldowns.tick();
horizon_client::spans::time("tick_using", || self.tick_using());
self.tick_bundle_hover();
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
// ticks; ServerLevel.tick then runs the scheduled and random block
@@ -4944,6 +5131,9 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
| Screen::CommandBlock
) || self.book.is_some()
|| self.win_screen_pauses())
@@ -4965,10 +5155,13 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
// The container screen's press state belongs to the screen: a screen
// that takes the pointer over starts with its own state, as
// `AbstractContainerScreen` does, so a press whose release never
// arrived (the pointer left the window) cannot wedge the next
// screen's quick move and clicks.
self.reset_inventory_clicks();
if !capture {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -5427,6 +5620,9 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
@@ -5439,6 +5635,7 @@ impl Game<'_> {
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_saving_indicator(&mut batch);
self.draw_toasts(&mut batch);
self.draw_pack_toasts(&mut batch);
self.remote_hud(&mut batch);
@@ -5495,6 +5692,7 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_),
@@ -5767,6 +5965,7 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_),
@@ -5813,12 +6012,14 @@ impl Game<'_> {
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks = Default::default();
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
@@ -5939,6 +6140,12 @@ impl Game<'_> {
/// Several clicks on one menu instance (a quick craft drag is three).
fn menu_ops(&mut self, ops: &[(Option<usize>, i32, horizon_client::menu::Input)]) -> bool {
use horizon_client::menu::Input;
// BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection.
for &(slot, _, input) in ops {
if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) {
self.unselect_bundle(slot);
}
}
if self.remote.is_some() && self.screen == Screen::Inventory {
// On a server the clicks are the server's: predicted and sent.
return self.remote_menu_ops(ops);
@@ -7264,7 +7471,12 @@ impl Game<'_> {
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
let shadow_sprite = self
.settings
.entity_shadows
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
let darken = self.sky_darken();
let a = alpha;
@@ -7442,6 +7654,8 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -7663,7 +7877,7 @@ impl Game<'_> {
/// strength and whether it is a critical hit): the weapon and effects
/// scaled by the attack strength, a mace's smash, the critical hit, then
/// enchantments.
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) {
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
// Riptide's spin replaces the attack damage attribute.
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
@@ -7691,8 +7905,9 @@ impl Game<'_> {
amount *= 1.5;
}
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
amount += self.enchant_damage_bonus(target_kind) * scale;
(amount, scale, crit)
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
amount += magic_boost;
(amount, scale, crit, magic_boost > 0.0)
}
/// `Player.getAttackStrengthScale(0.5)`.
fn attack_strength_scale(&self) -> f32 {
@@ -7879,7 +8094,7 @@ impl Game<'_> {
pub(super) fn attack_mob(&mut self, index: usize) {
let target_kind = self.mobs[index].kind.id.clone();
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
let (amount, scale, crit) = self.melee_damage(&target_kind);
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
@@ -7942,6 +8157,18 @@ impl Game<'_> {
feet,
width,
height,
false,
);
}
// Player.attackVisualEffects: magicCrit when the enchantments added damage.
if magic {
self.critical_hit(
self.lan_acting,
self.mobs[index].id as i32,
feet,
width,
height,
true,
);
}
if actual > 2.0 {
@@ -8064,6 +8291,8 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
};
@@ -8170,6 +8399,7 @@ impl Game<'_> {
);
self.session_lock = Some(session_lock);
self.end.set_locator(locator);
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
@@ -8308,6 +8538,7 @@ impl Game<'_> {
seed,
)?;
self.end.set_locator(locator);
self.share_spawn_conditions();
Ok((streaming, save))
}
/// Change dimension through a portal (Entity.changeDimension with
@@ -9603,7 +9834,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
self.player_projectile_shot();
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
self.audio
@@ -10647,6 +10878,21 @@ impl Game<'_> {
}
return Ok(false);
}
// A server pack's reload covers the frame with its progress
// (`LoadingOverlay`) until `tick_server_packs` applies it.
if let Some(reload) = &self.server_packs.reload {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
horizon_client::level_loading::draw_progress(
&mut hud,
&self.gui.atlas,
reload.progress.line(),
reload.progress.fraction(),
);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
let Some(size) = self
.renderer
.as_ref()
@@ -10792,6 +11038,17 @@ impl Game<'_> {
[0.0; 3]
};
let p = h.pos.map(f64::from);
if let Some(b) = self.context_outline(h.pos, state) {
return horizon_client::blocks::cube_edges()
.iter()
.map(|e| {
std::array::from_fn(|i| {
let k = i % 3;
p[k] + b[k] + e[i] * (b[k + 3] - b[k])
})
})
.collect();
}
f.outline_edges
.iter()
.map(|e| std::array::from_fn(|i| p[i % 3] + o[i % 3] + e[i]))
@@ -10956,6 +11213,8 @@ impl Game<'_> {
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
hud: &hud,
glint_alpha: self.settings.file.double("glintStrength") as f32,
glint_ticks: self.glint_ticks(),
clouds: (!title
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
@@ -11183,6 +11442,10 @@ impl Game<'_> {
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::Dead => {}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
Screen::Advancements => self.close_advancements(),
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
@@ -11251,8 +11514,10 @@ impl Game<'_> {
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
// (the server swaps the hands and sends both slots back; spectators can't).
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
if !self.spectator() {
self.remote_swap_hands();
if !self.spectator()
&& let Some(r) = self.remote.as_mut()
{
r.pending_keys.push(key);
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
@@ -11423,6 +11688,7 @@ impl Game<'_> {
self.tick_pregen();
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
self.spawn_fresh_chunks();
if let Some(lod) = &mut self.lod {
self.lod_observe
.extend(uploads.iter().map(|(key, _)| (key.0, key.2)));
@@ -11696,6 +11962,19 @@ impl ApplicationHandler for Game<'_> {
{
return;
}
// The pack reload's progress overlay takes no input
// (`LoadingOverlay`): `start_pack_reload` released what was held.
if self.server_packs.reload.is_some()
&& !matches!(
&event,
WindowEvent::CloseRequested
| WindowEvent::Focused(_)
| WindowEvent::Resized(_)
| WindowEvent::RedrawRequested
)
{
return;
}
if self.pending_save.is_some() {
match event {
WindowEvent::CloseRequested => {
@@ -11852,6 +12131,7 @@ impl ApplicationHandler for Game<'_> {
}
if self.screen == Screen::Inventory
&& d != 0.0
&& !self.bundle_scroll(scaled(raw_x), scaled(raw_y))
&& !self.tweak_scroll(d, self.inventory_target().0)
{
self.station_scroll(d);
@@ -11862,6 +12142,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::WorldOptions && d != 0.0 {
self.world_options_scroll(d);
}
if self.screen == Screen::ServerLinks && d != 0.0 {
self.server_links_scroll(d);
}
if self.screen == Screen::Advancements {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
@@ -12053,6 +12336,15 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::ServerLinks if pressed && button == MouseButton::Left => {
self.server_links_click()?
}
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
self.confirm_link_click()
}
Screen::DialogWarning if pressed && button == MouseButton::Left => {
self.dialog_warning_click()?
}
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
@@ -12641,6 +12933,7 @@ impl<'a> Game<'a> {
craft_table: None,
inv_drag: None,
tweaks: Default::default(),
bundle_mouse: Default::default(),
inv_clicks: Default::default(),
last_inv_click: None,
last_quick_moved: None,
@@ -12702,7 +12995,14 @@ impl<'a> Game<'a> {
level,
session_lock: None,
audio,
music: Default::default(),
music: {
// The manager starts with the saved option (MusicManager's constructor).
let mut music = horizon_client::music::MusicManager::default();
music.frequency = horizon_client::music::MusicFrequency::parse(
&settings.file.name("musicFrequency"),
);
music
},
ambience: Default::default(),
sound_paused: false,
cart_sounds: HashSet::new(),
@@ -12746,6 +13046,8 @@ impl<'a> Game<'a> {
command_source: None,
titles: Default::default(),
tool_highlight: Default::default(),
saving_indicator: Default::default(),
autosaving: false,
applied_modifiers: Vec::new(),
experience: Experience::default(),
orbs: Vec::new(),
@@ -12824,6 +13126,7 @@ impl<'a> Game<'a> {
.unwrap_or_default()
.join("assets/skins"),
)),
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: Vec::new(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
@@ -12890,6 +13193,7 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
@@ -12898,6 +13202,7 @@ impl<'a> Game<'a> {
lan_acting: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
guest_entity_events: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
chat_capture: None,
@@ -12924,6 +13229,7 @@ impl<'a> Game<'a> {
restore_player_state: true,
stats_ui: Default::default(),
adv_ui: Default::default(),
links_ui: Default::default(),
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
@@ -13166,7 +13472,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
"Loading assets",
@@ -13182,19 +13487,28 @@ const STAGE_LINES: [&str; 10] = [
/// The background loader's checkpoint, read by the loading screen each frame.
#[derive(Clone)]
struct Progress(Arc<AtomicU32>);
struct Progress {
value: Arc<AtomicU32>,
lines: &'static [&'static str],
}
impl Progress {
fn new() -> Self {
Self(Arc::new(AtomicU32::new(0)))
Self::with(&STAGE_LINES)
}
fn with(lines: &'static [&'static str]) -> Self {
Self {
value: Arc::new(AtomicU32::new(0)),
lines,
}
}
fn set(&self, stage: u32) {
self.0.store(stage, Ordering::Relaxed);
self.value.store(stage, Ordering::Relaxed);
}
fn fraction(&self) -> f32 {
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
}
fn line(&self) -> &'static str {
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
}
}
@@ -13554,16 +13868,22 @@ fn build_renderer(
&assets.sprite_mips,
&assets.animations,
(&atlas.pixels, atlas.width, atlas.height),
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
(
&atlas.glint_armor.0,
atlas.glint_armor.1,
atlas.glint_armor.2,
),
(&pack.forcefield.2, pack.forcefield.0),
&gui.panorama,
prepared,
previous,
api,
))?;
renderer.hud.fire_animations = atlas
.fire_animations
renderer.hud.sprite_animations = atlas
.sprite_animations
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
.collect();
Ok(renderer)
}
@@ -13580,6 +13900,7 @@ fn loading_renderer(
) -> Result<Renderer> {
static FORCEFIELD: [u8; 4] = [0; 4];
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
static EMPTY_GLINT: [u8; 4] = [0; 4];
let empty_panorama = horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
@@ -13590,6 +13911,10 @@ fn loading_renderer(
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
None => (EMPTY_HUD.as_slice(), 1, 1),
};
let (glint_pixels, glint_width, glint_height) = match atlas {
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
None => (EMPTY_GLINT.as_slice(), 1, 1),
};
// A glyph rasterized into an earlier texture is not in these pixels; the
// new texture gets it again when the character is drawn (see
// `build_renderer`).
@@ -13606,6 +13931,8 @@ fn loading_renderer(
&[],
&[],
(pixels, width, height),
(glint_pixels, glint_width, glint_height),
(glint_pixels, glint_width, glint_height),
(&FORCEFIELD[..], 1),
panorama,
None,
@@ -13613,10 +13940,10 @@ fn loading_renderer(
api,
))?;
if let Some(atlas) = atlas {
renderer.hud.fire_animations = atlas
.fire_animations
renderer.hud.sprite_animations = atlas
.sprite_animations
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
.collect();
}
Ok(renderer)
@@ -13652,6 +13979,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_spans: &[],
+23 -1
View File
@@ -419,6 +419,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -561,10 +564,26 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
Mode::CreateFlat {
name,
seed,
options,
flat,
..
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
"height": layer.height,
})).collect::<Vec<_>>(),
}),
Mode::Edit {
world,
name,
@@ -707,9 +726,12 @@ impl Game<'_> {
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+17 -2
View File
@@ -711,6 +711,20 @@ impl Game<'_> {
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
.res
.items()
.enchantments
.props
.get(&stack.item)
.is_some_and(|p| p.glint);
let overlay = if stack.has_foil(item_glint) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
let layer_poses = Poses::new(part);
horizon_client::mobs::render::draw(
part,
@@ -719,7 +733,7 @@ impl Game<'_> {
uv,
color,
packed,
0,
overlay,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
@@ -734,7 +748,7 @@ impl Game<'_> {
trim,
0xFFFF_FFFF,
packed,
0,
overlay,
out,
);
}
@@ -877,6 +891,7 @@ impl Game<'_> {
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
};
let profile = stack
.extra
@@ -725,21 +725,38 @@ impl Game<'_> {
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
let download = skin.download.clone().or_else(|| {
let key = skin.dynamic.as_ref()?;
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
else {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
if let Some((url, location)) = download {
let mut downloads = self.skin_downloads.borrow_mut();
if self.gui.atlas.sprites.contains_key(&location) {
let _ = downloads.state(skins::Kind::Skin, &url);
return Some(location);
}
// Its slot was evicted: offer the image again.
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
downloads.again(&location);
}
}
Some(skin.fallback)
Some(skin.fallback.to_owned())
}
}
+1 -1
View File
@@ -862,7 +862,7 @@ impl Game<'_> {
let a = alpha as f32;
let range = super::entity_culling::default_range(
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
self.settings.entity_distance,
self.view_scale(),
);
for b in &self.boats {
let entity_start = out.len();
+6 -3
View File
@@ -1009,9 +1009,12 @@ impl Game<'_> {
}
};
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
let text = tr("book.pageIndicator", "Page %s of %s")
.replacen("%s", &(page + 1).to_string(), 1)
.replacen("%s", &count.max(1).to_string(), 1);
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
// positional ("Page %1$s of %2$s").
let text = horizon_protocol::text::format_java(
&tr("book.pageIndicator", "Page %1$s of %2$s"),
&[(page + 1).to_string(), count.max(1).to_string()],
);
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
+103
View File
@@ -0,0 +1,103 @@
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
//! one of the items it shows, which a click that takes one out of it then takes
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
use super::{Game, Screen};
use horizon_client::{
bundle::{Contents, toggle_selected},
mouse::ScrollWheelHandler,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
#[derive(Default)]
pub(super) struct BundleMouse {
wheel: ScrollWheelHandler,
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
hovered: Option<usize>,
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
fn bundle_slot(&mut self, slot: usize) -> bool {
(slot < 36 || slot == 504 || (300..500).contains(&slot))
&& (self.remote.is_none() || self.remote_storage_open())
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
}
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
/// through the items its tooltip shows. True when the bundle took the wheel.
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
let Some(slot) = self.inventory_target().0 else {
return false;
};
if !self.bundle_slot(slot) {
return false;
}
let stack = self.slot_mut(slot).clone();
let items = self.res.items();
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
return false;
};
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
if shown == 0 {
return false;
}
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
if wheel != 0 {
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
if next != selected {
self.select_bundle(slot, next);
}
}
true
}
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
fn select_bundle(&mut self, slot: usize, index: i32) {
if let Some(stack) = self.slot_mut(slot) {
toggle_selected(stack, index);
}
if self.remote.is_some() {
let (_, menu) = self.remote_menu();
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
self.send(S::SelectBundleItem(play::SelectBundleItem {
slot: menu_slot as i32,
index,
}));
}
}
}
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
pub(super) fn unselect_bundle(&mut self, slot: usize) {
if !self.bundle_slot(slot) {
return;
}
let selected = self
.slot_mut(slot)
.as_ref()
.and_then(|s| s.data())
.is_some_and(|d| d.bundle_selected.0 >= 0);
if selected {
self.select_bundle(slot, -1);
}
}
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
/// screen, clears the bundle's selection.
pub(super) fn tick_bundle_hover(&mut self) {
let now = if self.screen == Screen::Inventory {
self.inventory_target().0
} else {
None
};
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
&& Some(left) != now
{
self.unselect_bundle(left);
}
}
}
@@ -4,8 +4,12 @@
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::motion::{LAVA, WATER};
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
use horizon_client::pose::Pose;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
@@ -33,5 +37,209 @@ impl Game<'_> {
}
}
self.eye_particles();
self.sprint_dust();
self.effect_swirls();
}
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
&& !p.spectator
&& p.pose != Pose::Crouching
&& !p.health.dead_or_dying()
&& !p.fluids.was_touching_water
&& !p.is_in_lava()
&& p.fluid_height(WATER) <= 0.0
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
&& &*m.kind.id == "minecraft:player"
&& self
.res
.facts()
.get(self.world.block(feet[0], feet[1], feet[2]))
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
}
}
for (position, movement, support) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
let name = game.block_name(s);
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|tag| game.block_in_tag(tag, &name))
};
let at = match support {
Some(s) if tall(self, s) => s,
Some(s) => [s[0], below, s[2]],
None => [position.x.floor() as i32, below, position.z.floor() as i32],
};
let state = self.world.block(at[0], at[1], at[2]);
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
// Clamped into the block when the feet are in another column.
if position.x.floor() as i32 != at[0] {
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
}
if position.z.floor() as i32 != at[2] {
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
}
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[x, position.y + 0.1, z],
[movement.x * -4.0, 1.5, movement.z * -4.0],
);
}
}
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
/// with visible effects, from the particles its synched data lists. The
/// window's player has its own effects, a server's mobs and players the
/// list the server sent, and the mobs and guests of a game in the frame
/// theirs.
fn effect_swirls(&mut self) {
if let Some(p) = self.player.as_ref() {
let effects = &self.potions.effects.list;
let list: Vec<_> = effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
swirl(
&mut self.particles,
p.position,
size,
&list,
ambient,
invisible,
);
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
if !m.alive() {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
.iter()
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.filter_map(|e| effect_particle(e.0, false))
.collect();
swirl(
&mut self.particles,
m.position,
size,
&list,
false,
invisible,
);
} else {
let list = &m.extra.effect_particles;
let ambient = m.extra.effect_ambient;
swirl(
&mut self.particles,
m.position,
size,
list,
ambient,
invisible,
);
}
}
}
}
/// `MobEffect.particleFactory`: the omens have particles of their own, any
/// other effect an entity effect in its colour, faint when ambient.
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
Some(match effect.trim_start_matches("minecraft:") {
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
other => {
let color = horizon_client::brewing::effect(other)?.color;
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
// MobEffect.AMBIENT_ALPHA is 38 of 255.
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), alpha],
)
}
})
}
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
/// chance (one in fifteen while invisible, a fifth of either when all the
/// effects are ambient) of one of the particles at a random spot of the box.
fn swirl(
particles: &mut ParticleEngine,
feet: Vec3,
size: (f64, f64),
list: &[ParticleOptions],
ambient: bool,
invisible: bool,
) {
if list.is_empty() {
return;
}
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
let r = particles.random();
let Ok(0) = r.next_int_bound(bound) else {
return;
};
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
let y = feet.y + size.1 * r.next_double();
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
particles.add(&list[pick], [x, y, z], [1.0; 3]);
}
+32
View File
@@ -10,6 +10,9 @@ struct Entry {
fall_distance: f32,
/// The causing entity: its id (for comparisons) and display name.
attacker: Option<(u32, Text)>,
/// The direct entity's display name when no entity caused the damage
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
#[derive(Default)]
pub(super) struct CombatTracker {
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text))>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -66,6 +71,14 @@ impl Game<'_> {
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
@@ -94,6 +107,15 @@ impl Game<'_> {
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending.remove(i).1);
let direct = match attacker {
Some(_) => None,
None => self
.combat
.pending_direct
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending_direct.remove(i).1),
};
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
@@ -111,10 +133,12 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -224,6 +248,7 @@ impl Game<'_> {
let entries = std::mem::take(&mut self.combat.entries);
let killing = entries.last().expect("checked above");
let killer = killing.attacker.clone();
let direct = killing.direct.clone();
if def.1 == "fall_variants"
&& let Some(knock) = knock.and_then(|i| entries.get(i))
{
@@ -248,15 +273,22 @@ impl Game<'_> {
.append(Text::literal("]"));
return Text::translate(&format!("{key}.message"), &[me, link]);
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
}
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
if let Some(((_, name), at)) = &self.combat.last_hurt_by
&& self.world.game_time - at <= 100
{
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
}
// No entity at all: the one-argument message, as vanilla sends it (a
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
}
+235 -82
View File
@@ -42,9 +42,10 @@ pub(super) enum Target {
Guest(u32),
Mob(u32),
Item(u32),
Orb(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
/// An experience orb, by its entity id.
Orb(u32),
/// A projectile, by its entity id.
Projectile(u32),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -53,6 +54,10 @@ pub(super) enum Target {
/// An entity of a type Horizon does not simulate (markers, displays,
/// interactions...): kept as its saved data at a position.
Other(u32),
/// An End crystal, by its entity id (`minecraft:end_crystal`).
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -340,14 +345,16 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(i) => self
Target::Orb(id) => self
.orbs
.get(i)
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
@@ -385,6 +392,16 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -399,10 +416,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|p| p.id == id)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
@@ -428,6 +446,8 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -512,51 +532,72 @@ impl Game<'_> {
s.pos[2].unwrap_or(p[2]),
]
};
let mut candidates: Vec<Target> = Vec::new();
let mut source_candidates: Vec<Target> = Vec::new();
match (s.kind, self.command_source) {
// @s: the executing entity (none for a server source).
('s', Some(source)) => candidates.extend(source.entity),
('s', None) => candidates.extend(self.source_entity()),
('s', Some(source)) => source_candidates.extend(source.entity),
('s', None) => source_candidates.extend(self.source_entity()),
// A guest in place is among the players, in join order.
_ if self.player.is_some() && self.lan_acting.is_none() => {
candidates.push(Target::Player);
source_candidates.push(Target::Player);
}
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend((0..self.orbs.len()).map(Target::Orb));
candidates.extend(
self.creatures
.projectiles
.iter()
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
source_candidates.extend(self.player_candidates(s));
}
let source_dim = self.dim;
// A selector with a spatial constraint reaches the level the command
// runs in only; an unbounded one reaches every loaded level
// (EntitySelector.findEntities).
let dims = if s.world_limited() {
vec![source_dim]
} else {
self.loaded_dims()
};
let mut out: Vec<(Target, f64)> = Vec::new();
for dim in dims {
let back = self.enter_command_level(dim);
let mut candidates = Vec::new();
if dim == source_dim {
candidates.append(&mut source_candidates);
}
if s.kind != 's' {
candidates.extend(self.level_candidates());
}
out.extend(self.select_in_level(candidates, s, origin));
if let Some(back) = back {
self.enter_command_level(back);
}
}
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The candidates of the level in place that pass the selector
/// (EntitySelector.getPredicate).
fn select_in_level(
&mut self,
candidates: Vec<Target>,
s: &Selector,
origin: [f64; 3],
) -> Vec<(Target, f64)> {
let level = self.experience.level;
let creative = self.creative;
let passing = self.selector_extra_filter(&candidates, s);
let mut out: Vec<(Target, f64)> = candidates
candidates
.into_iter()
.filter_map(|t| {
let p = self.target_position(t)?;
@@ -628,23 +669,103 @@ impl Game<'_> {
}
Some((t, d2))
})
.collect();
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The entities of the level in place a selector can find.
fn level_candidates(&self) -> Vec<Target> {
use horizon_client::projectiles::ProjectileKind as K;
let mut candidates = Vec::new();
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
candidates.extend(
self.creatures
.projectiles
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|p| Target::Projectile(p.id)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
// are entities of their own).
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates
}
/// The loaded levels, in the order the server holds them: the
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
fn loaded_dims(&self) -> Vec<Dim> {
let mut dims = vec![self.dim];
dims.extend(self.levels.parked_dims());
dims.sort_by_key(|d| match d {
Dim::Overworld => 0,
Dim::Nether => 1,
Dim::End => 2,
});
dims
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
// A player is in no level of its own.
Target::Player | Target::Guest(_) => true,
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
/// (the entity ids are unique across the loaded levels) and put the
/// level back afterwards. `f` does not run when no level holds the
/// entity.
pub(in crate::game) fn in_target_level<R>(
&mut self,
t: Target,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
if self.level_holds(t) {
return Some(f(self));
}
for dim in self.levels.parked_dims() {
let back = self.enter_command_level(dim);
if self.level_holds(t) {
let r = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
return Some(r);
}
if let Some(back) = back {
self.enter_command_level(back);
}
}
None
}
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
let path = self.options.resources.join(format!(
"data/minecraft/tags/entity_type/{}.json",
@@ -1104,6 +1225,10 @@ impl Game<'_> {
Ok(targets.len() as i32)
}
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1152,14 +1277,14 @@ impl Game<'_> {
i.previous = v;
}
}
Target::Orb(i) => {
if let Some(o) = self.orbs.get_mut(i) {
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.position = v;
o.previous = v;
}
}
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
}
@@ -1199,6 +1324,17 @@ impl Game<'_> {
}
}
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
}
}
Target::Dragon(id) => {
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
d.position = v;
d.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1558,6 +1694,10 @@ impl Game<'_> {
Ok(duration.unwrap_or(-1))
}
fn kill_target(&mut self, t: Target) {
// An entity in another level is killed in the level that holds it.
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -1577,16 +1717,13 @@ impl Game<'_> {
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Orb(i) => {
if i < self.orbs.len() {
self.orbs[i].age = u32::MAX / 2;
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
}
}
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
Target::Projectile(id) => {
self.creatures.projectiles.retain(|p| p.id != id);
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
@@ -1594,7 +1731,11 @@ impl Game<'_> {
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -1602,19 +1743,18 @@ impl Game<'_> {
} else {
vec![self.implied_player()]
};
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
// Remove indexed entities from the back so earlier indices stay valid.
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
for t in ordered {
let mut names: Vec<Text> = Vec::new();
for &t in &targets {
// The name of an entity in another level is read there.
let name = match self.in_target_level(t, |g| g.target_name(t)) {
Some(name) => name,
None => self.target_name(t),
};
names.push(name);
}
for &t in &targets {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1706,6 +1846,20 @@ impl Game<'_> {
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
"end_crystal" => {
// EndCrystal's constructor takes an id from the entity
// counter, and the crystal is shown with its base.
self.next_entity += 1;
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
show_bottom: true,
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
beam: None,
invulnerable: false,
});
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
@@ -1896,8 +2050,7 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
impl Game<'_> {
// ---- entity data ---------------------------------------------------
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
/// in another level is read in the level that holds it.
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
Some(match t {
Target::Guest(_) => {
@@ -113,9 +118,11 @@ impl Game<'_> {
let o = self.others.iter().find(|o| o.id == id)?.clone();
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
} else {
@@ -129,6 +136,16 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -397,6 +414,19 @@ impl Game<'_> {
}
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
let target = self.data_ref(ctx, 0)?;
// An entity in another level is read and written in the level that
// holds it.
if let DataRef::Entity(t) = &target {
let t = *t;
let target = target.clone();
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
Some(r) => r,
None => Err(err("argument.entity.notfound.entity", &[])),
};
}
self.cmd_data_in(id, ctx, target)
}
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
@@ -91,7 +91,12 @@ impl Game<'_> {
}
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
m.add_effect(namespaced(effect.id), duration, amplifier);
let name = namespaced(effect.id);
m.add_effect(name, duration, amplifier);
m.extra.hidden_effects.retain(|h| *h != name);
if !particles {
m.extra.hidden_effects.push(name);
}
true
}
None => false,
@@ -154,6 +159,10 @@ impl Game<'_> {
m.extra
.effects
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
let left = &m.extra.effects;
m.extra
.hidden_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra.effects.len() != before
}
None => false,
@@ -145,9 +145,42 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.eye_y() - m.position.y),
_ => 0.0,
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
Some(a) if a.marker => 0.0,
Some(a) if a.small => f64::from(0.9875f32),
_ => self.type_eye_height(&self.target_type(t)),
},
// Entity.getEyeHeight: the type's dimensions.
_ => self.type_eye_height(&self.target_type(t)),
}
}
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
/// read once: a float, widened as `Entity.getEyeY` does.
fn type_eye_height(&self, ty: &str) -> f64 {
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
EYE_HEIGHTS
.get_or_init(|| {
std::fs::read(self.options.resources.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.iter()
.filter_map(|(k, t)| {
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
})
.collect()
})
})
.unwrap_or_default()
})
.get(ty)
.copied()
.unwrap_or(0.0)
}
/// EntityAnchorArgument.Anchor.apply.
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
match s.entity {
@@ -467,9 +500,10 @@ impl Game<'_> {
"positioned" if c.has("as") => targets(self)?
.into_iter()
.filter_map(|t| {
// withPosition keeps the anchor (only `positioned <pos>`
// resets it to the feet).
Some(Source {
pos: self.target_position(t)?,
eyes: false,
..s
})
})
@@ -606,6 +640,73 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
// Leashable.getLeashHolder: a fence knot is not an entity here.
Some("leasher") => match entity {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
.and_then(|(holder, player)| match holder {
// The player who tied it (the host's when unrecorded).
horizon_client::mobs::leash::Holder::Player => {
let uuid = player.or(self.level.player_uuid)?;
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
}
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
_ => Vec::new(),
},
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
if self.lan_acting == Some(conn) =>
{
Some(Target::Player)
}
(horizon_client::mobs::Who::Player, Some(conn)) => {
Some(Target::Guest(conn))
}
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
})
.into_iter()
.collect(),
Target::Item(id) => self
.dropped
.iter()
.find(|d| d.id == id)
.and_then(|d| d.thrower)
.and_then(|words| {
let uuid = words
.iter()
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
self.target_by_uuid(uuid)
})
.into_iter()
.collect(),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
.map(|m| m.extra.counter2 as u32)
.filter(|owner| *owner != 0)
.map(Target::Mob)
.into_iter()
.collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
@@ -807,10 +908,12 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
));
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
@@ -244,7 +244,11 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
break;
@@ -120,6 +120,9 @@ impl Game<'_> {
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
}
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
let (message, total) = tracker.message(
@@ -14,10 +14,11 @@ impl Game<'_> {
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
// NbtContents.resolve: the path's values in the entity, block or
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
// ScoreContents.resolve: the holder's score in the objective.
let nbt_values = std::cell::RefCell::new(Vec::new());
{
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
if o.contains_key("nbt") {
if o.contains_key("nbt") || o.contains_key("score") {
nbt_values.borrow_mut().push(o.clone());
}
};
@@ -40,7 +41,11 @@ impl Game<'_> {
}
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
for o in nbt_values.into_inner() {
let t = self.resolve_nbt_component(&o);
let t = if o.contains_key("score") {
self.resolve_score_component(&o)?
} else {
self.resolve_nbt_component(&o)
};
resolved.push((o, t));
}
let mut resolve = |raw: &str| -> Text {
@@ -73,6 +78,54 @@ impl Game<'_> {
},
))
}
/// The text of a `score` component (ScoreContents.resolve): the named
/// holder's score, `*` being the player the command runs for.
fn resolve_score_component(
&mut self,
o: &serde_json::Map<String, serde_json::Value>,
) -> R<Text> {
let score = o.get("score");
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
let (name, objective) = (
field("name").unwrap_or(""),
field("objective").unwrap_or(""),
);
// findTargetName: a selector that matches one entity is that entity,
// one that matches none is just the name.
let mut reader = horizon_client::command::Reader::new(name);
let ty = horizon_client::command::args::ArgType::Entity {
single: false,
players: false,
};
let mut holder = name.to_owned();
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
&& !reader.can_read()
{
let found = self.select(&sel);
if found.len() > 1 {
return Err(err("argument.entity.toomany", &[]));
}
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
holder = t;
}
}
if holder == "*" {
holder = self.acting_player_name().to_owned();
}
let board = &self.score.board;
let (Some(objective), Some(score)) =
(board.objective(objective), board.score(&holder, objective))
else {
return Ok(Text::empty());
};
// numberFormatOrDefault(StyledFormat.NO_STYLE).
let format = (score.number_format.clone())
.or_else(|| objective.number_format.clone())
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
serde_json::json!({}),
));
Ok(format.format(score.value))
}
/// The text of an `nbt` component.
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
use horizon_world::nbt::Tag;
@@ -136,28 +189,40 @@ impl Game<'_> {
}
out
}
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
// Only the local player can receive messages and titles.
/// The players the command reaches: the window's own and the LAN guests.
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
let targets = self.entities(ctx, "targets", true)?;
Ok(targets.iter().filter(|t| **t == Target::Player).count())
Ok(targets
.into_iter()
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
.collect())
}
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let players = self.player_targets(ctx)?;
let message = self.component_arg(ctx, "message")?;
if n > 0 {
self.system_message(message);
// Each player's chat (a guest's through the connection).
for &t in &players {
self.for_player(t, |g| g.system_message(message.clone()));
}
Ok(n as i32)
Ok(players.len() as i32)
}
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let name = self.target_name(Target::Player);
let players = self.player_targets(ctx)?;
let n = players.len();
let name = players
.first()
.map_or_else(Text::empty, |t| self.target_name(*t));
let single = n == 1;
let key = if ctx.has("clear") {
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
self.titles_or_packet(
&players,
Titles::clear,
C::ClearTitles(play::Bool1 { value: false }),
);
"cleared"
} else if ctx.has("reset") {
self.titles_or_packet(
&players,
|t| {
t.clear();
t.reset_times();
@@ -172,6 +237,7 @@ impl Game<'_> {
};
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
self.titles_or_packet(
&players,
|t| t.set_times(fade_in, stay, fade_out),
C::SetTitlesAnimation(play::TitlesAnimation {
fade_in,
@@ -186,13 +252,17 @@ impl Game<'_> {
text: component_nbt(&text.component()),
};
if ctx.has("subtitle") {
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
"show.subtitle"
} else if ctx.has("actionbar") {
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
self.titles_or_packet(
&players,
|t| t.set_overlay(text, false),
C::SetActionBarText(sent),
);
"show.actionbar"
} else {
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
"show.title"
}
};
@@ -207,13 +277,27 @@ impl Game<'_> {
self.feedback(message);
Ok(n as i32)
}
/// The player the title is for: the guest acting now is sent the packet
/// (TitleCommand's `connection.send`), the window's own player shows it.
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
if self.lan_acting.is_some() {
self.guest_packets.push(packet);
} else {
local(&mut self.titles);
/// TitleCommand's `connection.send` for each player: a guest is sent the
/// packet, the window's own player shows it.
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
let mut local = Some(local);
for &t in players {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => {
if let Some(local) = local.take() {
local(&mut self.titles);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
}
}
@@ -141,27 +141,37 @@ impl Game<'_> {
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
_ => None,
};
let cause = match ctx.get("cause") {
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
_ => None,
};
let from = match ctx.get("location") {
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
_ => by.and_then(|t| self.target_position(t)),
};
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 => {
if self.creative || self.spectator() {
// `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 if let Some(player) = self.player.as_mut() {
match self.motion.health.damage.id(&ty) {
Some(id) => player
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false),
None => false,
}
} else {
false
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
}
Target::Mob(id) => {
let attacker = by.and_then(|t| match t {
let attacker = cause.or(by).and_then(|t| match t {
Target::Player => Some(Who::Player),
Target::Mob(i) => Some(Who::Mob(i)),
_ => None,
@@ -302,9 +312,18 @@ impl Game<'_> {
float("minVolume", 0.0),
);
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
// PlaySoundCommand: one seed for every player.
let seed = self.world.random.next_long();
let here = self.dim.id();
let mut tracker = Tracker::default();
for t in targets {
if t != Target::Player {
if !matches!(t, Target::Player | Target::Guest(_)) {
continue;
}
// Only players in the source's level hear it.
if let Target::Guest(conn) = t
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
{
continue;
}
let Some(p) = self.target_position(t) else {
@@ -328,8 +347,26 @@ impl Game<'_> {
} else {
(pos, volume)
};
self.audio.play_on(source, &sound, Some(at), v, pitch);
tracker.mark(&holder(Text::literal(self.acting_player_name())));
// The connection is sent the sound; the window's own player
// plays it.
let guest = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
Some((conn, host)) => {
let packet = host
.hub
.sound_packet(&sound, at, v, pitch, source as i32, seed);
if t == Target::Player {
self.guest_packets.push(packet);
} else {
host.hub.send_to(conn, &packet);
}
}
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
}
tracker.mark(&holder(self.target_name(t)));
}
let name = Text::literal(sound.clone());
let (message, total) = tracker.message(
@@ -606,8 +643,10 @@ impl Game<'_> {
let reached = reached.len() as i32;
if !local {
} else if count <= 0 {
// No count: one particle with the delta as velocity.
self.particles.create(&options, pos, delta);
// handleParticleEvent, count 0: one particle, the speed times
// the delta as velocity (both sent as floats, multiplied as floats).
self.particles
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
} else {
for _ in 0..count {
let r = self.particles.random();
+10
View File
@@ -19,7 +19,17 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
impl Game<'_> {
/// `ServerPlayer.completeUsingItem`: the player's own client is told
/// first, and completes the use itself (eating the food there too).
pub(super) fn send_use_item_complete(&mut self) {
if self.lan_acting.is_some() {
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
}
}
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
pub(super) fn emit_consume(
&mut self,
@@ -878,6 +878,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -1083,6 +1086,9 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
let mut fangs_vertices = Vec::new();
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
status["fangs_vertices"] = json!(fangs_vertices.len());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
+4 -3
View File
@@ -218,8 +218,9 @@ impl Game<'_> {
);
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -242,7 +243,7 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if natural && !self.creative && !self.spectator() {
if by_player && natural && !self.creative && !self.spectator() {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
+236 -41
View File
@@ -18,6 +18,7 @@ use horizon_client::{
spawning::{self, BiomeFacts},
},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
@@ -145,6 +146,8 @@ fn flame_geometry(
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
h -= 0.45;
@@ -430,6 +433,12 @@ impl Game<'_> {
),
}
}
/// What the generator finalizes this level's structure mobs with.
pub(super) fn share_spawn_conditions(&self) {
if let Some(terrain) = self.end.locator() {
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
}
}
/// `ServerLevel.getCurrentDifficultyAt`.
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
use horizon_world::food::Difficulty;
@@ -451,9 +460,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
@@ -467,6 +476,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.
@@ -526,6 +539,8 @@ impl Game<'_> {
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The mobs whose bodies go this tick: (id, feet, width, height).
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -598,6 +613,11 @@ impl Game<'_> {
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
gone.push((m.id, m.position, width, height));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
@@ -656,6 +676,12 @@ impl Game<'_> {
}
self.mobs.append(&mut mobs);
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
let at = [at.x, at.y, at.z];
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
@@ -682,6 +708,22 @@ impl Game<'_> {
self.handle_mob_events(queued)?;
Ok(())
}
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
let options = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Poof,
);
for _ in 0..20 {
let r = self.particles.level_random();
let xa = r.next_gaussian() * 0.02;
let ya = r.next_gaussian() * 0.02;
let za = r.next_gaussian() * 0.02;
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
let y = at[1] + height * r.next_double() - ya * 10.0;
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
self.particles.add(&options, [x, y, z], [xa, ya, za]);
}
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let killer = self.last_hurt_by_player.remove(&m.id);
@@ -892,6 +934,15 @@ impl Game<'_> {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
/// Adds a projectile, giving it an id from the entity counter
/// (`Entity.id`, the same counter as the rest) when it has none.
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
if p.id == 0 {
self.next_entity += 1;
p.id = self.next_entity;
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
@@ -1093,7 +1144,12 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
self.creatures.projectiles.push(p);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.add_projectile(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
MobEvent::Spawn(mut m) => {
@@ -1655,11 +1711,34 @@ impl Game<'_> {
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
self.mob_hits_player(amount, from, kind);
}
/// Damage whose causing mob is known (`cause`, or none): not guessed
/// from the mob nearest to `from`.
pub(super) fn mob_hits_player_caused(
&mut self,
amount: f32,
from: [f64; 3],
kind: &str,
cause: Option<u32>,
) {
self.hurt_player_from(amount, Some(from), kind, Some(cause));
}
pub(super) fn mob_hits_player(
&mut self,
amount: f32,
from: [f64; 3],
damage_type: &str,
) -> bool {
self.hurt_player_from(amount, Some(from), damage_type, None)
}
/// `hurtServer` on the player. `from` is the source's position (none: no
/// direction), `known`: the causing mob when it is given (`/damage`),
/// else the melee attacker is the mob nearest to `from`.
pub(super) fn hurt_player_from(
&mut self,
amount: f32,
from: Option<[f64; 3]>,
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -1672,23 +1751,26 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(Some(from), damage_type, false);
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = (!projectile && damage_type != "minecraft:player_attack")
.then(|| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
})
.flatten();
let attacker = match known {
Some(known) => known,
None => from
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
.and_then(|from| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
}),
};
// The causing entity for the death message.
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
@@ -1716,7 +1798,10 @@ impl Game<'_> {
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
// No source position: a random direction.
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
(from[0] - player.position.x, from[2] - player.position.z)
});
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
@@ -2054,6 +2139,33 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
if !matches!(shot.kind, ProjectileKind::Trident) {
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
}
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
return 8.0;
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
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()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(&trident),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
)
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -2082,6 +2194,8 @@ impl Game<'_> {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
};
self.audio
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
@@ -2175,7 +2289,7 @@ impl Game<'_> {
}
}
if keep {
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
}
}
/// `TargetBlock.onProjectileHit`: a target block takes its power from
@@ -2231,11 +2345,11 @@ impl Game<'_> {
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
if p.arrow.is_some() {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
@@ -2256,17 +2370,15 @@ impl Game<'_> {
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
ProjectileKind::Arrow { .. },
Hit::Entity {
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
},
) = (&p.kind, hit)
} = hit
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
{
// AbstractArrow.onHitEntity on a minecart.
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
let creative = p.owner == Some(Who::Player) && self.creative;
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
@@ -2277,7 +2389,7 @@ impl Game<'_> {
// Projectile.onHit: the landing is heard.
self.projectile_event("minecraft:projectile_land", p.owner, atp);
let source_kind = match p.kind {
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
DamageKind::Fireball
}
@@ -2289,6 +2401,40 @@ impl Game<'_> {
from: Some([p.previous.x, p.previous.y, p.previous.z]),
};
match (&p.kind, hit) {
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
// dealt whether or not the hurt lands, then it bounces off.
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
let mut trident = p.clone();
let victim = match who {
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone()),
Who::Player => Some("minecraft:player".to_owned()),
};
let amount = self.shot_damage(&mut trident, victim.as_deref());
let hurt = match who {
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:trident",
),
Who::Mob(id) => self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source)),
};
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(trident, outcome);
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
@@ -2510,14 +2656,30 @@ impl Game<'_> {
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:indirect_magic", owner);
}
match *effect {
"minecraft:instant_damage" => self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
),
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
@@ -3177,7 +3339,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.creatures.projectiles.push(proj);
self.add_projectile(proj);
self.player_projectile_shot();
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
// second is the `use_cooldown` component, as for any thrown item.
@@ -3274,7 +3436,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = f64::from(self.settings.entity_distance);
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
@@ -3283,9 +3445,16 @@ impl Game<'_> {
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let range = (w + h + w) / 3.0 * 64.0 * scale;
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
@@ -3426,6 +3595,30 @@ impl Game<'_> {
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
@@ -3495,6 +3688,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+12 -15
View File
@@ -348,11 +348,15 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -361,14 +365,8 @@ impl Game<'_> {
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
@@ -455,8 +453,7 @@ impl Game<'_> {
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+198 -42
View File
@@ -24,6 +24,7 @@ use horizon_client::{
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
}
});
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
println!(
// The launcher can close its stdout pipe while the server keeps running.
let _ = writeln!(
std::io::stdout().lock(),
"[{}] [{thread}/{level}]: {message}",
chrono::Local::now().format("%H:%M:%S")
);
@@ -295,14 +298,14 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 6] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -319,11 +322,18 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
@@ -332,18 +342,9 @@ impl Game<'_> {
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
horizon_client::save::WorldPreset::Normal
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
),
);
}
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -359,7 +360,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
@@ -572,7 +573,8 @@ impl Game<'_> {
self.ui.difficulty = props.difficulty as u8;
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| {
@@ -618,6 +620,22 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
vec![horizon_protocol::packets::configuration::ServerLink {
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
link: props.bug_report_link.clone(),
}]
} else {
log(
"WARN",
&format!("Failed to parse bug link {}", props.bug_report_link),
);
Vec::new()
},
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -654,6 +672,9 @@ impl Game<'_> {
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
}
let mut state = ServerState {
lists,
console,
@@ -713,9 +734,33 @@ impl Game<'_> {
}
/// 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.options.dedicated.is_some() {
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()?;
@@ -728,24 +773,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.options.dedicated.is_some() {
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;
}
}
@@ -1308,3 +1357,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
fn assert_log_lines(text: &str) {
let expected = [
format!("] [Server thread/INFO]: {LOGIN}"),
format!("] [User Authenticator #1/WARN]: {WARNING}"),
];
assert_eq!(text.lines().count(), expected.len());
for (line, suffix) in text.lines().zip(expected) {
assert!(line.starts_with('['));
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
assert_eq!(&line[9..], suffix);
}
}
#[test]
fn console_logging_survives_closed_stdout() {
if let Some(home) = std::env::var_os(CHILD_HOME) {
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
// Let the parent close the real stdout pipe only after libtest's startup output.
io::stdout().write_all(READY.as_bytes()).unwrap();
io::stdout().flush().unwrap();
io::stdin().read_exact(&mut [0]).unwrap();
std::thread::Builder::new()
.name("Server thread".into())
.spawn(|| {
let captured = capture_log(|| {
log("INFO", LOGIN);
log_from("User Authenticator #1", "WARN", WARNING);
});
assert_eq!(captured, [LOGIN, WARNING]);
horizon_client::logging::flush();
})
.unwrap()
.join()
.unwrap();
// Libtest's final status also writes to stdout; only the game logger is under test.
std::process::exit(0);
}
for closed_stdout in [false, true] {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!(
"horizon-console-log-test-{}-{unique}",
std::process::id()
));
fs::create_dir(&home).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"game::dedicated::tests::console_logging_survives_closed_stdout",
"--nocapture",
])
.env(CHILD_HOME, &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert!(
stdout.read_line(&mut line).unwrap() > 0,
"child was not ready"
);
if line.ends_with(READY) {
break;
}
}
if closed_stdout {
drop(stdout); // No reader remains: the next console write gets EPIPE.
} else {
child.stdout = Some(stdout.into_inner());
}
child.stdin.take().unwrap().write_all(&[1]).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"closed_stdout={closed_stdout}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !closed_stdout {
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
}
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
fs::remove_dir_all(home).unwrap();
}
}
}
+2 -2
View File
@@ -982,7 +982,7 @@ impl Game<'_> {
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
@@ -1034,7 +1034,7 @@ impl Game<'_> {
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
self.creatures.projectiles.push(shot);
self.add_projectile(shot);
}
}
}
+35 -4
View File
@@ -828,8 +828,10 @@ impl Game<'_> {
continue;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: at,
show_bottom: true,
time,
@@ -2157,11 +2159,14 @@ impl Game<'_> {
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
/// death message names it, or the cloud once the dragon is gone.
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
self.note_named_attacker(damage_type, id, name);
} else if damage_type == "minecraft:indirect_magic" {
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
self.note_direct_entity(damage_type, name);
}
self.hurt_player_by(damage_type, amount);
}
@@ -2482,6 +2487,8 @@ impl Game<'_> {
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2547,6 +2554,8 @@ impl Game<'_> {
layer: 6,
color: c,
light: wrap_light,
glint_uv: [0.0, 0.0],
});
}
(last_sin, last_cos, last_u) = (sin, cos, uu);
@@ -2592,13 +2601,17 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
@@ -2830,8 +2843,11 @@ impl Game<'_> {
return false;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: Vec3::new(x + 0.5, y, z + 0.5),
show_bottom: false,
time,
@@ -2848,6 +2864,21 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
/// (`Entity.kill` does not hurt it).
pub(super) fn kill_crystal(&mut self, id: u32) {
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
fn entity_in(&self, room: Aabb) -> bool {
let touches = |lo: Vec3, hi: Vec3| {
+221 -25
View File
@@ -5,6 +5,7 @@
use super::{Dim, Game};
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -19,7 +20,7 @@ use horizon_core::{
use std::{collections::HashMap, sync::Arc};
/// ServerLevel.END_SPAWN_POINT.
const END_SPAWN: [i32; 3] = [100, 50, 0];
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
@@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75;
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
Find { gateway: [i32; 3] },
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
Find { gateway: [i32; 3], exact: bool },
/// A known exit (exact, or findExitPosition around it).
Known { exit: [i32; 3], exact: bool },
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
gateway: [i32; 3],
entity: Option<&BlockEntity>,
legacy: Option<([i32; 3], bool)>,
) -> Self {
let configured = match entity {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
match configured.or(legacy) {
Some((exit, exact)) => Self::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
exit: END_SPAWN,
exact: true,
},
None => Self::Find {
gateway,
exact: exact_teleport(entity),
},
}
}
}
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
/// (false without one).
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
}
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
/// entity has its own; true when it took the saved one.
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
if entity.exit_portal.is_some() {
return false;
}
entity.exit_portal = Some(exit);
entity.exact_teleport = exact;
true
}
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
if exact { exit } else { find(exit) }
}
#[derive(Default)]
pub(super) struct EndState {
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
@@ -247,6 +298,8 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -754,6 +807,11 @@ impl Game<'_> {
}
}
}
self.world
.block_entities
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
BlockEntity::EndGateway(Default::default())
});
Ok(())
}
@@ -775,8 +833,7 @@ impl Game<'_> {
self.end.exits.as_mut().expect("loaded exits")
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
self.gateway_exits().insert(gateway, (exit, exact));
fn save_gateway_exits(&mut self) -> Result<()> {
let list: Vec<_> = self
.gateway_exits()
.iter()
@@ -788,6 +845,35 @@ impl Game<'_> {
Ok(())
}
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
if self.gateway_exits().remove(&gateway).is_some() {
self.save_gateway_exits()?;
}
Ok(())
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
// exit then waits in the side file until the gateway is used again (`gateway_job`).
if self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
g.exit_portal = Some(exit);
g.exact_teleport = exact;
self.world.modified.insert(chunk);
return self.forget_gateway_exit(gateway);
}
self.gateway_exits().insert(gateway, (exit, exact));
self.save_gateway_exits()
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -807,25 +893,39 @@ impl Game<'_> {
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let known = self.gateway_exits().get(&gateway).copied();
let job = match known {
Some((exit, exact)) => GatewayJob::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
GatewayJob::Known {
exit: END_SPAWN,
exact: true,
}
let mut known = self.gateway_exits().get(&gateway).copied();
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
// side file kept for it moves there, and the entry is dropped, so removing or replacing
// the exit (`/data remove block`) is not undone by an old entry.
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
if let Some(saved) = known
&& self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
if adopt_saved_exit(g, saved) {
self.world.modified.insert(chunk);
}
None => GatewayJob::Find { gateway },
};
known = None;
let _ = self.forget_gateway_exit(gateway);
}
let job = GatewayJob::for_gateway(
gateway,
self.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2])),
known,
);
let destination = match job {
GatewayJob::Known { exit, .. } => Vec3::new(
f64::from(exit[0]) + 0.5,
f64::from(exit[1]),
f64::from(exit[2]) + 0.5,
),
GatewayJob::Find { gateway } => {
GatewayJob::Find { gateway, .. } => {
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero()
* 1024.0;
@@ -882,12 +982,10 @@ impl Game<'_> {
[p[0], p[1] + 1, p[2]]
};
match job {
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
exit
} else {
find_exit(self, exit)
})),
GatewayJob::Find { gateway } => {
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -947,8 +1045,11 @@ impl Game<'_> {
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
self.place_gateway(exit)?;
self.set_gateway_exit(exit, gateway, false)?;
self.set_gateway_exit(gateway, exit, false)?;
Ok(bottom_center(find_exit(self, exit)))
// setExitPosition keeps the gateway's own exactTeleport.
self.set_gateway_exit(gateway, exit, exact)?;
Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})))
}
}
}
@@ -1240,8 +1341,103 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: full,
glint_uv: [0.0, 0.0],
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::containers::EndGateway;
#[test]
fn a_saved_exit_moves_into_a_gateway_without_one() {
let saved = ([8, 100, 0], true);
let mut bare = EndGateway::default();
assert!(adopt_saved_exit(&mut bare, saved));
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
assert!(bare.exact_teleport);
// The block entity's own exit wins over a stale entry.
let mut own = EndGateway {
exit_portal: Some([1, 2, 3]),
..Default::default()
};
assert!(!adopt_saved_exit(&mut own, saved));
assert_eq!(own.exit_portal, Some([1, 2, 3]));
assert!(!own.exact_teleport);
}
#[test]
fn a_new_exit_keeps_the_gateways_exact_teleport() {
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
let plain = BlockEntity::EndGateway(EndGateway::default());
assert!(exact_teleport(Some(&exact)));
assert!(!exact_teleport(Some(&plain)));
assert!(!exact_teleport(None));
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
let exit = [8, 100, 0];
// Exact: the exit itself, nothing is searched for.
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
assert_eq!(
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
[8, 111, 0]
);
}
#[test]
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
let gateway = [702, 100, 0];
let exit = [8, 100, 0];
for exact in [true, false] {
let entity = BlockEntity::EndGateway(EndGateway {
exit_portal: Some(exit),
exact_teleport: exact,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
GatewayJob::Known { exit, exact },
);
}
let empty = BlockEntity::EndGateway(EndGateway::default());
for entity in [None, Some(&empty)] {
assert_eq!(
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
GatewayJob::Known { exit, exact: false }
);
assert_eq!(
GatewayJob::for_gateway(gateway, entity, None),
GatewayJob::Known {
exit: END_SPAWN,
exact: true
}
);
let nearby = [96, 75, 0];
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(nearby, Some(&exact), None),
GatewayJob::Find {
gateway: nearby,
exact: true
}
);
assert_eq!(
GatewayJob::for_gateway(nearby, entity, None),
GatewayJob::Find {
gateway: nearby,
exact: false
}
);
}
}
}
@@ -60,12 +60,27 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
/// at 20 or more. The render distance is the client setting, capped by
/// the server's view distance when connected
/// (Options.getEffectiveRenderDistance).
pub(super) fn view_scale(&self) -> f64 {
let distance = match &self.remote {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
}
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the entity distance option.
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
let edge = (size.x + size.y + size.z) / 3.0;
let edge = if edge.is_nan() { 1.0 } else { edge };
edge * 64.0 * f64::from(view_scale)
edge * 64.0 * view_scale
}
impl Game<'_> {
+14 -9
View File
@@ -572,16 +572,16 @@ impl Game<'_> {
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
let block_count = if interaction == BlockInteraction::Keep {
0
} else {
targets.len()
};
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
let block_count = targets.len();
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion(center, radius, block_count, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, radius, block_count);
self.particles
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -598,6 +598,11 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
let key = (pos[0], pos[1], pos[2]);
@@ -64,6 +64,25 @@ impl Game<'_> {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if self
.res
.facts()
.get(block.state)
.name
.starts_with("minecraft:suspicious_")
{
// BrushableBlock.onBrokenAfterFall.
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.level_events.push(LevelEvent::LevelEvent {
id: 2001,
pos: at,
data: block.state.0 as i32,
});
self.level_events.push(LevelEvent::GameEvent {
pos: at,
event: "minecraft:block_destroy",
state: None,
});
}
return;
}
+49 -6
View File
@@ -7,12 +7,13 @@ use std::sync::Arc;
#[derive(Clone, Debug)]
pub(super) struct Fangs {
pos: [f64; 3],
yaw: f32,
pub id: u32,
pub pos: [f64; 3],
pub yaw: f32,
warmup: i32,
life: i32,
pub life: i32,
owner: u32,
started: bool,
pub started: bool,
}
#[derive(Default)]
pub(super) struct FangState {
@@ -21,6 +22,11 @@ pub(super) struct FangState {
}
impl Game<'_> {
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
self.next_entity += 1;
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
}
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
if self.fangs.model.is_none() {
self.fangs.model = horizon_client::mobs::load_model(
&self.options.resources,
@@ -29,6 +35,7 @@ impl Game<'_> {
.map(Arc::new);
}
self.fangs.list.push(Fangs {
id,
pos: at,
yaw,
warmup: delay,
@@ -37,6 +44,30 @@ impl Game<'_> {
started: false,
});
}
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
if event == 4
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
{
f.started = true;
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
1.0,
);
}
}
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
pub(super) fn tick_remote_fangs(&mut self) {
for f in &mut self.fangs.list {
if f.started {
f.life -= 1;
}
}
}
/// `EvokerFangs.tick`.
pub(super) fn tick_fangs(&mut self) {
let mut list = std::mem::take(&mut self.fangs.list);
@@ -50,7 +81,8 @@ impl Game<'_> {
}
if !f.started {
f.started = true;
self.audio.play(
self.lan_entity_event(f.id as i32, 4, f.pos);
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
@@ -71,12 +103,23 @@ impl Game<'_> {
let b = horizon_core::geometry::Aabb::new(lo, hi);
// Every player in the box, the server's players too.
let pos = f.pos;
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
// owner lives, plain magic (nobody to name) once it is gone.
let owner = self
.mobs
.iter()
.any(|m| m.id == f.owner && m.alive())
.then_some(f.owner);
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
let kind = match owner {
Some(_) => "minecraft:indirect_magic",
None => "minecraft:magic",
};
g.mob_hits_player_caused(6.0, pos, kind, owner);
}
};
bite(self);
@@ -643,6 +643,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
+5 -2
View File
@@ -311,8 +311,9 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
for f in &self.item_frames {
let entity_start = out.len();
let map = f.item.as_ref().and_then(super::maps::map_id);
@@ -377,6 +378,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
+103 -2
View File
@@ -20,7 +20,8 @@ use horizon_core::{
};
use horizon_world::{
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
worldgen::terrain::TerrainBlockStates,
};
/// The loaded world as the spawn searches query it.
@@ -109,7 +110,17 @@ impl Game<'_> {
while let Some((dx, dz)) = self.spawn_spiral.current() {
if self.spawn_spiral.tested() {
let (cx, cz) = (ox + dx, oz + dz);
if !self.streaming.is_lit(cx, cz) {
if self.options.dedicated.is_some() {
// getChunk(FULL) of the tested chunk alone: its 3x3 is decorated.
if !self.world.chunks.contains_key(&(cx, cz)) {
let area = [((cx, cz), -1)];
if self.streaming.extra != area {
self.streaming.extra = area.to_vec();
self.streaming.rank_full_task(cx, cz);
}
return Ok(None);
}
} else if !self.streaming.is_lit(cx, cz) {
self.streaming.center = (cx, cz);
return Ok(None);
}
@@ -121,6 +132,96 @@ impl Game<'_> {
}
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
}
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
/// each side that can hold one, on the first free column of a shuffled
/// walk over the chunk. Skipped while the chunk is not loaded.
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
use horizon_client::containers::BlockEntity;
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
let registry = &self.pack.assets.registry;
let (Some(chest), Some(torch)) = (
registry.default_state("minecraft:chest"),
registry.default_state("minecraft:torch"),
) else {
return Ok(());
};
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
let random = &mut self.world.random;
let mut shuffled = |min: i32| -> [i32; 16] {
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
for end in (2..=16usize).rev() {
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
values.swap(end - 1, other);
}
values
};
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
for x in xs {
for z in zs {
let y = self
.spawn_columns()
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
continue;
}
self.set_block_clients([x, y, z], chest)?;
let seed = self.world.random.next_long();
self.world
.block_entities
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
self.load_block_entity_nbt(
[x, y, z],
&format!(
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
),
);
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let at = [x + dx, y, z + dz];
let survives = self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
});
if survives {
self.set_block_clients(at, torch)?;
}
}
return Ok(());
}
}
Ok(())
}
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
state,
UPDATE_CLIENTS,
);
});
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape_with_context(
&mut scene,
self.world.block(pos[0], pos[1], pos[2]),
BlockPos::new(pos[0], pos[1], pos[2]),
&CollisionContext::empty(false),
)
.is_ok_and(|shape| shape.is_empty())
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
@@ -52,6 +52,8 @@ struct Flags {
/// server follows while it runs (IntegratedServer.tickServer).
render_distance: AtomicI32,
simulation_distance: AtomicI32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
}
/// Work the window hands its server thread (`MinecraftServer.execute`).
@@ -69,6 +71,10 @@ impl Integrated {
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
/// `MinecraftServer.isCurrentlySaving`.
pub fn is_saving(&self) -> bool {
self.flags.saving.load(Ordering::Relaxed)
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
@@ -148,6 +154,12 @@ pub(super) struct Serving {
/// is out of its place: applied after the tick.
pending: Option<Publish>,
}
impl Serving {
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
pub(super) fn set_saving(&self, saving: bool) {
self.flags.saving.store(saving, Ordering::Relaxed);
}
}
enum Publish {
Open(TcpListener, LanSettings),
Close,
@@ -408,6 +420,8 @@ impl Game<'_> {
s.flags.published.store(0, Ordering::Relaxed);
}
if let Some(mut host) = self.lan.take() {
// PlayerList.removeAll: the owner and every guest leave the game.
host.award_leave_game_to_all();
for g in &host.hub.guests {
self.save_guest(&host, g);
}
+36 -8
View File
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
profiles: &'a RefCell<horizon_client::skins::Resolver>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
// A `Dynamic` profile (a name or id only) resolves through the
// services (`PlayerSkinRenderCache.getOrDefault`).
Some(horizon_client::skins::head_skin(
profile,
&mut self.profiles.borrow_mut(),
&mut self.skins.borrow_mut(),
|location| self.sprites.contains_key(location),
))
}
}
@@ -194,6 +195,7 @@ impl Game<'_> {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
profiles: &self.skin_profiles,
}
}
/// The client level as item models read it: worked out once a frame (a
@@ -296,8 +298,32 @@ impl Game<'_> {
x,
y,
self.started.elapsed().as_secs_f32(),
self.settings.file.double("glintSpeed") as f32,
self.settings.file.double("glintStrength") as f32,
);
}
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.gui
.atlas
.sprites
.get("misc/enchanted_glint_item")
.copied()
.map(|_| item_render::Glint {
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
millis: self.started.elapsed().as_secs_f32() * 1000.0,
scale: 0.5,
})
}
/// The glint scroll phase shared by world items and armour
/// (`item_render::Glint::scroll_ticks`).
pub(super) fn glint_ticks(&self) -> f32 {
item_render::Glint::scroll_ticks(
self.started.elapsed().as_secs_f32() * 1000.0,
self.settings.file.double("glintSpeed") as f32,
)
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
pub(super) fn submit_item_world(
@@ -311,6 +337,7 @@ impl Game<'_> {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -332,6 +359,7 @@ impl Game<'_> {
out,
light,
lights: None,
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
+142 -12
View File
@@ -11,7 +11,7 @@ use horizon_client::{
chunks::ChunkEncoder,
data::SyncData,
entity::{EntityState, States},
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use std::{
collections::HashMap,
@@ -91,6 +92,33 @@ pub(super) struct LanHost {
states: lan::entity::States,
}
impl LanHost {
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
/// player is saved. Once per guest, however often they are saved.
pub(super) fn award_leave_game(&mut self, conn: u32) {
if let Some(play) = self.play.get_mut(&conn)
&& !play.left_game
{
play.left_game = true;
play.stats.custom("leave_game", 1);
}
}
/// `PlayerList.removeAll`: every player who joined leaves with the world.
pub(super) fn award_leave_game_to_all(&mut self) {
let conns: Vec<u32> = self
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| g.conn)
.collect();
for conn in conns {
self.award_leave_game(conn);
}
}
}
/// The player who owns an integrated server's world: a guest whose state is
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
/// single player.
@@ -191,6 +219,17 @@ fn mob_meta(
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
// swirls of its effects (the ones a mob has are visible, not ambient).
meta.extend(
hub.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, false)),
),
);
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
// weapon).
@@ -236,6 +275,27 @@ fn mob_meta(
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
"minecraft:snow_golem" => {
if e.sheared {
meta.push((16, serializer::BYTE, Value::Byte(0)));
}
defaults.push((16, serializer::BYTE, Value::Byte(16)));
}
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
"minecraft:mooshroom" => {
if e.variant == 1 {
meta.push((17, serializer::INT, Value::Int(1)));
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
"minecraft:piglin" => {
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
"minecraft:sheep" => {
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
@@ -350,6 +410,14 @@ fn mob_meta(
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
// rears up; the client animates it.
"minecraft:polar_bear" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
"minecraft:evoker" | "minecraft:illusioner" => {
if e.variant2 != 0 {
@@ -664,6 +732,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
host.award_leave_game_to_all();
for g in &host.hub.guests {
self.save_guest(&host, g);
}
@@ -675,6 +744,7 @@ impl Game<'_> {
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -845,6 +915,16 @@ impl Game<'_> {
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
bed: self.sleeping,
body: BodyFlags {
gliding: p.fall_flying,
swimming: p.swimming,
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
crouching: p.pose == horizon_client::pose::Pose::Crouching,
spin_attack: p.auto_spin_attack_ticks > 0,
on_fire: p.fire_ticks > 0,
invisible: self.potions.effects.get("invisibility").is_some(),
glowing: self.potions.effects.get("glowing").is_some(),
},
})
}
@@ -962,6 +1042,16 @@ impl Game<'_> {
host.hub.block_event(pos, b0, b1, state);
}
let entities = states.as_slice();
// An entity that left the level since the last tick (a snowball
// that hit) is dropped by the tracker below: its event goes out
// first, as `Level.broadcastEntityEvent` precedes the discard.
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
.into_iter()
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
self.lan_entity_events = later;
for (id, event, at) in gone {
host.hub.entity_event(id, event, at);
}
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
@@ -1054,6 +1144,9 @@ impl Game<'_> {
}
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
@@ -1243,8 +1336,9 @@ impl Game<'_> {
self.lan_entity_events.push((id, event, at));
}
}
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
/// attacker's client and observers once the hub is back in place.
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
/// the hub is back in place.
pub(super) fn critical_hit(
&mut self,
attacker: Option<u32>,
@@ -1252,21 +1346,50 @@ impl Game<'_> {
feet: [f64; 3],
width: f64,
height: f64,
magic: bool,
) {
use horizon_client::particles::{ParticleOptions, ParticleType};
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
let particle = if magic {
ParticleType::EnchantedHit
} else {
ParticleType::Crit
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
if self.audio.capture.is_some() {
self.lan_critical_hits.push((attacker, id));
self.lan_critical_hits.push((attacker, id, magic));
}
}
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:block")
{
host.hub.broadcast_near(
at,
32.0,
&C::LevelParticles(play::LevelParticles {
particle: horizon_protocol::entity_data::Particle {
id,
data: Some(horizon_protocol::entity_data::ParticleData::State(
state.0 as i32,
)),
},
override_limiter: false,
always_show: false,
pos: at,
dist: [0.25, 0.0625, 0.25],
max_speed: [0.05; 3],
count: 10,
randomization: 0,
}),
None,
);
}
}
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
@@ -1339,13 +1462,13 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
for (attacker, target) in critical_hits {
for (attacker, target, magic) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
}) else {
continue;
};
host.hub.critical_hit(attacker, target);
host.hub.critical_hit(attacker, target, magic);
}
}
@@ -1428,6 +1551,13 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
+72 -14
View File
@@ -74,6 +74,9 @@ pub(super) struct GuestPlay {
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
pub(super) left_game: bool,
last_inventory: u64,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
@@ -165,6 +168,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
/// How many projectiles there were: those added since are the guest's.
@@ -389,6 +394,7 @@ impl Game<'_> {
self.lan_acting_uuid.replace(g.uuid),
);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let modifiers = std::mem::replace(
@@ -449,6 +455,7 @@ impl Game<'_> {
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -508,6 +515,7 @@ impl Game<'_> {
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -529,6 +537,17 @@ impl Game<'_> {
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
// ServerPlayer.connection.send: the guest's own entity events.
let entity_id = host.hub.guests[i].entity_id;
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
host.hub.send_to(
conn,
&C::EntityEvent(play::EntityEvent {
id: entity_id,
event,
}),
);
}
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
host.hub.send_to(
@@ -1216,6 +1235,12 @@ impl Game<'_> {
{
self.guest_carried_back(host, gi);
}
S::SelectBundleItem(p) => {
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
}
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
let slots = host.hub.guests[gi]
@@ -1910,21 +1935,8 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1961,6 +1973,24 @@ impl Game<'_> {
play.digging = None;
}
}
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl), after
// LivingEntity.tick's use: an entry a finished use starts has 20
// ticks left when the next tick's packets are handled, as the
// guest's own client counts.
let play = host.play.entry(conn).or_default();
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
@@ -2319,7 +2349,35 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
// LivingEntity.checkFallDamage: the crumbs reach every player.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
position,
count,
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
} => {
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
};
if sound {
let name = "minecraft:block.honey_block.slide";
let source = horizon_client::settings::sound_source(name) as i32;
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
}
if particles {
self.honey_slide_particles(at);
host.hub.entity_event(id, 53, at);
}
}
_ => {}
}
}
@@ -576,16 +576,18 @@ impl Game<'_> {
/// A guest left: what their crafting grids and workstation held goes back into their
/// inventory before it is saved (dropped where they stood if full).
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
let play = host.play.remove(&g.conn);
// The guest's play state stays: their statistics are awarded and saved
// after this (`HostEvent::Left`), which removes it.
let play = host.play.get_mut(&g.conn);
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
g.craft_result = None;
if let Some(play) = play {
if let Some((_, _, menu, _)) = play.crafting.table {
if let Some((_, _, menu, _)) = play.crafting.table.take() {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
if let Some(station) = &play.station {
grid.extend(super::lan_stations::station_inputs(station));
if let Some(station) = play.station.take() {
grid.extend(super::lan_stations::station_inputs(&station));
}
}
if grid.is_empty() {
@@ -304,6 +304,9 @@ impl Game<'_> {
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
for f in &self.fangs.list {
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
}
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
@@ -354,6 +357,20 @@ impl Game<'_> {
a.is_some_and(|a| a.in_ground) || p.stuck,
));
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
K::Trident => {
let a = p.arrow.as_deref();
e.meta.extend(arrow_meta(
false,
a.is_some_and(|a| a.no_physics),
0,
a.is_some_and(|a| a.in_ground) || p.stuck,
));
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
}
}
// WitherSkull.DATA_DANGEROUS.
K::WitherSkull { dangerous: true } => {
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
@@ -1,7 +1,8 @@
//! Particles a server sends its players and its clients draw:
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
@@ -81,6 +82,66 @@ impl Game<'_> {
}
}
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
/// landing block's crumbs: drawn in this window, when it has one, and
/// sent to each player within 32 blocks. `host` is the hub of a guest's
/// turn, which is out of `self.lan` meanwhile.
pub(super) fn landing_dust(
&mut self,
host: Option<&LanHost>,
state: BlockStateId,
at: Vec3,
count: i32,
speed: f64,
) {
let at = [at.x, at.y, at.z];
if self.serving.is_none() && self.options.dedicated.is_none() {
let block = [
at[0].floor() as i32,
(at[1] - 0.2).floor() as i32,
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
self.particles.send_particles(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
at,
[0.0; 3],
speed,
count as u32,
);
}
}
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
return;
};
let Some(particle) = Self::hub_particle(
hub,
&ParticleOptions::simple(ParticleType::Block),
Some(state),
) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: [speed as f32; 3],
count,
randomization: 0,
});
for g in hub.guests.iter().filter(|g| hub.here(g)) {
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
/// network id and the data it carries (`state` for the block particles).
/// None while not serving, or for a particle whose data isn't sent from
@@ -89,13 +150,19 @@ impl Game<'_> {
&self,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
}
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
/// guest's turn).
fn hub_particle(
hub: &Hub,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
let name = format!("minecraft:{}", options.ty.name());
let id = self
.lan
.as_ref()?
.hub
.registry_id("minecraft:particle_type", &name)?;
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
let c = options.color;
let data = match options.ty {
@@ -254,8 +321,14 @@ impl Game<'_> {
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
let emitter = if radius < 2.0 {
pub(super) fn lan_explode(
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
let emitter = if small {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
+12 -5
View File
@@ -37,6 +37,8 @@ pub(super) struct Strike {
amount: f32,
scale: f32,
crit: bool,
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
@@ -109,7 +111,7 @@ impl Game<'_> {
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit) = self.melee_damage("minecraft:player");
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
@@ -125,6 +127,7 @@ impl Game<'_> {
amount,
scale,
crit,
magic,
from,
sweep,
knockback: (strength, xd, zd),
@@ -257,7 +260,7 @@ impl Game<'_> {
} else {
Default::default()
};
if hurt && s.crit {
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
@@ -267,13 +270,17 @@ impl Game<'_> {
};
if let Some((id, p)) = target {
let d = p.dimensions;
self.critical_hit(
s.attacker,
id,
let (feet, width, height) = (
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
);
if s.crit {
self.critical_hit(s.attacker, id, feet, width, height, false);
}
if s.magic {
self.critical_hit(s.attacker, id, feet, width, height, true);
}
}
}
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
+7
View File
@@ -104,6 +104,9 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if let Some(h) = holder {
if h == Holder::Player {
m.extra.leash_player = Some(player_uuid);
}
m.extra.leash = Some(h);
m.extra.leash_pending = None;
} else if m.age > 100 {
@@ -201,7 +204,9 @@ impl Game<'_> {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
self.mobs[index].extra.leash_player = Some(holder);
self.lead_sound("tied", at);
// The lead is always used up.
if let Some(stack) = self.held_mut().as_mut() {
@@ -381,6 +386,8 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -3,6 +3,7 @@
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
@@ -140,6 +141,68 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
let ty = if instant {
ParticleType::InstantEffect
} else {
ParticleType::Effect
};
// Vec3.atBottomCenterOf.
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
];
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
let r = self.particles.level_random();
let crumbs: Vec<[f64; 3]> = (0..8)
.map(|_| {
[
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
]
})
.collect();
for v in crumbs {
self.particles.add(&crumb, at, v);
}
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
let color = [channel(16), channel(8), channel(0)];
for _ in 0..100 {
let r = self.particles.level_random();
let dist = r.next_double() * 4.0;
let angle = r.next_double() * std::f64::consts::PI * 2.0;
let velocity = [
angle.cos() * dist,
0.01 + r.next_double() * 0.5,
angle.sin() * dist,
];
let brightness = 0.75 + r.next_float() * 0.25;
let options = ParticleOptions {
color: [
color[0] * brightness,
color[1] * brightness,
color[2] * brightness,
1.0,
],
scale: dist as f32,
..ParticleOptions::simple(ty)
};
self.particles.add(
&options,
[
at[0] + velocity[0] * 0.1,
at[1] + 0.3,
at[2] + velocity[2] * 0.1,
],
velocity,
);
}
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+8 -2
View File
@@ -75,7 +75,7 @@ pub(super) struct Parked {
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -120,6 +120,10 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
}
}
impl Game<'_> {
@@ -192,6 +196,7 @@ impl Game<'_> {
let mut locator = self.end.locator();
swap(&mut locator, &mut p.locator);
self.end.set_locator(locator);
self.share_spawn_conditions();
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
@@ -399,7 +404,8 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task keeps the workers fed until the next tick.
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
let now = Instant::now();
if *next > now {
+6
View File
@@ -1133,6 +1133,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -1180,6 +1182,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -1251,6 +1255,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
}
+39 -4
View File
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
pub yaw: f32,
pub previous_yaw: f32,
pub flipped: bool,
on_ground: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
fall_distance: f64,
pub hurt_time: i32,
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
pub first_tick: bool,
}
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
@@ -186,6 +188,7 @@ impl Minecart {
fuse: -1,
enabled: true,
loot: None,
first_tick: true,
}
}
pub fn bounds(&self) -> Aabb {
@@ -536,6 +539,7 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.hurt_time = (c.hurt_time - 1).max(0);
@@ -1297,6 +1301,7 @@ impl Game<'_> {
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1340,9 +1345,8 @@ impl Game<'_> {
return;
};
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
// edge, scaled by the entity distance option.
let range =
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
@@ -1509,6 +1513,10 @@ impl Minecart {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
@@ -1558,6 +1566,7 @@ impl Minecart {
_ => 0.0,
};
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
@@ -1582,4 +1591,30 @@ mod tests {
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
#[test]
fn a_cart_saves_as_never_ticked_until_it_ticks() {
use horizon_world::nbt::Tag;
let generated = Tag::compound([
("id", Tag::String("minecraft:chest_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("HasTicked", Tag::Byte(1)),
("Fire", Tag::Short(0)),
]);
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
cart.first_tick = false;
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
}
}
+75 -1
View File
@@ -1,6 +1,7 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items.
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
items::ItemStack,
@@ -203,6 +204,8 @@ impl Game<'_> {
layer: overlay,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -218,6 +221,7 @@ impl Game<'_> {
) {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
@@ -244,6 +248,76 @@ impl Game<'_> {
);
}
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
fn worn_block_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
let species = mob.species();
let on_head = match species {
Species::SnowGolem => !mob.extra.sheared,
Species::Mooshroom => !mob.is_baby(),
_ => false,
};
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
return;
}
let registry = &self.pack.assets.registry;
let block = match species {
Species::SnowGolem => "minecraft:carved_pumpkin",
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
_ => "minecraft:red_mushroom",
};
let Some(state) = registry.default_state(block) else {
return;
};
let alpha = alpha as f32;
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
else {
return;
};
let unit = Mat4::from_translation(Vec3::splat(-0.5));
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
let transforms = if species == Species::SnowGolem {
vec![
head * at(0.0, -0.34375, 0.0)
* spin(180.0)
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
* unit,
]
} else {
let base = render::base_transform(mob, alpha, camera);
vec![
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
base * at(0.2, -0.35, 0.5)
* spin(42.0)
* at(0.1, 0.0, -0.6)
* spin(-48.0)
* flip
* unit,
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
]
};
// The hurt tint reaches these blocks as it does the body.
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
for t in transforms {
let start = out.len();
self.block_model_vertices(state, 0, t, light, true, out);
for vertex in &mut out[start..] {
vertex.layer = overlay;
}
}
}
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
+4 -1
View File
@@ -371,7 +371,10 @@ impl Game<'_> {
}
/// The chat's tree after the running mods changed.
fn sync_mod_commands(&mut self) {
let mut names = self.mods.mods.commands().peekable();
// In-frame, the window runs server mods too: their names, free text
// here (the server parses their arguments).
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
let mut names = self.mods.mods.commands().chain(server).peekable();
self.mods.tree = names
.peek()
.is_some()
+13
View File
@@ -126,6 +126,19 @@ impl Game<'_> {
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
return false;
}
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
// seat, inventory or feeding to give. Camels seat two and check their
// passengers themselves.
let id = m.id;
if !matches!(species, Species::Camel | Species::CamelHusk)
&& self
.mobs
.iter()
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
{
return false;
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
@@ -14,7 +14,9 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) {
let partial = alpha as f32;
for &(p, piston) in &self.world.moving_pistons {
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
for (p, piston) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more