Compare commits

..
131 Commits
Author SHA1 Message Date
Ludovic Marie 0f56e3165a Merge pull request #12227 from horizon-rust/fix/keyring-timeout-12179
🤖 Agent: keyring calls now give up after 20 s on a thread of their own; a call still stuck makes later ones fail at once, so the account lock is never held forever (tested with a keyring that never answers).
2026-11-08 04:05:55 +01:00
Horizon agentandClaude Opus 5.5 98b4d049fa launcher: give up on a keyring that never answers (#12179)
A Secret Service daemon that never answers (two keyrings racing for the
D-Bus name, a locked collection with no working prompter) held the
account lock forever: Play stayed on "Starting…" and printed nothing.

Every keychain call now runs on a thread and gives up after 20 s
(Bounded): on Linux the secrets file takes writes as before, and a
sign-in that can't be read says so instead of "no owner". While a
given-up call is still stuck, later calls fail at once. Play prints one
stderr line per step, and `account list` prints why a check failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:04:29 +01:00
Ludovic Marie 6c2498236d Merge pull request #12222 from horizon-rust/fix/new-world-packs-test-meta
🤖 Agent: queued first. Main's correctness job fails on this test since #12086 met the folder metadata gate (84f695992), and every PR that merges main fails on it; verify is green here.
2026-11-08 04:02:16 +01:00
Ludovic Marie 7ddbb41f86 Merge pull request #12141 from RINNECODER/up2/compute-workgroups-limit
🤖 Agent: queued. A dispatch over 65,535 workgroups per dimension (the wgpu limit) is now split into several dispatches with the base passed along, so packs whose compute pass sizes from the screen still run at 4K and up.
2026-11-08 03:58:06 +01:00
Ludovic Marie 4b38e08b7f Merge pull request #11950 from senjinthedragon/item-entity-material-maps
🤖 Agent: queued. LabPBR `_n`/`_s` maps now reach entities, items, block entities and the hand; `set_shared_material_maps` now runs after `set_custom_sky` in loading_window.rs, so a pack reload binds both.
2026-11-08 03:58:03 +01:00
Horizon agentandClaude Opus 5.5 30c20a7a54 datapack test: give the new-world packs a pack section
folder_metadata_present (Pack.readPackMetadata) now requires a pack section
with a description, so the {} metadata in #12086's test hid both packs and
seed_create_world returned None. This failed every PR's verify on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:43:49 +01:00
Ludovic Marie f96d9e89b5 Merge pull request #10283 from MSedgeMC/fix/cloud-save
🤖 Agent: removing an entity's chunk now drops its area-effect clouds too (breath_clouds.retain), and saves keep entity_base_tag's Entity fields. On main merge 8b583a15e, save_roundtrip, entity_nbt_boolean, orb_saved_uuid, stand_fall_save, conversion_water_save, mob_pickup_save and potion_effect_icon pass. command_exec's only 2 mismatches are main's gateway_item case (same on bare main b13dbf068).
2026-11-08 03:41:29 +01:00
Ludovic Marie bca5833f8d Merge pull request #12185 from Ragbie/fix/shulker-hurt-teleport-now
🤖 Agent: queued. A shulker's hurt teleport now runs inside the hurt, before the bullet's levitation, and riders get off where it stood (`unRide`); NoAI shulkers never teleport, as `teleportSomewhere` requires.
2026-11-08 03:31:45 +01:00
Ludovic Marie 4a8a101391 Merge pull request #12182 from TheMrZZ/fix/h283-built-in-component-delivery
🤖 Agent: queued. Four components (`provides_trim_material`, `provides_banner_patterns`, `can_place_on`, `can_break`) are now encoded in outgoing stacks with the synced registry IDs; anything unsupported drops the whole buffer, as before.
2026-11-08 03:31:41 +01:00
Ludovic Marie 07aab11e71 Merge pull request #12181 from TheApple05/codex/older-server-mapping-generation
🤖 Agent: queued. Every mapping value stays the same; the IDs the translators read come from generated pinned constants, and `--check` catches drift.
2026-11-08 03:31:37 +01:00
Ludovic Marie bc21da03bc Merge pull request #12174 from NikitaMGrimm/saved-ticks-startup
🤖 Agent: queued. The server now waits for the saved tickets' chunks before the first tick (`prepareLevels`), so their saved ticks unpack at the time they were saved at; the wait gives up after 600 s.
2026-11-08 03:31:33 +01:00
Ludovic Marie 93fc4b2ba9 Merge pull request #12159 from itarqos5/tools/world-compare
🤖 Agent: queued. Tools only: `world_compare.py` and `perf_bench.py --seed`, no generator change; the seed report it answers is documented with the two seeds compared.
2026-11-08 03:31:30 +01:00
Ludovic Marie f3cc9257df Merge pull request #12080 from KBroneZ/claude/instances-folder
🤖 Agent: queued. `--folder` on the CurseForge `Instances` folder now finds its instances, and a Prism `instances` folder (same name where case is ignored) is not mistaken for it.
2026-11-08 03:31:26 +01:00
Ludovic Marie 0a407714e0 Merge pull request #12184 from sergue212fbright/fix/lod-slab-visibility
🤖 Agent: queued. Far terrain treats slabs as full cells like stairs, so a waterlogged slab keeps its own material instead of becoming water; the test walks every real slab state.
2026-11-08 03:31:22 +01:00
Ludovic Marie 3bb4e7827c Merge pull request #12164 from KBroneZ/claude/es-empty-shaderpacks
🤖 Agent: queued. An empty `shaderpacks/` (CurseForge and Sodium create it) no longer counts as a shader pack, for folders and archives alike; tests cover both.
2026-11-08 03:31:19 +01:00
Ludovic Marie 546eab04b7 Merge pull request #12168 from NikitaMGrimm/orb-piston-ground
🤖 Agent: queued. After every server push the ground flag is set to `verticalCollisionBelow` (`Entity.move` → `setOnGroundWithMovement`, which runs when `isLocalInstanceAuthoritative`), so a sideways push off a block clears it; #12167 builds on this.
2026-11-08 03:31:15 +01:00
Ludovic Marie 4e6920a319 Merge pull request #11829 from senjinthedragon/entity-motion-objects
🤖 Agent: queued. `at_velocity` now covers boats, minecarts, armor stands, frames, paintings, TNT, falling blocks, block entities and moving pistons; the `falling_block_visible` doc comment is back on its function.
2026-11-08 03:19:38 +01:00
Ludovic Marie c214f59711 Merge pull request #12076 from TheMrZZ/fix/h246-storage-alias
🤖 Agent: queued. A wildcard `data modify storage` that fails part way keeps the edits made so far, as vanilla's in-place NbtPath edit does; main merged in.
2026-11-08 03:19:34 +01:00
Ludovic Marie 4a374440f4 Merge pull request #11883 from TheMrZZ/fix/h205-mob-health-boost
🤖 Agent: queued. Health Boost raises the max health modifier and clamps health down when it ends, as `HealthBoostMobEffect` does; #11934 builds on it.
2026-11-08 03:15:58 +01:00
Ludovic Marie b99e2087d3 Merge pull request #11848 from TheMrZZ/fix/h174-bell-block-entity
🤖 Agent: queued. Command reads keep the real bell block entity (and copper golem statue) instead of a synthesized one, so `data get block` matches vanilla's NBT; main merged in.
2026-11-08 03:15:55 +01:00
Ludovic Marie b84c97088f Merge pull request #11766 from TheMrZZ/fix/mob-effect-amplifier-codec
🤖 Agent: queued. Effect amplifiers decode as unsigned bytes (`Byte.toUnsignedInt`), so an amplifier of 200 no longer reads as -56; main merged in.
2026-11-08 03:15:51 +01:00
Ludovic Marie 57da7aecbc Merge pull request #11729 from TheMrZZ/fix/h195-command-damage-range
🤖 Agent: queued. `damage` predicate ranges compare the item's damage as `ItemDamagePredicate` does (range on damage, not durability); main merged in.
2026-11-08 03:15:47 +01:00
Ludovic Marie e93afc5ade Merge pull request #11728 from TheMrZZ/fix/summoned-leash-knot-lifecycle
🤖 Agent: queued. A summoned leash knot snaps to the block centre and dies without a fence, as `LeashFenceKnotEntity.survives` checks; main merged in.
2026-11-08 03:15:44 +01:00
Ludovic Marie a7296b75e0 Merge pull request #11708 from TheMrZZ/fix/h032-jukebox-saved-clock
🤖 Agent: queued. A jukebox without a song now saves no `ticks_since_song_started`, as `JukeboxSongPlayer` writes it only while playing; main merged in.
2026-11-08 03:15:40 +01:00
Ludovic Marie 36584650ea Merge pull request #12152 from RINNECODER/up2/glsl-atomic-struct-copy
🤖 Agent: queued. Patch in vendored naga: copies of structs or arrays holding atomics go member by member, up to 64 scalars; larger ones stay whole copies, as noted.
2026-11-08 03:12:06 +01:00
Ludovic Marie aee2c63c3b Merge pull request #12143 from RINNECODER/up2/pack-image-formats
🤖 Agent: queued. The added formats (RGB10_A2, RGB10_A2UI, 16-bit SNORM) map to the wgpu formats `image_format` already binds; formats a GPU can't store into are still left out with a note.
2026-11-08 03:12:01 +01:00
Ludovic Marie ef23545cef Merge pull request #12126 from hunixcode/fix/translatable-fallback-literal
🤖 Agent: queued. A translatable with a fallback keeps the fallback as a literal, as `TranslatableContents` does, instead of looking it up as another key.
2026-11-08 03:11:58 +01:00
Ludovic Marie 92c708b3fe Merge pull request #12125 from hunixcode/fix/panel-unspecified-host
🤖 Agent: queued. The panel accepts LAN browsers when bound to 0.0.0.0 or :: only; a loopback bind still refuses them, so nothing new faces the network.
2026-11-08 03:11:55 +01:00
Ludovic Marie c6f935414d Merge pull request #12124 from itarqos5/perf/terrain-interpolant-packing
🤖 Agent: queued. Passing terrain's interpolants in 5 locations instead of 12; visbuffer reads `TerrainOut` only internally, so packs see no difference.
2026-11-08 03:11:51 +01:00
Ludovic Marie 73231e735d Merge pull request #12114 from p-stzhuk/fix/legacy-intel-metal
🤖 Agent: queued. The wgpu-hal change is in `third_party/`, gated to legacy Intel Metal; other GPUs take the same path as before.
2026-11-08 03:11:47 +01:00
Ludovic Marie a71408736f Merge pull request #12093 from vnsmoreira/packs/core-sky
🤖 Agent: queued. Pack `core/sky` and `core/stars` shaders apply only when a resource pack ships them; without one the sky pipeline is unchanged.
2026-11-08 03:11:44 +01:00
Ludovic Marie 8d2900e6ce Merge pull request #12113 from kacpherek/parity/batch-e
🤖 Agent: queued. One cost: the stepped tracker paths add a Vec per tracked entity on the tracker tick; small, but it is the hot path, worth a look if MSPT moves.
2026-11-08 03:05:56 +01:00
Ludovic Marie cd1318bf02 Merge pull request #12111 from kacpherek/parity/batch-c
🤖 Agent: queued. Batch: sculk now hears bottles, golems, endermen and frames; portals dismount per vehicle tick as vanilla's `Entity.handlePortal` does.
2026-11-08 03:05:53 +01:00
Ludovic Marie 2f661c08ed Merge pull request #12109 from kacpherek/parity/batch-a
🤖 Agent: queued. Batch of server click answers; each case matches `Mob.interact`/`InteractionResult` and is in the batch's harnesses, which run in parity.
2026-11-08 03:05:49 +01:00
Ludovic Marie 1bf8766c1d Merge pull request #12103 from martipops/controller/direct-craft
🤖 Agent: queued. LT + A crafting a recipe repeatedly is new controller input; servers see the same clicks a mouse would send, so nothing server-side changes.
2026-11-08 03:05:46 +01:00
Horizon agentandClaude Opus 5.5 1f9493c4fc Keep falling_block_visible's doc comment on it
The motion helpers went in between the comment and the function it describes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:44:31 +01:00
Horizon agentandClaude Opus 5.5 ebf06768f1 Merge main into #11950
Main moved build_renderer to game/loading_window.rs; the shared material maps
are set there now, after the custom sky as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:28:17 +01:00
Horizon agentandClaude Opus 5.5 fd03679d85 Merge main into #11829
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:27:35 +01:00
Evrim CicekandClaude Opus 5.5 ca896819d7 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:24:28 +03:00
GlockSmithandOpenAI Codex GPT-6 0a8df72a16 Keep slab materials in distant terrain
Retain top and bottom slab states in LOD chunk summaries instead of
discarding dry slabs or replacing waterlogged slabs with water. Use the
same full-cell approximation as the existing partial-block filter.

Check real summaries, coarse reduction and emitted faces for every
prepared slab state. All 72 LOD tests pass, including resource-backed
coverage of 606 slab states across 101 materials.

Refs horizon-rust/horizon#12180.

Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-11-07 20:23:37 -05:00
TheMrZZandCodex 074aa1124f Encode four built-in item components for outgoing inventory stacks
Preserve typed holder and adventure-predicate payloads at the inventory bridge.

Co-Authored-By: Codex <[email protected]>
2026-11-08 02:15:11 +01:00
TheApple05andCodex GPT-6 703c0ea18b Generate pinned adapter IDs and record regeneration recipes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-08 01:13:25 +00:00
Horizon agent 8b583a15e1 Merge remote-tracking branch 'origin/main' into m10283
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-08 02:06:02 +01:00
Evrim CicekandClaude Opus 5.5 c0a3af67b0 A teleporting shulker throws off whoever rides it where it stood (Entity.unRide)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:57:52 +03:00
Nikita Maximilian Grimm aed804a26d Merge remote-tracking branch 'origin/main' into saved-ticks-startup 2026-11-08 01:49:01 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8ce0848a31 Harness: stop a server that never finishes starting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:48:34 +01:00
Horizon agentandClaude Opus 5.5 3c73be2517 Merge main into #12076
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:42 +01:00
Evrim CicekandClaude Opus 5.5 917ce67f0d Test: a shulker bullet's hit teleports an open shulker before it returns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:37 +03:00
Evrim CicekandClaude Opus 5.5 b11fe1da84 A shulker's hurt teleports it inside the hurt, as vanilla's does
Shulker.hurtServer calls teleportSomewhere() and hitByShulkerBullet() inside
the hurt. Mob::damage has no world, so the flags it leaves now run right
after the hurt: in the mob's own tick (hurt_teleports, also without AI) and
in the game layer (settle_hurt_teleports: /damage, explosions, projectile
hits, the creature outbox), before a bullet's levitation. teleportSomewhere
also refuses a NoAI shulker, keeps inside the world border and unRides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:36 +03:00
Evrim CicekandClaude Opus 5.5 fdb23b8ac6 Harness: a shulker's low-health teleport inside the hurt (/damage while frozen, poison past tick 281, NoAI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:36 +03:00
Horizon agentandClaude Opus 5.5 a505caa32a Merge main into #11883
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:50 +01:00
Horizon agentandClaude Opus 5.5 c7a2daa593 Merge main into #11848
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:43 +01:00
Horizon agentandClaude Opus 5.5 3a967d8838 Merge main into #11766
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:42:56 +01:00
Horizon agentandClaude Opus 5.5 bd967852e5 Merge main into #11729
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:39:31 +01:00
Horizon agentandClaude Opus 5.5 ecdecc8fd2 Merge main into #11728
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:38:55 +01:00
Horizon agentandClaude Opus 5.5 c4c084be94 Merge main into #11708
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:38:51 +01:00
Nikita Maximilian Grimm 6e72e2de7d Merge branch 'tnt-piston-ground' into orb-piston-ground 2026-11-08 01:29:33 +01:00
itarqos5andClaude Sonnet 5.5 7c34cfa6b2 tools: world_compare.py compares a vanilla and a Horizon world of one seed
Reads the seed, spawn, biomes, structure starts and blocks two saved 26.x worlds
share, with a run-to-run floor from a second world of one game. Run on seed 12345
and a large negative seed: the spawn, every biome and every structure start match,
terrain blocks match to 99.3 to 99.9%, and decoration sits at vanilla's own floor
except trees, a few points under it. perf_bench.py gets --seed to make the worlds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 00:21:56 +00:00
Nikita Maximilian Grimm 2877c7fbfa Merge remote-tracking branch 'origin/main' into saved-ticks-startup 2026-11-08 01:01:52 +01:00
KBroneZandClaude Sonnet 5.5 5be2aba2ae style(port-pack): rustfmt the shaderpacks test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:01:09 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 1b63e0abc2 Startup waits for the forced chunks before the first tick, so saved ticks there fire on time
MinecraftServer.prepareLevels waits for the chunks the restored tickets hold
(ChunkLoadCounter) before the first tick, and the first tick unpacks their
saved block and fluid ticks at the game time they were saved at. Horizon's
dedicated and integrated servers started ticking while the forced chunks were
still streaming in, and the saved ticks were unpacked from the tick the chunk
arrived, one tick or more late.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:58:28 +01:00
KBroneZandClaude Sonnet 5.5 56ec50a875 fix(port-pack): an empty shaderpacks folder is not a shader pack to convert
The CurseForge app, and mods such as Sodium, create `shaderpacks/` in an instance
even when the player never installed a shader pack. Import counted the folder
itself as one "shader pack, conversion pending" file and listed it under "not yet",
so a client-only instance such as one with Sodium, Lithium, Jade, malilib and
Litematica reported 4 "not yet" rows for 3 mods. A folder instance now reports
shaderpacks/ only when it holds something, and a zip only when it has a file
under its shaderpacks/ (a bare directory entry no longer counts).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 01:54:47 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 a20d3296b4 A sideways piston push no longer leaves an experience orb marked on ground
The orb kept its own vertical-only on-ground rule for a piston push. It
now takes the server's end-of-move rule that items, primed TNT and armor
stands use: on ground only when the push's downward part was blocked.

Fixes #12100

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:23:06 +01:00
RINNECODERandClaude Opus 5.5 eadbb41095 Shader packs: run a workGroups over 65535 per dimension
wgpu caps a dispatch at 65535 work groups per dimension and panics past it;
GLSL packs may ask for more (131072). A larger workGroups now runs as the
same number of groups in a shape within the limit, and main works out each
group's place in the declared dispatch, so gl_WorkGroupID,
gl_GlobalInvocationID and gl_NumWorkGroups read what they would there. The
shape uses every group exactly, so no group leaves before a barrier. A
count no shape holds skips the program with a note; an oversized
workGroupsRender dispatch is not run instead of panicking.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:22:07 -06:00
RINNECODERandClaude Opus 5.5 bc59734a29 Shader packs: copy a struct or array holding atomics as its declared type
naga's GLSL frontend turns a uint/int that an atomic*() call touches into
an atomic type after parsing. A load or store of a whole struct or array
holding such a member then had the upgraded type: `Cell c = buf.cells[i];`
and `uint copy[4] = shared_counts;` failed validation. A whole struct or
fixed-size array of integers in a buffer or shared memory is now loaded
and stored member by member, so each copy keeps its declared type.

Vendored-naga patch: third_party/naga/src/front/glsl/context.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:58 -06:00
RINNECODERandClaude Opus 5.5 626c60740d Shader packs: make an image in its layout qualifier's format
An image declared RGB10_A2, RGB10_A2UI or a 16-bit SNORM format was made
as an RGBA8, RGBA8UI or 8-bit SNORM texture, so a shader binding it as
layout(rgb10_a2) etc. was refused ("is rgb10_a2 but its texture is
Rgba8Unorm"). Each now gets the wgpu format of its GLSL qualifier; one the
GPU can't store into is left out with a note, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:58 -06:00
martipopsandClaude Opus 5.5 de0132b638 Controllers: the craft prompt says Quick Craft, and its setting's description is shorter
Two words, like the guide's other rows, and the same name as Quick Craft on
Servers. The exact rule (single player, LAN worlds from the server list) stays
in docs/controllers.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:12:25 -05:00
itarqos5andClaude Sonnet 5.5 3d5216e717 perf(render): pass terrain's interpolants in 5 locations, not 12
Terrain's vertex stage exported 20 scalars in 12 interpolant locations. A GPU's
vertex stage pays per location (AMD gives each a parameter slot): on an RX 580 the
12 cost 3.2 ns per quad and the same values in 5 cost 1.6. TerrainOut stays the type
the shader code uses; TerrainWire crosses the stage boundary, each component
interpolated on its own and the integers flat, so a pixel gets the same values.

RX 580, Vulkan, 1080p, render distance 16: village 523.7 -> 590.8 FPS (+12.8%),
forest 267.6 -> 323.7 (+21.0%).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-07 23:09:46 +00:00
hunix 14ba691e53 Components: keep a translatable fallback literal instead of looking it up as a key
The component parser translated a missing key's fallback as if it were another key, so a fallback that named a
real key showed that key's translation. It now builds the text through Text::translate_with_fallback, as
TranslatableContents.decompose does (Language.getOrDefault(key, fallback)), which also keeps the fallback in the
component's JSON. Fixes #12042.
2026-11-07 13:02:44 -10:00
hunix 877cb37978 Web panel: accept LAN browsers when bound to 0.0.0.0 or ::
Bound to every interface, the panel's Host check accepts any IP address with the bind's port (and localhost),
which a rebound DNS name can never be, and the login link names this computer's network address instead of
0.0.0.0. Fixes #12039.
2026-11-07 13:02:44 -10:00
Kacpherek 34194ab7ec test: give the remote mining fixture's remote_break_restricted the position it now takes 2026-11-07 23:47:06 +01:00
Kacpherek ec77214342 Send a bounce in the tick it happens: move the update clock to its next interval (ServerEntity.sendChanges) 2026-11-07 23:42:53 +01:00
PetroandOpenAI GPT-6 Luna bec6ac6448 Fix legacy Intel Metal startup and terrain rendering
Use compatible compute shaders and direct terrain draws on older Intel Metal.

Co-Authored-By: OpenAI GPT-6 Luna <[email protected]>
2026-11-07 23:42:25 +01:00
Kacpherek a2b3f669ef Swing at a wandering trader only for a named name tag, not a lead or an unnamed tag (NameTagItem, Entity.interact) 2026-11-07 23:42:05 +01:00
Kacpherek ce4f0d7074 Let shears in hand still take a camel's back seat or open a ridden mount's inventory (Entity.interact falls through to mobInteract) 2026-11-07 23:41:12 +01:00
Kacpherek 031037cee5 Send stepped move paths only for living entities, as vanilla's SteppedInterpolationTracker does 2026-11-07 23:39:00 +01:00
Kacpherek 70342ed68b Show your own crit particles on critical hits against other players on servers (Player.attackVisualEffects, LocalPlayer.crit) 2026-11-07 23:30:01 +01:00
Kacpherek ef6fc399f5 List the blocks an item can break or be placed on in its tooltip (AdventureModePredicate.addToTooltip) 2026-11-07 23:29:04 +01:00
Kacpherek 7f51885a85 Show mobs bouncing on slime blocks and beds to other players without cutting the bounce short (SteppedInterpolationTracker) 2026-11-07 23:28:17 +01:00
Kacpherek 5ecd155b76 Stop cracking blocks a can_break tool may not break in adventure mode on servers (Player.blockActionRestricted) 2026-11-07 23:27:33 +01:00
Kacpherek 5fe374444f Keep riders in the portal when they get off a vehicle standing in one (LivingEntity.dismountVehicle)
Getting off a boat, minecart or mount whose block is in #minecraft:portals now takes the free-position branch straight up from the rider, as for a removed vehicle, instead of the vehicle's side dismount spot.
2026-11-07 23:27:14 +01:00
Kacpherek ff50de2553 Shear saddles off horses, striders, camels and nautili with a sound (Mob.attemptToShearEquipment)
Shears now take the saddle off any #can_equip_saddle mob, not only pigs, and play the saddle's shearing sound item.saddle.unequip unless the mob is silent. A mount wearing horse or nautilus armor is left alone, since vanilla shears that body armor first.
2026-11-07 23:26:20 +01:00
Kacpherek d4ea299fdd Let adventure-mode can_break and can_place_on predicates with nbt or components match their blocks (BlockPredicate.matches(BlockInWorld)) 2026-11-07 23:25:54 +01:00
Kacpherek 5383881562 Mark worlds with experimental features as Experimental in the world list (LevelSummary.createInfo) 2026-11-07 23:25:20 +01:00
Kacpherek 1c402c47a7 Anger piglins that see a player break a chest boat (ContainerEntity.chestVehicleDestroyed)
A survival player's hit that breaks a chest boat with entity_drops on angers the piglins that see it, as AbstractChestBoat.destroy does through chestVehicleDestroyed.
2026-11-07 23:25:04 +01:00
Kacpherek 75dd0dde7d Save and load a mob's hurt immunity as invulnerable_time (Entity.load / saveWithoutId)
A mob writes invulnerable_time while it has hurt immunity left and reads it back with a default of 0, so /data get shows it, /data merge sets it, and the immunity survives a save and reload.
2026-11-07 23:24:42 +01:00
Kacpherek 8c4e3bb507 Pour a water bottle onto dirt to make mud at once on servers instead of drinking it (PotionItem.useOn) 2026-11-07 23:24:38 +01:00
Kacpherek dabae908cd Keep hidden server list entries, so Direct Connection servers remember pack and Code of Conduct answers (ServerList.save, ServerList.add) 2026-11-07 23:24:26 +01:00
Kacpherek 2d94afaa61 Keep counting time since last rest on the death screen (ServerPlayer.doTick)
time_since_rest now counts every tick the player is not sleeping, dead or alive, as ServerPlayer.doTick does; time_since_death still counts only while alive.
2026-11-07 23:23:39 +01:00
Kacpherek 14a5193b41 Use up a knowledge book and swing the arm at once on servers (KnowledgeBookItem.use) 2026-11-07 23:23:35 +01:00
Kacpherek 7225a84b10 Count thrown snowballs, eggs, pearls, potions and xp bottles as used (throwable Item.use)
Throwing a snowball, egg, ender pearl, splash or lingering potion or bottle o' enchanting awards the used statistic, as SnowballItem, EggItem, EnderpearlItem, ThrowablePotionItem and ExperienceBottleItem.use do.
2026-11-07 23:23:21 +01:00
Kacpherek 939d492535 Let sculk hear item frames break as a block change too (ItemFrame.dropItem)
A frame broken by a player, an explosion or losing its support posts block_change after its drops, as ItemFrame.dropItem(level, causedBy) does, on top of the entity_die it already posted.
2026-11-07 23:22:57 +01:00
Kacpherek 41934f3d93 Let sculk hear items knocked out of item frames (ItemFrame.hurtServer)
Punching the item out of a frame posts block_change with the attacker as source after the item drops.
2026-11-07 23:22:45 +01:00
Kacpherek 85a6a8b9dc Let sculk hear items put into or turned in item frames (ItemFrame.interact)
Putting an item in a frame or rotating it posts block_change with the player as source, after the item or rotation changes.
2026-11-07 23:22:36 +01:00
Kacpherek b97f0f61d7 Let sculk hear endermen pick up and put down blocks (EnderMan block goals)
EndermanTakeBlockGoal.tick posts block_destroy and EndermanLeaveBlockGoal.tick posts block_place, each with the enderman and the block state as context.
2026-11-07 23:22:05 +01:00
Kacpherek e01adc67e3 Let Thorns hurt a player who sweeps its wearer in PvP (Player.doSweepAttack's doPostAttackEffects) 2026-11-07 23:21:48 +01:00
Kacpherek 32137ae44f Answer pandas' clicks on servers as vanilla does: scared ones pass, ones on their back swing, adults leave bamboo to the off hand (Panda.mobInteract) 2026-11-07 23:21:39 +01:00
Kacpherek 9503a3cc7d Let sculk hear copper golems open and close chests (ContainerOpenersCounter)
The first golem to open a chest half posts container_open with the golem as source, the last to close it posts container_close (incrementOpeners / decrementOpeners), and the openers recheck posts a sourceless container_close when the golems have walked off (recheckOpeners).
2026-11-07 23:21:26 +01:00
Kacpherek 7c4a6d1ea6 Use the held item on a pig or strider someone already rides on servers (Pig/Strider.mobInteract) 2026-11-07 23:21:14 +01:00
Kacpherek 3c4d936707 Say "formerly known as" when a renamed player joins a server (PlayerList.placeNewPlayer) 2026-11-07 23:21:11 +01:00
Kacpherek 44f5a37e64 Open a wandering trader's trades on servers without swinging the arm (WanderingTrader.mobInteract) 2026-11-07 23:21:01 +01:00
Kacpherek 5ffd5c6515 Use the held item on a sleeping villager on servers instead of swallowing the click (Villager.mobInteract) 2026-11-07 23:20:45 +01:00
Kacpherek ea99e6572c Let sculk hear glass bottles filling and count their uses (BottleItem.use)
Filling a bottle from water now plays the fill sound at the player and posts fluid_pickup at the water block; both the water and the dragon's breath branch award the used statistic (BottleItem.turnBottleIntoItem), and collecting dragon's breath fires player_interacted_with_entity on the cloud.
2026-11-07 23:20:32 +01:00
Kacpherek c5dbb620c2 Let sneak-clicks and clicks on occupied cushions reach the held item on servers, without an arm swing (Cushion.interact) 2026-11-07 23:20:24 +01:00
Kacpherek 92bedb26d0 Show team colours and prefixes in /list on LAN worlds (ListPlayersCommand.listPlayers uses Player.getDisplayName) 2026-11-07 23:19:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 721d4cfa44 Resource packs: a pack's core/sky and core/stars shaders
A resource pack's core/sky now draws the overworld's sky disc and its
core/stars the stars, as vanilla's SkyRenderer does with the SKY and
STARS pipelines: the disc of buildSkyDisc(16) with the sky colour in
ColorModulator and the sky's fog, the stars with their brightness, both
turned with the camera only. Without such a pack the sky draws natively.

Packs use this to tint the night sky or add a haze to the horizon. With
Visual Effects+'s sky shaders alone, Horizon's sky now reads within a few
units of the Java client's at noon, sunset and night.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:47:59 -03:00
martipopsandClaude Opus 5.5 26496db7c3 Merge upstream/main into controller/direct-craft
Main moved key_pressed to game/play_keys.rs and inventory_press to
game/inventory_input.rs; the craft's stop on a key or mouse press moves
with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:46:48 -05:00
martipopsandClaude Opus 5.5 d5f931ca64 Controllers: LT + A on a recipe crafts it, held to keep crafting
LT + A on a crafting book recipe crafts it into the inventory: a tap makes
one, each further LT + A while one is in flight makes one more, and held past
the repeat delay it keeps going until a button comes up, the items run out
or the inventory is full. One request at a time, each waiting for the
server's answer; a result moves only with room for all of it as the client
last saw the inventory. A result that isn't the recipe's stops the craft with
nothing moved, and two seconds with no answer stops it with nothing more
sent. A refused LT + A (a carried stack, another recipe still finishing, not
allowed here) is plain A.

It's free in single player and on LAN worlds joined from the server list's
LAN entries; everywhere else, joins by address included, it waits for Quick
Craft on Servers. On any server but single player's own, placements keep
250 ms apart, under Paper's default limit on recipe placements (5 a second
over 4 seconds); on servers that need the option, every request also keeps
a tick apart.

On the crafting book LT changes the category as it comes up unless A was
pressed meanwhile; RT, and both triggers on the furnace books, change it as
they go down, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:41:47 -05:00
Florian ErnstandClaude Opus 5.5 c0806b9ee3 Keep a failed wildcard set's edit in command storage
StorageDataAccessor.getData returns the stored compound itself, and
DataCommands.manipulateData edits it in place. A wildcard set on a
non-empty byte, int or long array clears the array before it rejects a
string, so the command fails with nothing counted and the storage keeps
the cleared array. Horizon edited a copy and dropped it on the failure.

On that failure, a /data modify on storage now puts the edited compound
back with CommandStorage::set_unsaved: only an id that is stored is
replaced, and the storage is not marked for saving, as vanilla calls no
setData. The error, the result, the success path and the entity and
block copies are unchanged.

Fixes #12057.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:08:42 +01:00
KBroneZandClaude Opus 5.5 70bc17720c fix(launcher): keep a Prism root's instances folder from reading as CurseForge's
Where folder names ignore case (Windows, macOS), a Prism or MultiMC root's
`instances` folder is also `Instances`, so `in_folder` took it for the
CurseForge app's modding folder and found nothing. It now reads that folder
as CurseForge's only when none of its instances has an `mmc-pack.json`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:03:37 +02:00
KBroneZandClaude Opus 5.5 6fa7f8896a feat(launcher): find instances in the CurseForge app's Instances folder
`instance others --folder` and `copy-from --folder` now accept a folder whose
children are CurseForge instances, the `Instances` folder the app shows the
player, as well as the modding folder above it. Before, that folder found
nothing.

When nothing is found, the message says where it looked (the folder named, or
every default folder the scan reads) and which folders it expects. The
CurseForge folder is joined one component at a time, so its path no longer
shows mixed separators on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:57:04 +02:00
SenjinandClaude Opus 5.5 fb3f1bce90 Shader packs: LabPBR maps for entities, items and the hand
The shared atlas's textures (items, entities, block entities) now bring
their _n and _s maps to shader packs as paged maps: 16x16 pages of the
atlas, only those holding a mapped texture stored, found through a page
table at the map's start. The programs drawing that geometry read
normals and specular through hz_pagedTexture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:39:19 +01:00
SenjinandClaude Opus 5.5 dddf351a05 Shader packs: at_velocity for the player's own model
Its vertices fell in the unmarked run that closes the mobs', so in third
person (and in a pack's shadow pass) its at_velocity read zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:03:19 +01:00
Florian ErnstandClaude Opus 5.5 ce25d100ef Merge origin/main into fix/mob-effect-amplifier-codec
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:15:10 +01:00
Florian ErnstandClaude Opus 5.5 8b577fc24c Merge origin/main into fix/h032-jukebox-saved-clock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:51 +01:00
Florian ErnstandClaude Opus 5.5 974305151d Merge origin/main into fix/summoned-leash-knot-lifecycle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:50 +01:00
Florian ErnstandClaude Opus 5.5 e6941baa5a Merge origin/main into fix/h195-command-damage-range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:49 +01:00
Florian ErnstandClaude Opus 5.5 7cd9dbc7a2 Let Health Boost raise a mob's healing maximum and clamp when it goes
Health Boost is a max_health attribute modifier. The attribute getter
already counted it, but Mob::heal clamps to the cached attrs.max_health,
which the effect paths never refreshed, and nothing lowered Health when
the effect went (LivingEntity.onAttributeUpdated). Adding the effect,
/effect clear and its expiry now refresh the cache and lower Health above
the new maximum; the add still heals nothing.

Sulfur cubes and wolves write their maximum straight into the cache
(sulfur_cube::set_size, tame::set_tame), outside the attribute model,
and keep their current behavior. Other effect removal paths are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:03 +01:00
Florian ERNSTandCodex 2dd6879bc4 Keep actual bell block entities for command reads
Co-Authored-By: Codex <[email protected]>
2026-11-07 16:55:24 +01:00
SenjinandClaude Opus 5.5 ebfcad98f6 Shader packs: at_velocity for blocks a piston moves
A moving block (and an extending or retracting head) is drawn with the entities while it
moves, and read zero at_velocity: flying machines and piston contraptions smeared under a
pack's TAA. Each marks its own run, (6 << 32) | the cell it moves to, which stays the same
while it moves; a finished one keeps that key until the chunk mesh shows the block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:25:09 +01:00
SenjinandClaude Opus 5.5 381c5a1e62 Shader packs: at_velocity for boats, minecarts, armor stands, frames, paintings, TNT, falling blocks and block entities
They were drawn in runs marked only by type, so a pack's at_velocity read zero on them even
while they or the camera moved (TAA smeared them). Each now marks its own motion run:
(3 << 32) | entity id, (4 << 32) | id for an item frame's held item, (5 << 32) | position for a
block entity, with a closing mark after each group; maps stay unmarked (keyed by map id).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:25:24 +01:00
Florian ERNSTandCodex 2688eb16d6 Decode Mob effect amplifiers as unsigned bytes
Co-Authored-By: Codex <[email protected]>
2026-11-07 13:52:45 +01:00
TheMrZZandCodex gpt-6.1-sol ac4859eab4 fix: match command damage-only item predicate ranges
Evaluate the supported damage-only subpredicate against modeled damage and
existing effective component presence. Preserve removal precedence and leave
unsupported conjunctions on their existing path.

Fixes #11715

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 12:23:52 +01:00
Florian ERNSTandCodex 087c763432 Normalize summoned leash knots and check their fence support
Co-Authored-By: Codex <[email protected]>
2026-11-07 12:23:27 +01:00
TheMrZZandCodex gpt-6.1-sol 6d5c7c51a6 Preserve modeled jukebox saved-song clock presence
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 11:01:17 +01:00
Horizon agent ab6900b4c9 Merge main into #10283
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-07 01:57:18 +01:00
Horizon agentandClaude Opus 5.5 96c7d68d0d Unloading a chunk takes its lingering clouds with it
entity_chunks(remove = true) is the unload path: every other kind it
writes is also taken out of the level, and the potion clouds were not,
so a chunk loaded again got a second cloud on the same spot. The new
test unloads and reloads a cloud and expects one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:04 +01:00
Horizon agent 47dc19fa20 Merge main into #10283 2026-11-06 12:54:39 +01:00
MSedgeMC a33e5b574a The ledger knows a cloud's save is ported
AreaEffectCloud.addAdditionalSaveData was listed missing, which was right: only
the reader existed. game/potions.rs::potion_cloud_to_nbt writes it now, from
entity_chunks.
2026-11-05 22:12:59 +00:00
MSedgeMC 78cf6e36a0 Test that a lingering cloud survives a save
A cloud with a poison and a slowness effect, one of them hidden and one ambient,
written with potion_cloud_to_nbt and read back with potion_cloud_from_nbt: the
position, age, duration, radius, the four use settings, the duration scale,
RadiusPerTick, the effect list and the colour all come back. The test also
checks that the compound carries the field names vanilla's
readAdditionalSaveData looks for, and that it is saved as
minecraft:area_effect_cloud.
2026-11-05 22:12:59 +00:00
MSedgeMC bd991e7917 A lingering potion's cloud survives a save
AreaEffectCloud.addAdditionalSaveData writes the cloud's age, duration, radius,
the four use settings, RadiusPerTick, potion_duration_scale and its
potion_contents, and the client already read every one of them back
(game/potions.rs::load_potion_cloud): a cloud that was loaded from a save came
back whole. Nothing ever wrote one, though. entity_chunks writes the dragon, the
crystals, the item frames, the fangs and the rest kind by kind, and the
lingering clouds -- which live in breath_clouds, not in entity_lists! -- were in
none of those, so every replacement pass dropped the cloud a thrown potion or a
creeper left behind.

The save side now writes them: game/potions.rs::potion_cloud_to_nbt builds the
compound on the entity fields entity_nbt::entity_base_tag gives it, and
entity_chunks adds one tag per potion-bearing cloud of the chunk. A dragon's own
breath carries no potion and is not written.

The reader moved out of the Game method into potion_cloud_from_nbt, so the pair
can be tested against itself. The client keeps no potion id, so the effects are
written as custom_effects with the colour the cloud was drawn in, and the custom
particle and owner reference the vanilla cloud also saves have no counterpart
here.
2026-11-05 22:12:58 +00:00
266 changed files with 11178 additions and 686 deletions

No files matched your search

+1 -1
View File
@@ -168,7 +168,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| 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`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| 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), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| 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`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; 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)) |
+41 -3
View File
@@ -412,10 +412,11 @@ pub fn to_text_with(
.collect()
})
.unwrap_or_default();
let translated = Text::translate(key, &args);
// TranslatableContents.decompose: Language.getOrDefault(key, fallback), so the
// fallback is the template itself, never looked up as another key.
match o.get("fallback").and_then(Value::as_str) {
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
_ => translated,
Some(fallback) => Text::translate_with_fallback(key, fallback, &args),
None => Text::translate(key, &args),
}
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
@@ -513,6 +514,43 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
impl ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
/// TranslatableContents.decompose: Language.getOrDefault(key, fallback). A missing key
/// shows the fallback as written, even when the fallback is another key the language has.
#[test]
fn a_translatable_fallback_stays_literal() {
let pack = Mem([(
"assets/minecraft/lang/en_us.json".to_owned(),
br#"{"real.key": "Translated", "greet": "Hello %s"}"#.to_vec(),
)]
.into());
let ((), _) = lang::scoped(Language::load(&[&pack], "en_us"), || {
let parse = |v: Value| to_text(&v, &mut |_| Text::empty()).plain();
let missing = serde_json::json!({"translate": "missing.key", "fallback": "real.key"});
assert_eq!(parse(missing), "real.key");
// The fallback is a template: its arguments are filled in.
let args = serde_json::json!({"translate": "missing.key", "fallback": "Hi %s", "with": ["Steve"]});
assert_eq!(parse(args), "Hi Steve");
// A key the language has wins over its fallback.
let found =
serde_json::json!({"translate": "greet", "fallback": "real.key", "with": ["Alex"]});
assert_eq!(parse(found), "Hello Alex");
// A component built with the constructor keeps its meaning through its JSON.
let built = Text::translate_with_fallback("missing.key", "real.key", &[]);
let again = to_text(&built.component(), &mut |_| Text::empty());
assert_eq!(again.plain(), built.plain());
assert_eq!(again.component(), built.component());
});
}
/// SnbtGrammar: a compound or list may end with one comma; two in a row
/// stay an error.
#[test]
+8 -1
View File
@@ -583,6 +583,10 @@ pub struct PadSettings {
/// The sneak button flips sneaking (Bedrock's default) instead of
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
/// LT + A on a recipe crafts on every server, not only in single player and on LAN worlds
/// found in the server list. Off by default: a server's rules may count a repeated craft as a
/// macro.
pub craft_on_servers: bool,
pub glyphs: GlyphStyle,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
@@ -614,6 +618,7 @@ impl Default for PadSettings {
look_deadzone: 0.12,
trigger_threshold: 0.4,
toggle_sneak: true,
craft_on_servers: false,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
@@ -684,6 +689,7 @@ impl PadSettings {
s.trigger_threshold = unit(s.trigger_threshold).clamp(0.1, 0.9)
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"craftOnServers" => s.craft_on_servers = flag(s.craft_on_servers),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
@@ -745,7 +751,8 @@ impl PadSettings {
self.keyboard.name(),
);
text.push_str(&format!(
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
"craftOnServers:{}\ngameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
bool_text(self.craft_on_servers),
self.guide_detail.name(),
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
@@ -83,6 +83,8 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
Bell,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
@@ -452,6 +454,9 @@ pub struct Jukebox {
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
/// `JukeboxBlockEntity.saveAdditional` writes the clock only when its song
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
}
impl Jukebox {
@@ -392,6 +392,9 @@ impl BlockEntity {
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:bell" {
return Some(Self::Bell);
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -487,6 +490,7 @@ impl BlockEntity {
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
@@ -525,6 +529,7 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
@@ -559,6 +564,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -589,6 +595,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
@@ -624,6 +631,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
@@ -191,6 +191,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
Opt::PadGameplayGuide,
"Show the buttons for what you can do, while you play with a controller.",
),
option(
Opt::PadCraftOnServers,
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
),
option(
Opt::PadTriggerThreshold,
"How far a trigger is pulled before it counts as pressed.",
@@ -344,6 +348,7 @@ impl Item {
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
Item::Option(Opt::PadCraftOnServers) => Some(c.craft_on_servers),
_ => None,
}
}
+1 -1
View File
@@ -1149,7 +1149,7 @@ mod tests {
for pack in ["void", "islands"] {
let dir = game.join(NEW_WORLD_PACKS).join(pack);
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
std::fs::write(dir.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(dir.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
}
let (temp, ids) = seed_create_world(&game).unwrap();
+119 -9
View File
@@ -162,6 +162,7 @@ mod arena;
pub mod capture;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
mod cull;
mod end_sky;
pub mod entity_skin;
@@ -1214,6 +1215,8 @@ pub struct Renderer {
lines_secondary: wgpu::RenderPipeline,
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
core_lines: Option<core_lines::CoreLines>,
/// The packs' `core/sky` and `core/stars`, drawn in place of the native sky and stars.
core_sky: Option<core_sky::CoreSky>,
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
@@ -1510,6 +1513,9 @@ pub struct Renderer {
/// The block sprites as one atlas, while a shader pack is loaded.
pack_atlas: Option<pack_atlas::PackAtlas>,
pack_material_atlases: Option<[pack_atlas::PackAtlas; 2]>,
/// The shared atlas's LabPBR normal and specular paged maps (`set_shared_material_maps`),
/// which shader packs read for entities, items and the hand.
shared_material_maps: [Option<wgpu::Texture>; 2],
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
@@ -4605,6 +4611,10 @@ impl Renderer {
});
let static_entity_index_quads = 1 << 14;
let static_entity_indices = Self::quad_indices(&device, static_entity_index_quads);
let indirect_execution = adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let flat_wanted = flat::wanted(&adapter, &device);
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
// their opaque quads first, so each pass draws its part in a run or two.
@@ -4643,6 +4653,7 @@ impl Renderer {
&bindings,
arena.page_vertices(),
min_sections,
indirect_execution,
|layout, label, vertex, fragment, blend, cull, pulled| {
let buffers: &[Option<wgpu::VertexBufferLayout<'_>>] =
if pulled { &[] } else { &buffers };
@@ -4703,10 +4714,7 @@ impl Renderer {
let multi_draw = device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
&& adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
&& indirect_execution;
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
@@ -4865,6 +4873,7 @@ impl Renderer {
lines,
lines_secondary,
core_lines: None,
core_sky: None,
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
@@ -5044,6 +5053,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_material_atlases: None,
shared_material_maps: [None, None],
pack_terrain_flat: false,
separate_ao: false,
pack_lava_layers: Vec::new(),
@@ -5135,6 +5145,47 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// The shared atlas's LabPBR maps (`HudAtlas::material_maps`), uploaded for the shader packs
/// loaded from now on; a map too large for this GPU is left out.
pub fn set_shared_material_maps(&mut self, maps: &[Option<crate::paged_maps::PagedMap>; 2]) {
let limit = self.device.limits().max_texture_dimension_2d;
let sizes: Vec<String> = (maps.iter().zip(["normals", "specular"]))
.filter_map(|(m, name)| {
m.as_ref()
.map(|m| format!("{name} {}x{}", m.width, m.height))
})
.collect();
if !sizes.is_empty() {
let bytes: usize = maps.iter().flatten().map(|m| m.pixels.len()).sum();
log::info!(
"shared atlas LabPBR maps: {} ({:.1} MiB)",
sizes.join(", "),
bytes as f64 / f64::from(1 << 20)
);
}
self.shared_material_maps = maps.each_ref().map(|map| {
let map = map.as_ref().filter(|m| m.width.max(m.height) <= limit)?;
Some(self.device.create_texture_with_data(
&self.queue,
&wgpu::TextureDescriptor {
label: Some("Shared atlas material map"),
size: wgpu::Extent3d {
width: map.width,
height: map.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&map.pixels,
))
});
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
@@ -5151,6 +5202,15 @@ impl Renderer {
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
}
/// The resource packs' `core/sky` and `core/stars` (`HudAtlas::sky_shader`,
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.frame_format, disc, stars);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -6959,7 +7019,7 @@ impl Renderer {
dimension: i32,
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
let post = match post {
let mut post = match post {
Ok(post) => post,
Err(e) => {
self.pack_post = Some((dimension, None));
@@ -6968,6 +7028,12 @@ impl Renderer {
return Err(e);
}
};
if let Some(post) = post.as_mut() {
post.set_paged_maps(
(self.shared_material_maps.each_ref())
.map(|t| t.as_ref().map(|t| t.create_view(&Default::default()))),
);
}
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
@@ -9076,6 +9142,39 @@ impl Renderer {
self.uploads.buffer(&mut encoder, core.uniform(), 0, &data);
}
}
// A pack's core/sky and core/stars: the disc with the sky colour, the stars with their
// brightness, both turned with the camera only (SkyRenderer's model view).
if let Some(core) = &self.core_sky
&& !frame.title
{
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
}
.bytes(size, frame.post_effects.menu_blur_radius as i32);
let [sky_end, clouds_end, ..] = globals.fog_ends;
let fog = [fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end];
let view = views.primary();
// Far enough for the disc's rim, 512 out: the sky draws without a depth test.
let fov = (2.0 * view.tan_half_fov.atan()).to_degrees();
let projection = jitter * world_projection(fov, view.aspect, 2048.0) * pose;
let [r, g, b, ..] = frame.sky.sky_color;
let sky =
core_sky::uniforms(block, projection, view.view, [r, g, b, 1.0], fog_color, fog);
let stars = frame.stars.map(|(rotation, brightness)| {
let b = brightness.clamp(0.0, 1.0);
core_sky::uniforms(
block,
projection,
view.view * rotation,
[b; 4],
fog_color,
fog,
)
});
core.prepare(&mut self.uploads, &mut encoder, &sky, stars.as_deref());
}
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
@@ -10165,8 +10264,16 @@ impl Renderer {
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
match self.core_sky.as_ref().filter(|c| c.draws_disc()) {
Some(core) => {
core.draw_disc(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
None => {
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
}
}
if frame.sky.sunrise_color[3] > 0.001 {
pass.set_pipeline(&self.sunrise_pipeline);
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
@@ -10217,7 +10324,10 @@ impl Renderer {
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
self.stars.draw(&mut pass);
match self.core_sky.as_ref().filter(|c| c.draws_stars()) {
Some(core) => core.draw_stars(&mut pass),
None => self.stars.draw(&mut pass),
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.dark_disc && !pack_draws_sky {
@@ -12498,7 +12608,7 @@ struct Overlay {
hand_view_proj: Mat4,
}
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
pub(crate) fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, NEAR_PLANE)
}
@@ -1047,7 +1047,15 @@ mod tests {
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let mut flat = flat::Flat::new(
&device,
&terrain_layout,
4 * GRAIN,
0,
false,
pipeline,
solid,
);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -0,0 +1,436 @@
//! A resource pack's `core/sky` and `core/stars` shaders: the overworld's sky disc and stars drawn
//! with the pack's GLSL (`SkyRenderer.renderSkyDisc`, `renderStars`), as a pack that adds a haze
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
pub(crate) const GLUE: Glue = Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// `SkyRenderer.buildSkyDisc(16)`: a fan around the point 16 above the camera, out to 512 every
/// 45 degrees, as a triangle list.
pub(crate) fn disc_triangles() -> Vec<[f32; 3]> {
let rim: Vec<[f32; 3]> = (-180..=180)
.step_by(45)
.map(|i: i32| {
let a = (i as f32).to_radians();
[512.0 * a.cos(), 16.0, 512.0 * a.sin()]
})
.collect();
rim.windows(2)
.flat_map(|edge| [[0.0, 16.0, 0.0], edge[0], edge[1]])
.collect()
}
/// One of the sky's pack pipelines with its vertices and uniforms.
struct Part {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
count: u32,
}
impl Part {
fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
program: &CoreProgram,
name: &str,
blend: Option<wgpu::BlendState>,
triangles: &[[f32; 3]],
) -> Option<Self> {
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = |stage, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&format!("Resource pack {name} ({stage})")),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("vertex", &program.vertex);
let fragment = module("fragment", &program.fragment);
// Vanilla's SKY and STARS pipelines have no samplers: a pack that reads one is not used.
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(name),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(name),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(name),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(name),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
})],
},
primitive: wgpu::PrimitiveState::default(),
// The sky is drawn first, behind everything: no depth test, no depth write.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's {name} shader is not used: {e}");
return None;
}
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: bytemuck::cast_slice(triangles),
usage: wgpu::BufferUsages::VERTEX,
});
Some(Self {
pipeline,
group,
uniform,
vertices,
count: triangles.len() as u32,
})
}
fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw(0..self.count, 0..1);
}
}
/// The packs' sky disc and stars pipelines, each present when a pack replaces that shader.
pub(super) struct CoreSky {
disc: Option<Part>,
stars: Option<Part>,
}
impl CoreSky {
/// `None` when neither shader is replaced or neither builds.
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
disc: Option<&CoreProgram>,
stars: Option<&CoreProgram>,
) -> Option<Self> {
let disc = disc.and_then(|program| {
// RenderPipelines.SKY: ColorTargetState.DEFAULT, no blending.
Part::new(device, format, program, "sky", None, &disc_triangles())
});
let stars = stars.and_then(|program| {
// RenderPipelines.STARS: BlendFunction.OVERLAY (src alpha and one; alpha one, zero).
let overlay = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
};
let triangles = super::stars::star_triangles();
Part::new(device, format, program, "stars", Some(overlay), &triangles)
});
(disc.is_some() || stars.is_some()).then_some(Self { disc, stars })
}
pub(super) fn draws_disc(&self) -> bool {
self.disc.is_some()
}
pub(super) fn draws_stars(&self) -> bool {
self.stars.is_some()
}
/// Uploads the disc's and the stars' blocks: `sky` and `stars` from [`uniforms`].
pub(super) fn prepare(
&self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
sky: &[u8],
stars: Option<&[u8]>,
) {
if let Some(disc) = &self.disc {
uploads.buffer(encoder, &disc.uniform, 0, sky);
}
if let (Some(part), Some(data)) = (&self.stars, stars) {
uploads.buffer(encoder, &part.uniform, 0, data);
}
}
/// The disc, in place of the native sky colour. The caller sets its own group 0 again after.
pub(super) fn draw_disc(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(disc) = &self.disc {
disc.draw(pass);
}
}
/// The stars, in place of the native ones. The caller sets its own group 0 again after.
pub(super) fn draw_stars(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(stars) = &self.stars {
stars.draw(pass);
}
}
}
/// The std140 blocks for one sky draw: `Globals`, `Projection` (`projection`, far enough for the
/// disc's 512-block rim), `DynamicTransforms` (`model_view`: the camera's rotation, times the
/// stars' rotation for them; `color`: the sky colour, or the star brightness in every channel)
/// and `Fog` (`fog_color`, `fog`: environmental start and end, render distance start and end,
/// sky end, clouds end).
pub(super) fn uniforms(
globals: [u8; 48],
projection: Mat4,
model_view: Mat4,
color: [f32; 4],
fog_color: [f32; 4],
fog: [f32; 6],
) -> Vec<u8> {
let mut data = vec![0f32; 256];
data[64..80].copy_from_slice(&projection.to_cols_array());
let transforms = 128;
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
data[transforms + 32..transforms + 36].copy_from_slice(&color);
data[192..196].copy_from_slice(&fog_color);
data[196..202].copy_from_slice(&fog);
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
bytes[..48].copy_from_slice(&globals);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
/// `buildSkyDisc(16)`: eight triangles from the point above the camera to the rim, which
/// starts and ends at x -512 (cos 180°) and passes through z -512, x 512 and z 512.
#[test]
fn the_disc_is_vanillas_fan_as_triangles() {
let triangles = disc_triangles();
assert_eq!(triangles.len(), 24);
assert!(triangles.chunks(3).all(|t| t[0] == [0.0, 16.0, 0.0]));
let rim: Vec<[f32; 3]> = triangles.chunks(3).map(|t| t[1]).collect();
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
assert!(near(rim[0], [-512.0, 16.0, 0.0]));
assert!(near(rim[2], [0.0, 16.0, -512.0]));
assert!(near(rim[4], [512.0, 16.0, 0.0]));
assert!(near(rim[6], [0.0, 16.0, 512.0]));
assert!(near(triangles[23], [-512.0, 16.0, 0.0]));
}
/// The prepared resources (`HORIZON_RESOURCES`, or `.local/horizon-client/<version>`), where
/// vanilla's sky shader and its includes are.
fn resources() -> std::path::PathBuf {
std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
})
}
/// Vanilla's `sky.vsh` with a pack's fragment shader that paints `ColorModulator` (the sky
/// colour) where the disc is: looking at the horizon, the top half of the target is the
/// disc, the bottom half stays clear.
#[test]
fn a_pack_sky_shader_paints_the_disc_above_the_horizon() {
let root = resources();
let Ok(vertex) =
std::fs::read_to_string(root.join("assets/minecraft/shaders/core/sky.vsh"))
else {
eprintln!("skipping: no prepared resources at {}", root.display());
return;
};
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
layout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = ColorModulator;\n}\n";
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU: skipping");
return;
};
const SIZE: u32 = 64;
let format = wgpu::TextureFormat::Rgba8Unorm;
let sky = CoreSky::new(&device, format, Some(&program), None).expect("the sky pipeline");
let projection = crate::gfx::world_projection(90.0, 1.0, 2048.0);
let data = uniforms(
[0; 48],
projection,
Mat4::IDENTITY,
[0.25, 0.5, 1.0, 1.0],
[0.0; 4],
[1e6; 6],
);
queue.write_buffer(&sky.disc.as_ref().unwrap().uniform, 0, &data);
let target = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let color = target(format, wgpu::TextureUsages::COPY_SRC);
let depth = target(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::empty(),
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
sky.draw_disc(&mut pass);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: usize, y: usize| pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4].to_vec();
// Row 0 is the top: the disc, in the sky colour; the bottom row is below the horizon.
let top = at(32, 4);
assert!(
top.iter()
.zip([64u8, 128, 255, 255])
.all(|(&p, w)| p.abs_diff(w) <= 1),
"{top:?}"
);
assert_eq!(at(32, 60), vec![0, 0, 0, 0]);
}
}
+17 -2
View File
@@ -106,6 +106,8 @@ pub struct Flat {
phase2_calls: wgpu::Buffer,
phase2_call_capacity: u64,
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
@@ -365,6 +367,7 @@ impl Flat {
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
indirect_execution: bool,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -489,10 +492,16 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
20,
wgpu::BufferUsages::INDIRECT | storage_usage,
storage_usage
| if indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
),
phase2_call_capacity: 1,
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -645,6 +654,7 @@ impl Flat {
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
} else {
@@ -753,7 +763,12 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
self.phase2_call_capacity * 20,
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE
| if self.indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
);
}
if phase2_calls > 0 {
@@ -73,10 +73,15 @@ pub struct WorldDrawn<'a> {
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API 371).
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player,
/// `(2 << 32) | id` for a dropped item, `(3 << 32) | id` for another entity (boat,
/// minecart, armor stand, item frame, painting, primed TNT, falling block) and
/// `(4 << 32) | id` for an item frame's held item (their entity ids), `(5 << 32) | p` for a
/// block entity (`p`: its position, x and z in 11 bits each, y in 10), `(6 << 32) | p`
/// for a block a piston moves (`p`: the cell it moves to), `7 << 32` for the player's own
/// model, 0 for anything else.
/// A run lasts until the next. Unlike `spans`, whose ids name a type, these follow one
/// entity from frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
@@ -757,6 +757,7 @@ impl PackAtlas {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
}
@@ -127,6 +127,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}),
item_id_declared: false,
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
paged_maps: true,
}
}
@@ -190,6 +192,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
@@ -82,6 +82,9 @@ pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
(Kind::Uint, 10) => T::Rgb10a2Uint,
(Kind::Snorm, 16) => pick(T::R16Snorm, T::Rg16Snorm, T::Rgba16Snorm),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
@@ -52,6 +52,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
@@ -0,0 +1,207 @@
//! Paged maps (`paged_maps.rs`) through the translator's `hz_pagedTexture` and the pass's
//! binding: a pack reads the map's texels at the colour's coordinates.
use super::*;
use wgpu::util::DeviceExt;
/// The atlas the fixture's coordinates are in, and the one mapped texture's place.
const ATLAS: (u32, u32) = (64, 32);
const SPRITE: [u32; 4] = [20, 4, 20, 20];
/// The fixture's normal map texel at texel (x, y) of its texture.
fn texel(x: u32, y: u32) -> [u8; 4] {
[x as u8 * 10, y as u8 * 10, 7, 255]
}
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn a_pack_reads_paged_maps_at_the_colours_coordinates() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-paged-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
// Each pixel reads one atlas texel's centre: in the texture (on four pages), beside it,
// on a page without maps, and the specular map, which no texture has.
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D normals;
uniform sampler2D specular;
vec2 at(float x, float y) { return (vec2(x, y) + 0.5) / vec2(64.0, 32.0); }
void main() {
int x = int(floor(gl_FragCoord.x));
if (x == 0) gl_FragData[0] = texture2D(normals, at(20.0, 4.0));
else if (x == 1) gl_FragData[0] = texture2D(normals, at(39.0, 23.0));
else if (x == 2) gl_FragData[0] = texture2D(normals, at(31.0, 15.0), 2.0);
else if (x == 3) gl_FragData[0] = texture2DLod(normals, at(32.0, 16.0), 3.0);
else if (x == 4) gl_FragData[0] = texture2D(normals, at(19.0, 4.0));
else if (x == 5) gl_FragData[0] = texture2D(normals, at(50.0, 30.0));
else if (x == 6) gl_FragData[0] = texture2D(specular, at(25.0, 10.0));
else gl_FragData[0] = texture2D(normals, at(33.0, 5.0));
}
"#,
)
.unwrap();
let [x0, y0, w, h] = SPRITE;
let normal: Vec<u8> = (0..w * h).flat_map(|i| texel(i % w, i / w)).collect();
let [normals, specular] = crate::paged_maps::build(
ATLAS,
&[crate::paged_maps::SpriteMaps {
rect: SPRITE,
maps: [Some(normal), None],
}],
16384,
);
assert!(specular.is_none());
let normals = normals.unwrap();
let map = device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Fixture paged normal map"),
size: wgpu::Extent3d {
width: normals.width,
height: normals.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&normals.pixels,
)
.create_view(&Default::default());
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
paged_maps: true,
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: map.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// As the entity passes are (`paged`), with the maps the renderer hands over.
pass.paged = true;
post.set_paged_maps([Some(map), None]);
post.copied |= 1;
post.resize(&device, [8, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [8, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 8,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let local = |x: u32, y: u32| texel(x - x0, y - y0);
let expected = [
local(20, 4),
local(39, 23),
local(31, 15),
local(32, 16),
crate::sprites::FLAT_NORMAL,
crate::sprites::FLAT_NORMAL,
crate::sprites::NO_SPECULAR,
local(33, 5),
];
for (i, e) in expected.iter().enumerate() {
assert_eq!(&data[i * 4..i * 4 + 4], e, "pixel {i}");
}
std::fs::remove_dir_all(root).unwrap();
}
@@ -61,6 +61,9 @@ mod shadow_faces_tests;
#[cfg(test)]
mod r11f_image_tests;
#[cfg(test)]
mod work_group_fold_tests;
#[cfg(test)]
mod forced_flip_tests;
@@ -430,6 +433,9 @@ struct Pass {
/// What `texture` reads instead of the block atlas.
atlas: Option<wgpu::TextureView>,
material_maps: bool,
/// Draws the shared atlas's geometry: `normals` and `specular` are its paged maps
/// (`PackPost::set_paged_maps`), as its programs were translated to read them.
paged: bool,
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
ids: bool,
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
@@ -674,6 +680,8 @@ pub struct PackPost {
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
material_maps: Option<[wgpu::TextureView; 2]>,
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
paged_maps: [Option<wgpu::TextureView>; 2],
lightmap: wgpu::TextureView,
deferred_pre: u16,
composite_pre: u16,
@@ -2179,6 +2187,7 @@ fn build_pass(
draw,
atlas: geometry.and_then(|g| g.atlas.clone()),
material_maps: geometry.is_some_and(|g| g.material_maps),
paged: false,
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2319,6 +2328,7 @@ fn build_compute(
draw: false,
atlas: None,
material_maps: false,
paged: false,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2357,6 +2367,13 @@ impl Refused {
}
impl PackPost {
/// The shared atlas's normal and specular paged maps (`paged_maps.rs`), which the programs
/// drawing its geometry read as `normals` and `specular`; set before the first frame (a pass's
/// bind groups are kept once made). A missing one reads as the dummy (flat, no specular).
pub fn set_paged_maps(&mut self, maps: [Option<wgpu::TextureView>; 2]) {
self.paged_maps = maps;
}
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
///
@@ -2780,6 +2797,13 @@ impl PackPost {
ids: None,
cull: false,
});
// Programs drawing the shared atlas's geometry read its paged maps (`pack_entities::inputs`).
let paged = |pass: Option<Pass>| {
pass.map(|mut pass| {
pass.paged = true;
pass
})
};
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
@@ -2792,15 +2816,15 @@ impl PackPost {
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
paged(build(p, Some(&decal)))
} else {
build(p, Some(g))
paged(build(p, Some(g)))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
.and_then(|(p, g)| paged(build(p, Some(g))))
});
let entities = entity_vertices
.filter(|_| (entity_passes.iter().chain(&shadow_entities)).any(Option::is_some))
@@ -2843,7 +2867,7 @@ impl PackPost {
ids: None,
cull: false,
};
build(p, Some(&g)).map(|pass| (pass, geometry))
paged(build(p, Some(&g))).map(|pass| (pass, geometry))
});
// Translucent held items, drawn from the hand's own buffer.
let hand_water = post.hand_water.and_then(|p| {
@@ -2857,7 +2881,7 @@ impl PackPost {
ids: None,
cull: false,
};
build(p, Some(&g))
paged(build(p, Some(&g)))
});
let weather = post
.weather
@@ -3296,6 +3320,7 @@ impl PackPost {
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
material_maps: world.material_maps,
paged_maps: [None, None],
lightmap: world.lightmap,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
@@ -3656,6 +3681,12 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals if pass.paged => {
self.paged_maps[0].as_ref().unwrap_or(&self.dummy_normal)
}
Source::Specular if pass.paged => {
self.paged_maps[1].as_ref().unwrap_or(&self.dummy_black)
}
Source::Normals => self
.material_maps
.as_ref()
@@ -3756,6 +3787,16 @@ impl PackPost {
[n(0), n(1), 1]
});
let (pipeline, groups) = (pipeline.clone(), groups);
// wgpu panics on a dispatch over its limit. The translator reshapes a large
// `workGroups`; a `workGroupsRender` that large is not run.
let most = device.limits().max_compute_workgroups_per_dimension;
if groups.iter().any(|&n| n > most) {
log::warn!(
"shader pack: {}: {groups:?} work groups is over {most} per dimension; not run",
pass.name
);
return;
}
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
pack_images::snapshot(encoder, &self.images[image]);
}
@@ -6256,6 +6297,34 @@ mod tests {
);
}
#[test]
fn an_image_is_made_in_its_layout_qualifiers_format() {
// `image.x = … RGB10_A2 …` read through `layout(rgb10_a2) image2D x`.
for q in [
"rgba32f",
"rgba16f",
"rg16f",
"r11f_g11f_b10f",
"r32f",
"rgba16",
"rgb10_a2",
"rgba8",
"rg8",
"r16",
"rgba16_snorm",
"rgba8_snorm",
"r8_snorm",
"rgba32i",
"r16i",
"rgb10_a2ui",
"rgba8ui",
"r32ui",
] {
let def = Format::parse(q).unwrap();
assert_eq!(Some(pack_images::format(def, true)), image_format(q), "{q}");
}
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -6301,6 +6370,10 @@ mod destination_tests;
#[path = "pack_material_tests.rs"]
mod material_tests;
#[cfg(test)]
#[path = "pack_paged_maps_tests.rs"]
mod paged_maps_tests;
#[cfg(test)]
mod rgb_alpha_tests;
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -719,6 +719,7 @@ fn shadow_depth_map_reads(
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}),
..Default::default()
};
@@ -46,6 +46,7 @@ fn pack() -> Post {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -0,0 +1,57 @@
//! A `workGroups` over wgpu's 65535 per dimension runs, and its programs read the work group
//! built-ins of the dispatch they declared. The programs are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
// 70000 x 1 x 2 groups; the last 64 of the second layer each paint one pixel, green when
// the built-ins agree with that dispatch.
add(
"composite.csh",
"#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(70000,1,2);\n\
layout(rgba8) writeonly uniform image2D colorimg0;\n\
void main() {\n uint first = 70000u - 64u;\n\
if (gl_WorkGroupID.z != 1u || gl_WorkGroupID.x < first) return;\n\
uint k = gl_WorkGroupID.x - first;\n\
bool ok = gl_WorkGroupID.y == 0u && gl_NumWorkGroups == uvec3(70000u, 1u, 2u)\n\
&& gl_GlobalInvocationID == gl_WorkGroupID;\n\
imageStore(colorimg0, ivec2(k % 8u, k / 8u), ok ? vec4(0, 1, 0, 1) : vec4(1, 0, 0, 1));\n}\n",
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nin vec2 uv;\n\
void main() { gl_FragColor = texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0); }\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-fold", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn a_dispatch_over_the_limit_reads_its_declared_work_groups() {
let Some(gpu) = Fixture::new() else { return };
let post = pack();
assert!(post.notes.is_empty(), "{:?}", post.notes);
let mut post = gpu.build(post);
let pixels = gpu.render(&mut post, [8, 8]);
for (k, got) in pixels.iter().enumerate() {
assert_eq!(*got, [0, 255, 0, 255], "pixel {k}; notes {:?}", post.notes);
}
}
@@ -104,6 +104,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
}
}
@@ -131,6 +132,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
}
}
@@ -46,6 +46,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}),
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
+66 -2
View File
@@ -100,12 +100,18 @@ pub struct HudAtlas {
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
/// Their emissive overlays (`_e`), packed here too.
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
/// ETF's blinking frames (`_blink`), packed here too.
pub blinks: horizon_optifine::blink::Blinks,
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -888,6 +894,10 @@ impl HudAtlas {
&[],
&crate::gfx::core_lines::GLUE,
);
let sky =
|name| pack_text::load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
atlas.sky_shader = sky("sky");
atlas.stars_shader = sky("stars");
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
@@ -967,6 +977,7 @@ impl HudAtlas {
revision: Self::next_revision(),
pixels: Vec::new(),
packed: None,
material_maps: Default::default(),
galactic_widths: [0; 128],
width: 1,
height: 1,
@@ -981,6 +992,8 @@ impl HudAtlas {
glint_armor: (Vec::new(), 1, 1),
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures: Default::default(),
emissive: Arc::default(),
blinks: Default::default(),
@@ -1029,6 +1042,7 @@ impl HudAtlas {
(glyph_top, height - glyph_top),
white,
),
material_maps: Default::default(),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
@@ -1045,6 +1059,8 @@ impl HudAtlas {
white,
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
@@ -1269,7 +1285,16 @@ impl HudAtlas {
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
// A texture's LabPBR maps, as decoded (width, height, RGBA8), for shader packs.
type Maps = [Option<(u32, u32, Vec<u8>)>; 2];
let read_maps = full && crate::sprites::labpbr_maps();
type Decoded = (
PackedImage,
Option<TextureFlags>,
Option<(u32, u32)>,
bool,
Option<Box<Maps>>,
);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = if let Some(name) = named.get(path) {
name.clone()
@@ -1317,11 +1342,21 @@ impl HudAtlas {
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
let maps = read_maps
.then(|| {
let stem = path.file_stem()?.to_str()?;
let maps: Maps = ["_n", "_s"].map(|suffix| {
decode(&path.with_file_name(format!("{stem}{suffix}.png"))).ok()
});
maps.iter().any(Option::is_some).then(|| Box::new(maps))
})
.flatten();
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
(name, w, h, pixels),
flags,
gui_size,
animated,
maps,
))
};
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
@@ -1342,7 +1377,11 @@ impl HudAtlas {
let mut gui_sizes = HashMap::new();
let mut animated: Vec<String> = Vec::new();
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
for (image, image_flags, gui_size, is_animated) in decoded {
let mut maps: HashMap<String, Box<Maps>> = HashMap::new();
for (image, image_flags, gui_size, is_animated, image_maps) in decoded {
if let Some(m) = image_maps {
maps.insert(image.0.clone(), m);
}
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
@@ -1517,6 +1556,28 @@ impl HudAtlas {
}
sprite_animations.push((rect, animation));
}
// Each mapped texture's maps at its size in the atlas, paged.
let material_maps = if maps.is_empty() {
Default::default()
} else {
let mapped: Vec<crate::paged_maps::SpriteMaps> = maps
.iter()
.filter_map(|(name, m)| {
let rect = sprites.get(name)?;
let (tw, th) = rect.texels((width, height));
let x = (rect.u0 * width as f32).round() as u32;
let y = (rect.v0 * height as f32).round() as u32;
Some(crate::paged_maps::SpriteMaps {
rect: [x, y, tw, th],
maps: m.each_ref().map(|map| {
let (mw, mh, pixels) = map.as_ref()?;
crate::paged_maps::fit(pixels, (*mw, *mh), (tw, th))
}),
})
})
.collect();
crate::paged_maps::build((width, height), &mapped, MAX_ATLAS_SIZE)
};
let white = sprites["horizon/white"];
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
@@ -1531,6 +1592,7 @@ impl HudAtlas {
);
Ok(Self {
revision: Self::next_revision(),
material_maps: std::sync::Arc::new(material_maps),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
@@ -1546,6 +1608,8 @@ impl HudAtlas {
glint_armor,
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures,
emissive,
blinks,
+1
View File
@@ -84,6 +84,7 @@ pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
pub mod pad_glyph;
pub mod paged_maps;
pub mod par;
pub mod particles;
pub mod poi_sections;
+70 -1
View File
@@ -559,7 +559,8 @@ pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
/// holes in mountains (and paths and fields dips). Slabs use the same
/// full-cell approximation as stairs, so distant slab builds remain visible.
fn nearly_full(name: &str) -> bool {
matches!(
name,
@@ -568,6 +569,7 @@ fn nearly_full(name: &str) -> bool {
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
|| name.ends_with("_slab")
|| name.contains("fence")
|| name.contains("_wall")
|| name.contains("bars")
@@ -3977,6 +3979,73 @@ mod tests {
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn slab_materials_survive_chunk_summaries_and_draw_far_faces() -> crate::Result<()> {
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables: Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?),
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let kinds = state_kinds(&facts);
let mut chunk = Chunk::empty(0, 0);
let mut mesher = mesh::Mesher::default();
let mut materials = FxHashSet::default();
// Real top, bottom, double and waterlogged states: a waterlogged slab
// must keep its material rather than become a column of plain water.
for (i, f) in facts.states.iter().enumerate() {
if !f.name.ends_with("_slab") {
continue;
}
materials.insert(f.name.as_ref());
let state = BlockStateId(i as u32);
chunk.set(3, 90, 3, state.0);
let columns = summarize(&chunk, &kinds);
let column = &columns[3 * 16 + 3];
assert_eq!(column.spans.len(), 1, "{} state {i}", f.name);
assert_eq!(column.spans[0].state, state, "{} state {i}", f.name);
assert_eq!((column.spans[0].min, column.spans[0].max), (90, 91));
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, column.spans);
let mesh = mesher.mesh(
TileKey {
x: 0,
z: 0,
level: 0,
},
&columns.iter().collect::<Vec<_>>(),
&FxHashSet::default(),
None,
&context,
&std::array::from_fn(|_| Vec::new()),
&[],
);
// Like stairs, far slabs use the existing full-cell approximation.
assert_eq!(mesh.solid.len(), 6 * 4, "{} state {i}", f.name);
assert!(mesh.translucent.is_empty(), "{} state {i}", f.name);
}
for name in [
"minecraft:smooth_stone_slab",
"minecraft:oak_slab",
"minecraft:cut_copper_slab",
] {
assert!(materials.contains(name), "missing fixture {name}");
}
Ok(())
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables,
@@ -9,19 +9,22 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
}
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
// depth), combined by max blending.
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_oit_bounds(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let color = oit_terrain_color(in);
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
// Stage 2.
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
@fragment fn fs_oit_transmittance(wire: TerrainWire) -> OitCoefficients {
let in = from_wire(wire);
let color = oit_terrain_color(in);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
// Stage 3: premultiplied colour, weighted by the transmittance in front.
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_oit_accumulate(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let color = oit_terrain_color(in);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
@@ -312,6 +312,7 @@ impl Opt {
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
Opt::PadInvertX => "horizon.options.invert_x",
Opt::PadInvertY => "horizon.options.invert_y",
Opt::PadCraftOnServers => "horizon.options.craft_on_servers",
Opt::PadToggleSneak => "horizon.options.sneak_button",
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
@@ -849,6 +850,7 @@ impl Opt {
}),
Opt::PadInvertX => on_off(s.controller.invert_x),
Opt::PadInvertY => on_off(s.controller.invert_y),
Opt::PadCraftOnServers => on_off(s.controller.craft_on_servers),
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
"options.key.toggle"
} else {
@@ -1156,6 +1158,9 @@ impl Opt {
}
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
Opt::PadCraftOnServers => {
s.controller.craft_on_servers = !s.controller.craft_on_servers
}
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
Opt::PadNintendoButtons => {
s.controller.nintendo_letters = !s.controller.nintendo_letters
@@ -0,0 +1,237 @@
//! The shared atlas's LabPBR maps (`_n`, `_s` beside an item's, entity's or block's texture),
//! for shader packs: a map the size of the atlas would be mostly empty (only some textures have
//! maps) and as large as the atlas itself, so each is a paged map instead. The atlas is cut into
//! 16x16 pages; only pages holding a mapped texture get one in the map's pool, found through a
//! page table at the map's start. Packs read the maps with the colour's coordinates, as for
//! terrain: the translator turns their reads into `hz_pagedTexture` (horizon-shaders,
//! `PAGED_READ`, which documents the layout this writes).
/// A page's side, in texels.
pub const PAGE: u32 = 16;
/// Texels before the page table: the atlas's size, and the map's value where it has none.
const HEADER: u32 = 2;
/// One paged map, RGBA8.
#[derive(Clone, Debug, PartialEq)]
pub struct PagedMap {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// A texture's maps: its place in the atlas (x, y, width, height, in texels) and its normal and
/// specular images at that size (RGBA8), each where it has one.
pub struct SpriteMaps {
pub rect: [u32; 4],
pub maps: [Option<Vec<u8>>; 2],
}
/// The normal and specular paged maps of an atlas of `size` texels, each `None` where no texture
/// has that map; `max_side` is the GPU's largest texture side.
pub fn build(size: (u32, u32), sprites: &[SpriteMaps], max_side: u32) -> [Option<PagedMap>; 2] {
[
(0, crate::sprites::FLAT_NORMAL),
(1, crate::sprites::NO_SPECULAR),
]
.map(|(kind, default)| {
let mapped = sprites
.iter()
.filter_map(|s| s.maps[kind].as_deref().map(|m| (s.rect, m)));
build_one(size, mapped, default, max_side)
})
}
fn build_one<'a>(
(width, height): (u32, u32),
sprites: impl Iterator<Item = ([u32; 4], &'a [u8])>,
default: [u8; 4],
max_side: u32,
) -> Option<PagedMap> {
let (pages_x, pages_y) = (width.div_ceil(PAGE), height.div_ceil(PAGE));
// Each atlas page's place in the pool, plus one (0: none).
let mut table = vec![0u32; (pages_x * pages_y) as usize];
let mut pool: Vec<Vec<u8>> = Vec::new();
let blank: Vec<u8> = default.repeat((PAGE * PAGE) as usize);
for ([x, y, w, h], map) in sprites {
if w == 0 || h == 0 || map.len() < (w * h * 4) as usize || x + w > width || y + h > height {
continue;
}
for py in y / PAGE..=(y + h - 1) / PAGE {
for px in x / PAGE..=(x + w - 1) / PAGE {
let entry = &mut table[(py * pages_x + px) as usize];
if *entry == 0 {
pool.push(blank.clone());
*entry = pool.len() as u32;
}
let page = &mut pool[*entry as usize - 1];
// The sprite's texels on this page.
let (x0, x1) = (x.max(px * PAGE), (x + w).min((px + 1) * PAGE));
let (y0, y1) = (y.max(py * PAGE), (y + h).min((py + 1) * PAGE));
for ty in y0..y1 {
let src = (((ty - y) * w + (x0 - x)) * 4) as usize;
let dst = (((ty - py * PAGE) * PAGE + (x0 - px * PAGE)) * 4) as usize;
let len = ((x1 - x0) * 4) as usize;
page[dst..dst + len].copy_from_slice(&map[src..src + len]);
}
}
}
}
if pool.is_empty() || pool.len() >= 1 << 24 {
return None;
}
let entries = HEADER + pages_x * pages_y;
// The narrowest map width (whole pages) that keeps it within the GPU's side.
let (side, rows, map_height) = [1024, 2048, 4096, 8192, 16384]
.into_iter()
.filter(|&s| s <= max_side)
.map(|s| {
let rows = entries.div_ceil(s).next_multiple_of(PAGE);
let per_row = s / PAGE;
(s, rows, rows + (pool.len() as u32).div_ceil(per_row) * PAGE)
})
.find(|&(_, _, h)| h <= max_side)?;
let mut pixels = vec![0u8; (side * map_height * 4) as usize];
let header = [
width as u8,
(width >> 8) as u8,
height as u8,
(height >> 8) as u8,
];
pixels[..4].copy_from_slice(&header);
pixels[4..8].copy_from_slice(&default);
for (i, &k) in table.iter().enumerate() {
let at = (HEADER as usize + i) * 4;
pixels[at..at + 4].copy_from_slice(&[k as u8, (k >> 8) as u8, (k >> 16) as u8, 255]);
}
let per_row = side / PAGE;
for (k, page) in pool.iter().enumerate() {
let k = k as u32;
let (ox, oy) = (k % per_row * PAGE, rows + k / per_row * PAGE);
for row in 0..PAGE {
let dst = (((oy + row) * side + ox) * 4) as usize;
let src = (row * PAGE * 4) as usize;
pixels[dst..dst + (PAGE * 4) as usize]
.copy_from_slice(&page[src..src + (PAGE * 4) as usize]);
}
}
Some(PagedMap {
width: side,
height: map_height,
pixels,
})
}
/// `map` (RGBA8, `mw` by `mh`) at `w` by `h`, nearest: a map drawn for a strip of animation
/// frames keeps its first, the colour's shape from its top.
pub fn fit(map: &[u8], (mw, mh): (u32, u32), (w, h): (u32, u32)) -> Option<Vec<u8>> {
if mw == 0 || mh == 0 || w == 0 || h == 0 || map.len() < (mw * mh * 4) as usize {
return None;
}
// The first frame of a strip as tall as the colour's frame is wide, scaled.
let frame_h = (u64::from(mw) * u64::from(h) / u64::from(w)).clamp(1, u64::from(mh)) as u32;
let mut out = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
let sy = (u64::from(y) * u64::from(frame_h) / u64::from(h)) as u32;
for x in 0..w {
let sx = (u64::from(x) * u64::from(mw) / u64::from(w)) as u32;
let at = ((sy * mw + sx) * 4) as usize;
out.extend_from_slice(&map[at..at + 4]);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// What `PAGED_READ` returns at texel (x, y) of the atlas.
fn read(map: &PagedMap, (width, height): (u32, u32), (x, y): (u32, u32)) -> [u8; 4] {
let texel = |at: u32| {
let at = (at * 4) as usize;
[0, 1, 2, 3].map(|c| map.pixels[at + c])
};
let at = |x: u32, y: u32| texel(y * map.width + x);
let h = texel(0);
assert_eq!(
(
u32::from(h[0]) | u32::from(h[1]) << 8,
u32::from(h[2]) | u32::from(h[3]) << 8
),
(width, height)
);
let pages = (width.div_ceil(PAGE), height.div_ceil(PAGE));
let e = texel(2 + (y / PAGE) * pages.0 + x / PAGE);
let k = u32::from(e[0]) | u32::from(e[1]) << 8 | u32::from(e[2]) << 16;
if k == 0 {
return texel(1);
}
let rows = (2 + pages.0 * pages.1)
.div_ceil(map.width)
.next_multiple_of(16);
let per_row = map.width / 16;
at(
(k - 1) % per_row * 16 + x % 16,
rows + (k - 1) / per_row * 16 + y % 16,
)
}
#[test]
fn mapped_textures_read_back_and_the_rest_reads_the_default() {
let size = (4096, 2048);
// A 20x20 texture across four pages, and one without a specular map.
let normal: Vec<u8> = (0..20 * 20)
.flat_map(|i: u32| [i as u8, (i >> 8) as u8, 7, 255])
.collect();
let sprites = [
SpriteMaps {
rect: [30, 40, 20, 20],
maps: [Some(normal.clone()), Some(vec![9; 20 * 20 * 4])],
},
SpriteMaps {
rect: [1000, 1000, 16, 16],
maps: [Some(vec![3; 16 * 16 * 4]), None],
},
];
let [normals, specular] = build(size, &sprites, 16384);
let (normals, specular) = (normals.unwrap(), specular.unwrap());
for y in 0..20 {
for x in 0..20 {
let i = ((y * 20 + x) * 4) as usize;
assert_eq!(read(&normals, size, (30 + x, 40 + y)), normal[i..i + 4]);
assert_eq!(read(&specular, size, (30 + x, 40 + y)), [9; 4]);
}
}
// The texels beside it on its pages, and pages without a map.
assert_eq!(read(&normals, size, (29, 40)), crate::sprites::FLAT_NORMAL);
assert_eq!(read(&normals, size, (2000, 5)), crate::sprites::FLAT_NORMAL);
assert_eq!(
read(&specular, size, (1005, 1005)),
crate::sprites::NO_SPECULAR
);
assert_eq!(read(&normals, size, (1005, 1005)), [3; 4]);
// Five pages: a table of 2 + 256 x 128 entries, 33 rows at 1024 wide (48, whole pages),
// then a row of pages.
assert_eq!((normals.width, normals.height), (1024, 64));
}
#[test]
fn nothing_mapped_makes_no_map() {
let sprites = [SpriteMaps {
rect: [0, 0, 16, 16],
maps: [None, None],
}];
assert_eq!(build((1024, 1024), &sprites, 16384), [None, None]);
}
#[test]
fn a_map_fits_its_texture() {
// A 2x map is sampled down; a strip of frames keeps its first.
let map: Vec<u8> = (0..4 * 8).flat_map(|i: u8| [i, 0, 0, 255]).collect();
let fitted = fit(&map, (4, 8), (2, 2)).unwrap();
assert_eq!(
fitted.chunks(4).map(|p| p[0]).collect::<Vec<_>>(),
[0, 2, 8, 10]
);
}
}
@@ -840,6 +840,7 @@ pub enum Opt {
PadRepeatInterval,
PadInvertX,
PadInvertY,
PadCraftOnServers,
PadToggleSneak,
PadMoveDeadzone,
PadLookDeadzone,
+108 -56
View File
@@ -199,26 +199,73 @@ fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
// The terrain stages' values, as the code reads them. What crosses from the vertex stage to the
// fragment stage is `TerrainWire`: the same values in fewer interpolant locations.
struct TerrainOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) color: vec4<f32>,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
clip: vec4<f32>,
uv: vec2<f32>,
layer: u32,
color: vec4<f32>,
spherical: f32,
cylindrical: f32,
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
@location(5) @interpolate(flat) emission: u32,
emission: u32,
// HDR lighting: block and sky light, as levels 0 to 15.
@location(11) scene: vec2<f32>,
@location(6) @interpolate(flat) fade: u32,
scene: vec2<f32>,
fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@location(7) local: vec3<f32>,
local: vec3<f32>,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
visibility: f32,
// The camera-relative height, for the water mirror's cut at the plane.
@location(9) height: f32,
@location(10) @interpolate(flat) emissive_layer: u32,
height: f32,
emissive_layer: u32,
}
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
// components are used): on an RX 580 the 12 cost 3.2 ns per quad and these 5 cost 1.6. Every
// component is still interpolated on its own (and the integers stay flat), so each pixel gets
// the very values the separate locations gave it.
struct TerrainWire {
@builtin(position) clip: vec4<f32>,
// The colour.
@location(0) a: vec4<f32>,
// `local` and `visibility`.
@location(1) b: vec4<f32>,
// `uv` and `scene`.
@location(2) c: vec4<f32>,
// `spherical`, `cylindrical` and `height`.
@location(3) d: vec4<f32>,
// `layer`, `emission`, `fade` and `emissive_layer`.
@location(4) @interpolate(flat) e: vec4<u32>,
}
fn to_wire(o: TerrainOut) -> TerrainWire {
var w: TerrainWire;
w.clip = o.clip;
w.a = o.color;
w.b = vec4(o.local, o.visibility);
w.c = vec4(o.uv, o.scene);
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
return w;
}
fn from_wire(w: TerrainWire) -> TerrainOut {
var o: TerrainOut;
o.clip = w.clip;
o.color = w.a;
o.local = w.b.xyz;
o.visibility = w.b.w;
o.uv = w.c.xy;
o.scene = w.c.zw;
o.spherical = w.d.x;
o.cylindrical = w.d.y;
o.height = w.d.z;
o.layer = w.e.x;
o.emission = w.e.y;
o.fade = w.e.z;
o.emissive_layer = w.e.w;
return o;
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -333,9 +380,9 @@ fn fade_code(w: f32) -> u32 {
@vertex fn vs_terrain(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
// index list. A section's vertices fill whole 256-vertex blocks of its page:
@@ -346,10 +393,10 @@ fn fade_code(w: f32) -> u32 {
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Solid terrain draws in two passes, each over its own part of every mesh's quads
// (`mesher::Part`): first the quads with no cutout that are not fading, through
@@ -370,38 +417,38 @@ fn left_out() -> TerrainOut {
@vertex fn vs_terrain_opaque(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_cutout(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_flat_opaque(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_flat_cutout(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
@@ -511,37 +558,37 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
unorm8x4(c.r.z), light, origin.xyz, origin.w);
}
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
return corner_vertex(section_corner(vertex).c, origin);
@location(5) origin: vec4<f32>) -> TerrainWire {
return to_wire(corner_vertex(section_corner(vertex).c, origin));
}
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
@location(5) origin: vec4<f32>) -> TerrainWire {
let s = section_corner(vertex);
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
@location(5) origin: vec4<f32>) -> TerrainWire {
let s = section_corner(vertex);
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
return corner_vertex(s.c, origin);
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
@@ -620,11 +667,13 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_solid(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
return solid_color(in);
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_opaque(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
@@ -635,7 +684,8 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_solid_mirror(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if in.height < globals.fog_ends.w { discard; }
return solid_color(in);
}
@@ -703,7 +753,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_translucent_mark(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
@@ -716,7 +767,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// Water, glass and other see-through faces draw whole as they fade in, the
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
// pixels showed the layer behind through the one in front, twice the water.
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_translucent(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.01 { discard; }
@@ -63,6 +63,8 @@
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
"horizon.options.invert_y": "Invert Look Y",
"horizon.options.craft_on_servers": "Quick Craft on Servers",
"horizon.controller.direct_craft": "Quick Craft",
"horizon.options.sneak_button": "Sneak Button",
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
@@ -975,6 +977,7 @@
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
@@ -1443,7 +1443,7 @@ pub fn main() -> horizon_client::Result<()> {
// What a copper golem did to chests, before the next mob ticks.
for e in &events[from.min(events.len())..] {
match e {
MobEvent::ChestOpen { chest, open } => {
MobEvent::ChestOpen { chest, open, .. } => {
let o = world
.chest_openers
.entry((chest[0], chest[1], chest[2]))
@@ -90,6 +90,17 @@ impl CommandStorage {
}
self.dirty = true;
}
/// What an in-place edit of `get`'s compound leaves behind when no
/// `set` follows: a command that changed the stored compound and then
/// failed (`DataCommands.manipulateData` throws before `setData`). Only
/// an id that is stored has a compound to edit; the storage is not
/// marked for saving.
pub fn set_unsaved(&mut self, id: &str, contents: Tag) {
let (ns, path) = split_id(id);
if let Some(stored) = self.namespaces.get_mut(ns).and_then(|m| m.get_mut(path)) {
*stored = contents;
}
}
/// Every stored id (`keys`).
pub fn keys(&self) -> Vec<String> {
self.namespaces
+139 -1
View File
@@ -664,6 +664,12 @@ pub fn mob_to_nbt(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
// Entity.saveWithoutId: the hurt immunity left, only while there is some.
if m.invulnerable > 0 {
map.insert("invulnerable_time".into(), Tag::Int(m.invulnerable));
} else {
map.remove(&horizon_world::nbt::JavaString::from("invulnerable_time"));
}
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
@@ -1938,7 +1944,12 @@ pub fn mob_from_nbt(
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
// `MobEffectInstance.Details`: Codec.BYTE uses boxed Number.byteValue,
// then UNSIGNED_BYTE interprets the resulting byte as 0..255.
let amplifier = e
.get("amplifier")
.and_then(horizon_world::datafix::dynamic::as_number)
.map_or(0, |n| i32::from(n.byte_value() as u8));
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
@@ -1955,6 +1966,8 @@ pub fn mob_from_nbt(
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
// Entity.load: `getIntOr("invulnerable_time", 0)`.
m.invulnerable = number(tag.get("invulnerable_time")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Bat {
// Bat.readAdditionalSaveData: BatFlags bit 0, resting.
m.extra.flag = number(tag.get("BatFlags")).unwrap_or(0) & 1 != 0;
@@ -2639,6 +2652,28 @@ fn item_uuid_from_tag(tag: Option<&Tag>) -> Option<[i32; 4]> {
values.get(..4)?.try_into().ok()
}
/// The `Entity` fields a saved non-mob entity carries (`Base::tag`), for a
/// caller that writes its own kind's fields around them.
pub fn entity_base_tag(
id: &str,
position: Vec3,
motion: Vec3,
rotation: [f32; 2],
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
Base {
id,
uuid,
position,
motion,
rotation,
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: 0.0,
}
.tag(raw)
}
/// `ItemEntity.readAdditionalSaveData`: Health uses NumericTag's short conversion;
/// Owner and Thrower use the numeric UUID codec.
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -9392,6 +9427,109 @@ mod carrier_inventory_tests {
}
}
#[cfg(test)]
mod mob_effect_amplifier_codec {
use super::*;
fn types() -> HashMap<String, Arc<MobType>> {
let kind = crate::mobs::sensor_scan_tests::kind();
HashMap::from([(kind.id.clone(), kind)])
}
fn input(amplifier: Option<Tag>) -> Tag {
let mut effect = Tag::compound([
("id", string("minecraft:night_vision")),
("duration", Tag::Int(200)),
("ambient", Tag::Byte(1)),
("show_particles", Tag::Byte(0)),
("show_icon", Tag::Byte(0)),
]);
if let Some(value) = amplifier {
put(&mut effect, "amplifier", value);
}
Tag::compound([
("id", string("minecraft:cow")),
("Pos", doubles(&[6.5, 200.0, 6.5])),
("Health", Tag::Float(10.0f32.to_bits())),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Tags", Tag::List(vec![string("amplifier-control")])),
("active_effects", Tag::List(vec![effect])),
])
}
fn saved_effect(tag: &Tag) -> &Tag {
let Some(Tag::List(effects)) = tag.get("active_effects") else {
panic!("actual effect missing: {tag:?}");
};
assert_eq!(effects.len(), 1);
&effects[0]
}
#[test]
fn decoded_numeric_amplifiers_roundtrip_without_raw_input() {
let cases = [
(None, 0),
(Some(Tag::Byte(0)), 0),
(Some(Tag::Int(1)), 1),
(Some(Tag::Byte(127)), 127),
(Some(Tag::Byte(-128)), 128),
(Some(Tag::Byte(-1)), 255),
(Some(Tag::Int(256)), 0),
(Some(Tag::Int(-1)), 255),
(Some(Tag::Long(9007199254740993)), 1),
(Some(Tag::Long(-4294967297)), 255),
(Some(Tag::Float(1.75f32.to_bits())), 1),
(Some(Tag::Float((-0.5f32).to_bits())), 0),
(Some(Tag::Double((-1.75f64).to_bits())), 255),
(Some(Tag::Double(256.75f64.to_bits())), 0),
(Some(Tag::Double(1e20f64.to_bits())), 255),
(Some(Tag::Double((-1e20f64).to_bits())), 0),
];
let types = types();
for (value, expected) in cases {
let raw = input(value.clone());
let mob = mob_from_nbt(&raw, &types, 7, None).unwrap();
assert_eq!(
mob.extra.effects,
[("minecraft:night_vision", 200, expected)],
"{value:?}"
);
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [158, 0, 0, 1], source, None, |_| None);
let effect = saved_effect(&saved);
let expected_tag = (expected != 0).then_some(Tag::Byte(expected as u8 as i8));
assert_eq!(effect.get("amplifier"), expected_tag.as_ref(), "{value:?}");
assert_eq!(effect.get("duration"), Some(&Tag::Int(200)));
assert_eq!(effect.get("ambient"), Some(&Tag::Byte(1)));
assert_eq!(effect.get("show_particles"), Some(&Tag::Byte(0)));
assert_eq!(effect.get("show_icon"), Some(&Tag::Byte(0)));
let back = mob_from_nbt(&saved, &types, 8, None).unwrap();
assert_eq!(back.extra.effects, mob.extra.effects);
}
}
}
#[test]
fn modeled_amplifier_mutation_overrides_stale_raw_and_omits_zero() {
let raw = input(Some(Tag::Byte(12)));
let mut mob = mob_from_nbt(&raw, &types(), 7, None).unwrap();
for amplifier in [0, 128, 255, 1] {
mob.extra.effects[0].2 = amplifier;
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [158, 0, 0, 2], source, None, |_| None);
let expected = (amplifier != 0).then_some(Tag::Byte(amplifier as u8 as i8));
assert_eq!(saved_effect(&saved).get("amplifier"), expected.as_ref());
assert_eq!(saved.get("Health"), Some(&Tag::Float(10.0f32.to_bits())));
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
if source.is_some() {
assert_eq!(saved.get("Tags"), raw.get("Tags"));
}
}
}
}
}
#[cfg(test)]
mod camel_inherited_saved_tests {
use super::*;
+45 -1
View File
@@ -336,6 +336,7 @@ mod ordered_tick;
mod packs;
mod pad;
mod pad_actions;
mod pad_craft;
mod pad_menu;
mod paintings;
mod pandas;
@@ -2176,6 +2177,19 @@ impl FallingBlock {
}
}
/// A block entity's `mark_motion` id: its position, packed into the 32 bits beside the tag
/// (x and z wrap at 2048 blocks and y at 1024; two block entities that far apart in one frame's
/// view are told apart by their vertex counts, as `PackEntities` matches only equal runs).
fn motion_block_entity(pos: [i32; 3]) -> u64 {
motion_at(Game::MOTION_BLOCK_ENTITY, pos)
}
/// `tag` with a block position packed into its low 32 bits (`motion_block_entity`).
fn motion_at(tag: u64, pos: [i32; 3]) -> u64 {
let [x, y, z] = pos.map(|c| c as u32);
tag | u64::from((x & 0x7FF) | (z & 0x7FF) << 11 | (y & 0x3FF) << 22)
}
/// `FallingBlockRenderer.shouldRender` drops a falling block whose own state is
/// the state of the cell it is in (`level.getBlockState(entity.blockPosition())`):
/// a `NoGravity` block, or one that has just landed, sits inside an identical
@@ -7682,6 +7696,7 @@ impl Game<'_> {
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
self.tick_explicit_knots();
self.ordered_before(ordered_tick::Batch::ArmorStands)?;
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands())?;
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
@@ -8318,6 +8333,7 @@ impl Game<'_> {
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -8381,6 +8397,7 @@ impl Game<'_> {
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -8515,6 +8532,7 @@ impl Game<'_> {
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -9530,6 +9548,17 @@ impl Game<'_> {
None => camera_rotation(self.camera_type, self.yaw, self.pitch),
}
}
/// `mark_motion` ids of entities that aren't mobs or dropped items (boats, minecarts, armor
/// stands, item frames, paintings, primed TNT, falling blocks): their entity id under this tag.
pub(super) const MOTION_OBJECT: u64 = 3 << 32;
/// An item frame's held item, apart from the frame (one id may mark one run).
pub(super) const MOTION_FRAME_ITEM: u64 = 4 << 32;
/// A block entity, by its position (`motion_block_entity`).
pub(super) const MOTION_BLOCK_ENTITY: u64 = 5 << 32;
/// A block a piston moves, by the cell it moves to (`motion_at`).
pub(super) const MOTION_MOVING_BLOCK: u64 = 6 << 32;
/// The player's own model (third person, and a shader pack's shadow pass).
pub(super) const MOTION_OWN_PLAYER: u64 = 7 << 32;
/// The entity whose geometry starts at vertex `start` (0: none), for frame
/// generation to follow its own motion. Each run lasts until the next mark.
pub(super) fn mark_motion(&self, start: usize, id: u64) {
@@ -9716,6 +9745,7 @@ impl Game<'_> {
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
@@ -9729,6 +9759,7 @@ impl Game<'_> {
.borrow_mut()
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
@@ -9962,7 +9993,10 @@ impl Game<'_> {
r.block_entity_revision(),
Some(&section_revision),
&mut out,
|_, state, start| self.mark_block_entity(start, state),
|pos, state, start| {
self.mark_block_entity(start, state);
self.mark_motion(start, motion_block_entity(pos));
},
|pos, _, start, out| {
if shadow_only_at.contains(&pos) {
self.hide_spans_from(start, out.len());
@@ -10155,6 +10189,7 @@ impl Game<'_> {
let light = self.world.light(x, y + 1, z);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let start = out.len();
self.mark_motion(start, Self::MOTION_OBJECT | u64::from(f.id));
self.block_model_vertices(
f.state,
horizon_core::random::position_seed(f.start_pos),
@@ -10168,23 +10203,30 @@ impl Game<'_> {
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
}
}
self.mark_motion(out.len(), 0);
self.mark_entity(out.len(), "");
self.moving_piston_geometry(camera, alpha, &mut out);
// LevelRenderer.submitTransientBlocks.
self.transient_block_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:tnt");
self.tnt_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
self.mark_entity(out.len(), "minecraft:painting");
self.painting_geometry(camera, &mut out);
self.mark_motion(out.len(), 0);
self.mark_entity(out.len(), "minecraft:item_frame");
self.frame_geometry(camera, &mut out);
self.mark_motion(out.len(), 0);
self.mark_entity(out.len(), "minecraft:armor_stand");
self.armor_stand_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
self.cushion_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:oak_boat");
self.boat_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
self.mark_entity(out.len(), "minecraft:minecart");
self.cart_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
// Beams: `gbuffers_beaconbeam`, not a block entity's program (5.4 has no stage for them).
self.mark_stage(out.len(), "NONE");
self.beacon_geometry(camera, alpha, &mut out);
@@ -18224,6 +18266,8 @@ fn write_level_for_role(
}
}
#[cfg(test)]
mod storage_alias_tests;
#[cfg(test)]
mod sulfur_bucket_tests;
#[cfg(test)]
@@ -1089,6 +1089,7 @@ impl Game<'_> {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(a.id));
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
+11
View File
@@ -1380,6 +1380,9 @@ impl Game<'_> {
// of coasting on the velocity of the ride that just ended.
self.boats[i].velocity = Vec3::ZERO;
self.boats[i].delta_rotation = 0.0;
if self.dismount_in_portal(self.boats[i].position) {
return;
}
let target = self.boat_dismount_target(i, 0.6, self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
@@ -1619,7 +1622,14 @@ impl Game<'_> {
// Player.attack's full damage against the boat's entity type.
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
let chest = self.boats[i].chest;
let hurt = self.hurt_boat_result(i, damage, self.creative);
// AbstractChestBoat.destroy -> ContainerEntity.chestVehicleDestroyed:
// with entity_drops on, a player whose hit breaks a chest boat (not a
// creative discard) angers the piglins that see it.
if chest && hurt == Some(true) && !self.creative && self.ui.rules.bool("entity_drops") {
self.anger_piglins(true);
}
self.vehicle_attack_sounds(area, magic.then_some(id));
if damage > 0.0
&& let Some(destroyed) = hurt
@@ -1729,6 +1739,7 @@ impl Game<'_> {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(b.id));
let models = if b.chest {
&self.boat_models.chest_models
} else {
@@ -91,11 +91,14 @@ impl Prompt {
}
impl Game<'_> {
/// The server list entry of the joined address, if it has one.
/// The server list entry of the joined address, if it has one, shown or
/// hidden (`ServerList.get`).
fn accepted_code_of_conduct(&self) -> Option<i32> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
let (shown, hidden) = servers::load_all(&multiplayer::servers_file(&saves()));
shown
.into_iter()
.chain(hidden)
.find(|s| s.ip == address)
.map(|s| s.accepted_code_of_conduct)
}
@@ -106,13 +109,20 @@ impl Game<'_> {
return;
};
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
let (mut list, mut hidden) = servers::load_all(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accepted_code_of_conduct = accepted;
found = true;
}
if found && let Err(e) = servers::save(&file, &list) {
// Not among the shown entries: the hidden ones (a Direct Connection's).
if !found {
for entry in hidden.iter_mut().filter(|s| &s.ip == address) {
entry.accepted_code_of_conduct = accepted;
found = true;
}
}
if found && let Err(e) = servers::save_all(&file, &list, &hidden) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
+205
View File
@@ -96,6 +96,8 @@ pub(in crate::game) struct OtherEntity {
pub yaw: f32,
pub pitch: f32,
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
/// Only explicitly loaded leash knots own this BlockAttachedEntity check counter.
pub knot_check: Option<u8>,
}
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
CommandError::tr(key, args)
@@ -3735,6 +3737,46 @@ impl Game<'_> {
_ => false,
}
}
// `MinMaxBounds.Ints.matches`: DamagePredicate's damage-only
// Int bounds shape; effective DAMAGE presence is required.
// Other fields keep the entire existing predicate path.
("minecraft:damage", Some(ref v))
if matches!(v, horizon_world::nbt::Tag::Compound(c) if c.len() == 1)
&& match v.get("damage") {
Some(horizon_world::nbt::Tag::Int(_)) => true,
Some(horizon_world::nbt::Tag::Compound(bounds)) => {
bounds.iter().all(|(key, value)| {
matches!(key.to_string_lossy().as_str(), "min" | "max")
&& matches!(value, horizon_world::nbt::Tag::Int(_))
})
}
_ => false,
} =>
{
let removed = stack
.data()
.is_some_and(|data| data.other("!minecraft:damage").is_some());
let has_damage = !removed
&& (present.is_some()
|| self.items().get(&stack.item).is_some_and(|item| {
item.components.iter().any(|c| c == "minecraft:damage")
}));
let n = i64::from(stack.damage);
has_damage
&& match v.get("damage").unwrap() {
horizon_world::nbt::Tag::Int(exact) => n == i64::from(*exact),
bounds => {
let get = |key| match bounds.get(key) {
Some(horizon_world::nbt::Tag::Int(value)) => {
Some(i64::from(*value))
}
_ => None,
};
get("min").is_none_or(|min| n >= min)
&& get("max").is_none_or(|max| n <= max)
}
}
}
(_, Some(v)) => present.is_some_and(|have| {
horizon_client::nbt_path::compare_nbt(&v, Some(have))
}),
@@ -5128,6 +5170,169 @@ mod forced_respawn_tests {
}
}
#[cfg(test)]
mod item_damage_range_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_core::position::BlockPos;
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn actual_chest_damage_range_uses_live_damage_and_effective_presence() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("setblock 4 199 4 stone"));
assert!(game.run_command(r#"setblock 4 200 4 chest{Items:[{Slot:0b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:custom_data":{marker:195}}},{Slot:1b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":3,"minecraft:custom_data":{marker:195}}},{Slot:2b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":4,"minecraft:custom_data":{marker:195}}},{Slot:3b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":5,"minecraft:custom_data":{marker:195}}},{Slot:4b,id:"minecraft:stone",count:1,components:{"minecraft:custom_data":{marker:195}}},{Slot:5b,id:"minecraft:diamond_sword",count:1,components:{"!minecraft:damage":{},"minecraft:custom_data":{marker:195}}}]}"#));
assert!(game.run_command("scoreboard objectives add h195 dummy"));
assert!(game.world.loaded(4, 4));
assert!(game.run_command("execute if block 4 199 4 stone"));
let clock = game.world.game_time;
let stacks = match game.world.block_entities.get(&(4, 200, 4)).unwrap() {
horizon_client::containers::BlockEntity::Chest(slots) => slots.clone(),
_ => panic!("actual loaded chest missing"),
};
for (slot, damage) in [0, 3, 4, 5, 0, 0].into_iter().enumerate() {
let stack = stacks[slot].as_ref().unwrap();
assert_eq!(stack.damage, damage);
assert_eq!(stack.count, 1);
assert_eq!(
&*stack.item,
if slot == 4 {
"minecraft:stone"
} else {
"minecraft:diamond_sword"
}
);
assert_eq!(
stack.custom_data().unwrap().get(&"marker".into()),
Some(&horizon_world::nbt::Tag::Int(195))
);
assert!(game.run_command(&format!(
"execute if items block 4 200 4 container.{slot} {}",
stack.item
)));
}
assert!(stacks[6].is_none());
assert!(
game.items()
.get("minecraft:diamond_sword")
.unwrap()
.components
.iter()
.any(|c| c == "minecraft:damage")
);
assert!(
!game
.items()
.get("minecraft:stone")
.unwrap()
.components
.iter()
.any(|c| c == "minecraft:damage")
);
let removed =
horizon_client::item_data::encode_components(stacks[5].as_ref().unwrap()).unwrap();
assert!(
removed.get("!minecraft:damage").is_some(),
"actual removal patch must survive load before evaluating it"
);
assert!(game.run_command(
"execute unless data block 4 200 4 Items[{Slot:0b}].components.\"minecraft:damage\""
));
assert!(game.run_command(
"execute if data block 4 200 4 Items[{Slot:2b,components:{\"minecraft:damage\":4}}]"
));
assert!(game.run_command(
"execute if data block 4 200 4 Items[{Slot:5b}].components.\"!minecraft:damage\""
));
let mut cases = Vec::new();
for (slot, damage) in [(1, 3), (2, 4), (3, 5)] {
for (shape, expected) in [
("4", damage == 4),
("{min:4}", damage >= 4),
("{max:4}", damage <= 4),
("{min:3,max:4}", (3..=4).contains(&damage)),
] {
cases.push((
slot,
format!("minecraft:damage~{{damage:{shape}}}"),
expected,
));
}
}
cases.extend([
(0, "damage~{damage:0}".into(), true),
(0, "damage~{damage:{min:1}}".into(), false),
(4, "damage~{damage:0}".into(), false),
(5, "damage~{damage:0}".into(), false),
(2, "!damage~{damage:4}".into(), false),
(1, "!damage~{damage:4}".into(), true),
(1, "damage=3".into(), true),
(1, "damage=4".into(), false),
(1, "count~1".into(), true),
(1, "custom_data~{marker:195}".into(), true),
]);
let mut mismatches = Vec::new();
for (slot, term, expected) in cases {
let stack = stacks[slot].as_ref().unwrap();
let predicate = ItemInput {
id: stack.item.to_string(),
tag: false,
any: false,
components: Some(format!("[{term}]")),
};
let actual = game.stack_matches(&predicate, stack);
if actual != expected {
mismatches.push(format!(
"direct slot{slot} {term}: {actual}, expected{expected}"
));
}
assert!(game.run_command("scoreboard players set #r h195 91"));
assert!(game.run_command("scoreboard players set #s h195 17"));
let command = format!(
"execute store result score #r h195 store success score #s h195 if items block 4 200 4 container.{slot} {}[{term}]",
stack.item
);
game.run_command(&command);
let r = game.score.board.score("#r", "h195").map(|s| s.value);
let s = game.score.board.score("#s", "h195").map(|s| s.value);
if (r, s) != (Some(i32::from(expected)), Some(i32::from(expected))) {
mismatches.push(format!("command {term}: {r:?}/{s:?}, expected{expected}"));
}
assert!(game.run_command("scoreboard players set #branch h195 23"));
game.run_command(&format!("execute unless items block 4 200 4 container.{slot} {}[{term}] run scoreboard players set #branch h195 7",stack.item));
let branch = game.score.board.score("#branch", "h195").map(|s| s.value);
if branch != Some(if expected { 23 } else { 7 }) {
mismatches.push(format!("unless {term}: {branch:?}"));
}
}
assert!(!game.run_command(
"execute if items block 4 200 4 container.4 diamond_sword[damage~{damage:4}]"
));
assert!(!game.run_command(
"execute if items block 4 200 4 container.6 diamond_sword[damage~{damage:4}]"
));
let actual = match game.world.block_entities.get(&(4, 200, 4)).unwrap() {
horizon_client::containers::BlockEntity::Chest(slots) => slots,
_ => panic!("source chest changed"),
};
assert_eq!(
actual, &stacks,
"queries must leave the authoritative loaded stacks unchanged"
);
assert_eq!(game.world.game_time, clock);
assert!(
mismatches.is_empty(),
"damage-only command predicate mismatches: {mismatches:#?}"
);
}
}
#[cfg(test)]
mod vehicle_kill_tests {
use super::*;
+136 -3
View File
@@ -263,6 +263,7 @@ impl Game<'_> {
id,
kind: "minecraft:area_effect_cloud".into(),
item_transformation: None,
knot_check: None,
position: horizon_core::geometry::Vec3::new(x, y, z),
yaw: 0.0,
pitch: 0.0,
@@ -374,9 +375,9 @@ impl Game<'_> {
st.liked_player_ttl = was.liked_player_ttl;
}
}
// Entity.load mutates the existing living entity; its damage
// cooldown and previous-hit amount are not read from saved NBT.
slot.invulnerable = old.invulnerable;
// Entity.load mutates the existing living entity; its
// previous-hit amount is not read from saved NBT (its hurt
// immunity is, as `invulnerable_time`).
slot.last_hurt = old.last_hurt;
// Entity.load updates saved data on the existing entity;
// lastTradedPlayer is transient and survives a live NoAI edit.
@@ -1111,6 +1112,13 @@ impl Game<'_> {
let mut data = self.data_of(&target)?;
let changed = data_cmd::modify(op, &mut data, &target_path, &sources)?;
if changed == 0 {
// StorageDataAccessor.getData is the stored compound itself: an
// edit that changed it and still counted nothing (a wildcard set
// that clears an array, then rejects the value) stays. An entity's
// or a block's data is a copy.
if let DataRef::Storage(id) = &target {
self.score.storage.set_unsaved(id, data);
}
return Err(err("commands.data.merge.failed", &[]));
}
self.set_data_of(&target, data)?;
@@ -1894,6 +1902,131 @@ mod modern_literal_tests {
}
}
#[cfg(test)]
mod bell_command_identity_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
use horizon_core::{geometry::Vec3, position::ChunkPos};
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h174-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("tick freeze"));
assert!(game.run_command("fill 0 198 0 15 199 15 stone"));
test(&mut game);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn placed_bell_is_a_real_stored_entity_with_command_metadata() {
with_game(|game| {
let clock = game.world.game_time;
assert!(game.run_command("setblock 8 200 4 chest[facing=north] strict"));
assert!(game.world.block_entities.contains_key(&(8, 200, 4)));
assert!(game.run_command("setblock 10 200 4 stone strict"));
assert!(game.block_entity_nbt([10, 200, 4]).is_none());
assert!(game.run_command(
"setblock 4 200 4 bell[facing=north,attachment=floor,powered=false] strict"
));
assert!(game.run_command(
"execute if block 4 200 4 bell[facing=north,attachment=floor,powered=false]"
));
assert!(game.world.chunks.contains_key(&(0, 0)));
let entity = game
.world
.block_entities
.get(&(4, 200, 4))
.expect("actual placed bell stored in collection");
assert_eq!(entity.id(), "minecraft:bell");
assert!(entity.fits("minecraft:bell"));
assert!(!entity.fits("minecraft:chest"));
for _ in 0..2 {
let tag = game.block_entity_nbt([4, 200, 4]).unwrap();
for (key, value) in [
("id", Tag::String("minecraft:bell".into())),
("x", Tag::Int(4)),
("y", Tag::Int(200)),
("z", Tag::Int(4)),
] {
assert_eq!(tag.get(key), Some(&value));
}
assert!(game.run_command(
"execute if data block 4 200 4 {id:\"minecraft:bell\",x:4,y:200,z:4}"
));
}
// No raw input: factory/model and the real common writer supply metadata.
let fresh = BlockEntity::for_block("minecraft:bell").unwrap();
let tag = save::block_entity_data((-3, 204, 11), &fresh, "minecraft:bell", None);
assert_eq!(tag.get("x"), Some(&Tag::Int(-3)));
assert_eq!(tag.get("y"), Some(&Tag::Int(204)));
assert_eq!(tag.get("z"), Some(&Tag::Int(11)));
let (pos, back) = save::decode_block_entity(&tag).unwrap();
assert_eq!(pos, (-3, 204, 11));
assert_eq!(back.id(), "minecraft:bell");
assert!(game.run_command(
"setblock 4 200 4 bell[facing=east,attachment=floor,powered=false] strict"
));
assert_eq!(
game.world.block_entities.get(&(4, 200, 4)).unwrap().id(),
"minecraft:bell"
);
assert!(game.run_command("setblock 4 200 4 air strict"));
assert!(!game.world.block_entities.contains_key(&(4, 200, 4)));
assert!(game.world.block_entities.contains_key(&(8, 200, 4)));
assert!(game.run_command(
"setblock 4 200 4 bell[facing=south,attachment=floor,powered=false] strict"
));
assert_eq!(
game.world.block_entities.get(&(4, 200, 4)).unwrap().id(),
"minecraft:bell"
);
assert_eq!(game.world.game_time, clock);
});
}
}
#[cfg(test)]
mod falling_live_adoption_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
@@ -146,9 +146,20 @@ impl Game<'_> {
(Target::Mob(id), e) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
let before = m.extra.effects.len();
let boosted = |m: &horizon_client::mobs::Mob| {
m.extra
.effects
.iter()
.any(|x| x.0 == "minecraft:health_boost")
};
let had_boost = boosted(m);
m.extra
.effects
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes.
if had_boost && !boosted(m) {
horizon_client::mobs::finalize::health_boost_changed(m);
}
let left = &m.extra.effects;
m.extra
.hidden_effects
@@ -2222,3 +2222,218 @@ mod mob_equipment_stack_tests {
});
}
}
#[cfg(test)]
mod builtin_component_delivery_tests {
use crate::game::{Game, lan::LanHost};
use horizon_client::{
items::ItemStack,
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig},
};
use horizon_core::position::ChunkPos;
use horizon_protocol::{
buf::{Codec, Reader, Writer},
item::{Slot, component},
packets::{configuration::ClientInformation, play},
};
use horizon_world::nbt::Tag;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h283_actual_command_inventory_bridge_encodes_four_builtin_components() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.world.loaded(8, 8));
game.lan_host(
crate::game::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "component-wire-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 283,
name: "ComponentGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[8.5, 200.0, 8.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.selected = 1;
guest.permission = 2;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let clock = game.world.game_time;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let registries: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("registries.json")).unwrap()).unwrap();
let id = |registry: &str, name: &str| {
registries[registry]["entries"][name]["protocol_id"]
.as_i64()
.unwrap() as i32
};
let command = |game: &mut Game<'_>, host: &mut LanHost, input: &str| {
assert!(
game.as_guest(host, 2, |g| g.run_command(input)).unwrap(),
"{input}"
);
};
let capture = |host: &mut LanHost| {
while rx.try_recv().is_ok() {}
host.hub.send_inventory(0);
let mut packets = Vec::new();
for out in rx.try_iter() {
match out {
Out::Packet(body) => packets.push(body),
Out::Packets(bodies) => packets.extend(bodies),
Out::Disconnect(_) => panic!("unexpected disconnect"),
}
}
let mut contents = Vec::new();
for body in packets {
let mut r = Reader::new(&body);
let packet = r.varint().unwrap();
if packet == horizon_protocol::ids::play::clientbound::CONTAINER_SET_CONTENT {
let content = play::ContainerSetContent::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0, "complete enclosing content payload");
assert_eq!(content.container, 0);
assert_eq!(content.items.len(), 46);
assert!(content.carried.is_none());
contents.push(content);
}
}
assert_eq!(contents.len(), 1, "one actual inventory content packet");
contents.pop().unwrap().items[9].clone().unwrap()
};
command(
&mut game,
&mut host,
"item replace entity @s inventory.0 with stone[minecraft:custom_name=\"H283 control\"] 3",
);
let control = capture(&mut host);
assert_eq!(control.item, id("minecraft:item", "minecraft:stone"));
assert_eq!(control.count, 3);
assert!(control.component(component::CUSTOM_NAME).is_some());
command(
&mut game,
&mut host,
"item replace entity @s inventory.0 with gold_ingot",
);
let plain = capture(&mut host);
assert!(
plain
.component(id(
"minecraft:data_component_type",
"minecraft:provides_trim_material"
))
.is_none()
);
assert!(plain.removed.is_empty());
let mut failures = Vec::new();
for (name, item, value, expected) in [
(
"minecraft:provides_trim_material",
"minecraft:diamond",
"\"minecraft:gold\"",
Tag::String("minecraft:gold".into()),
),
(
"minecraft:provides_banner_patterns",
"minecraft:paper",
"\"#minecraft:pattern_item/globe\"",
Tag::String("#minecraft:pattern_item/globe".into()),
),
(
"minecraft:can_place_on",
"minecraft:stone",
"{blocks:\"minecraft:dirt\"}",
Tag::compound([("blocks", Tag::String("minecraft:dirt".into()))]),
),
(
"minecraft:can_break",
"minecraft:diamond_pickaxe",
"{blocks:\"minecraft:stone\",state:{}}",
Tag::compound([
("blocks", Tag::String("minecraft:stone".into())),
("state", Tag::compound(std::iter::empty::<(&str, Tag)>())),
]),
),
] {
command(
&mut game,
&mut host,
&format!("item replace entity @s inventory.0 with {item}[{name}={value}]"),
);
let source: ItemStack = host.hub.guest(2).unwrap().inventory[9].clone().unwrap();
assert_eq!(source.item.as_ref(), item);
assert_eq!(source.count, 1);
assert_eq!(
source.data().unwrap().other(name),
Some(&expected),
"actual registered command adoption"
);
assert!(source.wire_patch().is_none(), "no cached wire fallback");
let received = capture(&mut host);
assert_eq!(received.item, id("minecraft:item", item));
assert_eq!(received.count, 1);
assert!(received.removed.is_empty());
let kind = id("minecraft:data_component_type", name);
let mut w = Writer::new();
Slot::write(&Some(received.clone()), &mut w);
let mut r = Reader::new(&w.data);
let reread = Slot::read(&mut r).unwrap().unwrap();
assert_eq!(r.remaining(), 0, "complete nested slot payload");
assert_eq!(reread.component(kind), received.component(kind));
if received.component(kind).is_none() {
failures.push((name, received));
}
assert_eq!(host.hub.guest(2).unwrap().uuid, 283);
assert_eq!(host.hub.guest(2).unwrap().health, 20.0);
assert_eq!(host.hub.guest(2).unwrap().position, [8.5, 200.0, 8.5]);
assert_eq!(game.world.game_time, clock);
}
assert!(
failures.is_empty(),
"actual command -> network_stack -> full protocol packet missing builtin components: {failures:?}"
);
}
}
@@ -21,11 +21,14 @@ use horizon_world::{
const RECHECK_TICKS: i64 = 5;
impl Game<'_> {
/// A copper golem opened or closed a chest half: the counter changes and
/// A copper golem opened or closed a chest half: the counter changes, the
/// first opener posts CONTAINER_OPEN and the last closer CONTAINER_CLOSE
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`), and
/// the clients see the block event (`openerCountChanged`).
pub(super) fn golem_chest_open(&mut self, chest: [i32; 3], open: bool) {
pub(super) fn golem_chest_open(&mut self, chest: [i32; 3], open: bool, mob: u32) {
let key = (chest[0], chest[1], chest[2]);
let entry = self.world.chest_openers.entry(key).or_insert((0, 0.0));
let before = entry.0;
if open {
entry.0 += 1;
entry.1 = entry.1.max(3.0);
@@ -39,6 +42,12 @@ impl Game<'_> {
if count <= 0 {
self.world.chest_openers.remove(&key);
}
let who = Some(horizon_client::mobs::Who::Mob(mob));
if open && before == 0 {
self.post_block_game_event("minecraft:container_open", chest, who, None);
} else if !open && count == 0 {
self.post_block_game_event("minecraft:container_close", chest, who, None);
}
self.block_entity_event(chest, 1, count.max(0));
// The guests get the same `ClientboundBlockEventPacket` (the lid).
self.lan_block_event(chest, 1, count.max(0));
@@ -99,9 +108,11 @@ impl Game<'_> {
if count == before {
continue;
}
// `recheckOpeners`: the game event has no source.
if count == 0 {
self.world.chest_openers.remove(&key);
self.golem_chest_sound(p, false);
self.post_block_game_event("minecraft:container_close", p, None, None);
} else if let Some(o) = self.world.chest_openers.get_mut(&key) {
o.0 = count;
}
+29 -14
View File
@@ -2419,13 +2419,13 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
MobEvent::GameEvent {
event: "minecraft:block_place",
event: event @ ("minecraft:block_place" | "minecraft:block_destroy"),
at,
source,
state,
} => {
let ctx = self.who_context(Some(Who::Mob(source)), Some(state));
self.post_game_event("minecraft:block_place", at, ctx);
self.post_game_event(event, at, ctx);
}
// The sculk layer tracks steps and bee/bat flaps itself
// (`entity_motion_events`, `tick_mob_sculk_events`). Birds
@@ -2762,9 +2762,10 @@ impl Game<'_> {
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
}
MobEvent::Tamed { .. } | MobEvent::Buck { .. } | MobEvent::StabDismount { .. } => {
self.mount_event(&e)
}
MobEvent::Tamed { .. }
| MobEvent::Buck { .. }
| MobEvent::StabDismount { .. }
| MobEvent::EjectPassengers { .. } => self.mount_event(&e),
MobEvent::IronGolemHit {
target,
amount,
@@ -2825,7 +2826,7 @@ impl Game<'_> {
let _ = self.process_level_events();
}
}
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
MobEvent::ChestOpen { chest, open, mob } => self.golem_chest_open(chest, open, mob),
MobEvent::ChestSlot { chest, slot, stack } => {
self.golem_chest_slot(chest, slot, stack)
}
@@ -5074,7 +5075,9 @@ impl Game<'_> {
let result = self.projectile_hit_inner(p, hit);
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
result
result?;
// The hit's hurt teleports an enderman or a shulker now (`hurtServer`).
self.settle_hurt_teleports()
}
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let _attacker = self.knockback_scope_for(p.owner);
@@ -5876,12 +5879,22 @@ impl Game<'_> {
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& m.damage_projectile(4.0, source, aim, [p.velocity.x, p.velocity.z])
{
m.add_effect("minecraft:levitation", 200, 0);
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
let hurt =
m.damage_projectile(4.0, source, aim, [p.velocity.x, p.velocity.z]);
if hurt {
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
hurt
});
if hurt {
// The hurt's teleport (`teleportSomewhere`,
// `hitByShulkerBullet`) comes before the levitation.
self.settle_hurt_teleports()?;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.add_effect("minecraft:levitation", 200, 0);
}
}
}
},
@@ -6217,7 +6230,7 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if let Some(sheared) = self.shear_pig_saddle(index, &item) {
if let Some(sheared) = self.shear_saddle(index, &item) {
if sheared {
self.swing_arm();
}
@@ -7111,6 +7124,8 @@ impl Game<'_> {
on_ground,
);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
self.award_stat("used", item, 1);
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
// second is the `use_cooldown` component, as for any thrown item.
self.apply_use_cooldown(stack);
@@ -534,6 +534,23 @@ impl Game<'_> {
}
}
/// `LivingEntity.dismountVehicle` from a live vehicle at `vehicle` whose
/// block (`blockPosition`) is in `#minecraft:portals`: the rider leaves by
/// the same free-position branch as from a removed vehicle, not beside it.
/// True when it did.
pub(super) fn dismount_in_portal(&mut self, vehicle: Vec3) -> bool {
let pos = [
vehicle.x.floor() as i32,
vehicle.y.floor() as i32,
vehicle.z.floor() as i32,
];
if !self.block_in_tag("minecraft:portals", &self.block_name(pos)) {
return false;
}
self.dismount_removed_vehicle(vehicle.y);
true
}
/// `Entity.positionRider` for the mobs (and other players) sitting on a
/// cushion: its passenger attachment `(0, 0.25, 0)`, less the rider's own
/// vehicle attachment. A server sends no position for a passenger, so
@@ -1063,6 +1063,7 @@ impl Game<'_> {
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
self.prepare_levels()?;
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, however it ends. The management
@@ -859,6 +859,10 @@ impl Game<'_> {
self.world.block_entities.insert(
p,
BlockEntity::Jukebox(horizon_client::containers::Jukebox {
song_clock_present: stack
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
.is_some_and(|i| i.jukebox_song.is_some()),
items: [stack],
ticks: 0,
playing: false,
+2
View File
@@ -3828,6 +3828,8 @@ impl Game<'_> {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
sync_position: false,
stepped: true,
idle: false,
bare: false,
leash: None,
+59 -21
View File
@@ -12,19 +12,24 @@ use horizon_world::{
impl Game<'_> {
/// `Enderman.hurtServer` teleports inside the hurt (`teleport()`,
/// `repeatedlyTryToTeleport()`). `Mob::damage` has no world, so it leaves
/// the tries in `counter2`; a hurt the game layer deals runs them here,
/// right after it, not on the enderman's next AI step.
/// `repeatedlyTryToTeleport()`), and so does `Shulker.hurtServer`
/// (`teleportSomewhere()`, `hitByShulkerBullet()`). `Mob::damage` has no
/// world, so it leaves them pending (an enderman's tries in `counter2`, a
/// shulker's flags); a hurt the game layer deals runs them here, right
/// after it, not on the mob's next AI step.
pub(super) fn settle_hurt_teleports(&mut self) -> Result<()> {
let pending: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Enderman && m.extra.counter2 > 0)
.filter(|m| {
(m.species() == Species::Enderman && m.extra.counter2 > 0)
|| horizon_client::mobs::shulker::teleport_pending(m)
})
.map(|m| m.id)
.collect();
for id in pending {
// What `LivingEntity.hurtServer` raised comes first, where the
// enderman was hurt and from its random's next draws: the hurt
// mob was hurt and from its random's next draws: the hurt
// sound, `InfestedMobEffect.onMobHurt`'s silverfish.
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
@@ -33,21 +38,46 @@ impl Game<'_> {
if !hurt.is_empty() {
self.handle_mob_events(hurt)?;
}
// The world is read while the enderman moves: take it out, then
// put it back where it was (the mob order stays).
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let shulker = self.mobs[i].species() == Species::Shulker;
// A shulker's teleport reads the mobs around it (`noCollision`,
// the crowding count of `hitByShulkerBullet`), itself included as
// in its own tick; an enderman's reads only blocks.
let uuid = self.level.player_uuid;
let others: Vec<_> = if shulker {
self.mobs
.iter()
.map(|m| super::creatures::seen(m, uuid))
.collect()
} else {
Vec::new()
};
// The world is read while the mob moves: take it out, then put it
// back where it was (the mob order stays).
let mut mob = self.mobs.swap_remove(i);
let mut events = Vec::new();
// `hitByShulkerBullet` draws its crowding check from the level's random.
let mut level_random = self.world.random.clone();
{
let world = self.mob_world_base(&[]);
horizon_client::mobs::enderman::run_pending_teleports(
&mut mob,
&world,
&mut events,
);
let world = self.mob_world_base(&others);
if shulker {
horizon_client::mobs::shulker::run_pending_teleports(
&mut mob,
&world,
&mut level_random,
&mut events,
);
} else {
horizon_client::mobs::enderman::run_pending_teleports(
&mut mob,
&world,
&mut events,
);
}
}
self.world.random = level_random;
self.mobs.push(mob);
let last = self.mobs.len() - 1;
self.mobs.swap(i, last);
@@ -55,7 +85,8 @@ impl Game<'_> {
}
Ok(())
}
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, post
/// BLOCK_PLACE with the enderman and the placed state, then clear.
pub(super) fn enderman_place_block(
&mut self,
mob: u32,
@@ -95,18 +126,25 @@ impl Game<'_> {
|| !support.full_collision
|| occupants.iter().any(|bounds| bounds.intersects(area))
{
return false;
return None;
}
// AIR is a legitimate normalized state. setBlock may report no
// change for it; the eligible operation still empties the hand.
b.set_block(level, ticks, at, state, flags::UPDATE_ALL);
true
Some(state)
});
if placed
&& let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == mob && m.species() == Species::Enderman && m.extra.carried == Some(carried)
})
{
let Some(state) = placed else {
return self.process_level_events();
};
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(horizon_client::mobs::Who::Mob(mob)),
Some(state),
);
if let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == mob && m.species() == Species::Enderman && m.extra.carried == Some(carried)
}) {
m.extra.carried = None;
}
self.process_level_events()
@@ -169,3 +169,119 @@ fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
assert!(p.distance(at) < 3.0, "silverfish at {p:?}, hurt at {at:?}");
}
}
fn shulker(game: &Game, id: u32, at: Vec3, seed: i64) -> Mob {
let kind = game
.res
.mob_types()
.get("minecraft:shulker")
.unwrap()
.clone();
let mut mob = Mob::new(id, kind, at, 0.0);
mob.random = LegacyRandom::new(seed);
mob.persistent = true;
mob
}
/// `ShulkerBullet.onHitEntity` -> `Shulker.hurtServer`: an open shulker a
/// bullet hurts runs `hitByShulkerBullet` (its teleport) inside the hurt, so
/// it has moved when the hit returns, and the levitation still lands on it.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_shulker_bullets_hit_teleports_an_open_shulker_before_it_returns() {
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let at = Vec3::new(8.5, 4.0, 8.5);
let mut moved = 0;
// On a flat floor one try in 17 finds a block to cling to: some seeds teleport.
for seed in 0..40 {
let id = 700 + seed as u32;
let mut mob = shulker(&game, id, at, seed);
// Open: hitByShulkerBullet is for an open shell only.
mob.extra.counter = 100;
game.mobs.push(mob);
let bullet = Projectile::new(
ProjectileKind::ShulkerBullet {
target: None,
dir: None,
steps: 0,
delta: [0.0, 0.0, 0.0],
seed: 0,
},
Vec3::new(at.x, at.y + 0.5, at.z - 1.0),
None,
);
game.projectile_hit(
&bullet,
Hit::Entity {
who: horizon_client::mobs::Who::Mob(id),
at: Vec3::new(at.x, at.y + 0.5, at.z),
},
)
.unwrap();
let m = game.mobs.iter().find(|m| m.id == id).unwrap();
assert!(
!m.extra.flag && !m.extra.flag2,
"seed {seed}: a teleport left for the AI step"
);
assert!(
m.has_effect("minecraft:levitation"),
"seed {seed}: no levitation"
);
if m.position.distance(at) > 0.5 {
moved += 1;
}
game.mobs.clear();
}
assert!(moved > 0, "no shulker teleported");
}
/// `Shulker.teleportSomewhere` -> `Entity.unRide`: a mob riding the shulker
/// gets off where the shulker stood (`ejectPassengers`) instead of being
/// carried to where it teleports.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_teleporting_shulker_leaves_its_rider_behind() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let at = Vec3::new(8.5, 4.0, 8.5);
let mut teleported = 0;
for seed in 0..40 {
let (id, rider) = (800 + 2 * seed as u32, 801 + 2 * seed as u32);
let mut mob = shulker(&game, id, at, seed);
// What `after_hurt` leaves after a low-health roll.
mob.extra.flag = true;
game.mobs.push(mob);
let mut zombie = enderman(&game, rider, at, 0);
zombie.kind = game
.res
.mob_types()
.get("minecraft:zombie")
.unwrap()
.clone();
game.mobs.push(zombie);
horizon_client::mobs::riders::link(&mut game.mobs, rider, id);
game.settle_hurt_teleports().unwrap();
let s = game.mobs.iter().find(|m| m.id == id).unwrap();
let z = game.mobs.iter().find(|m| m.id == rider).unwrap();
if s.position.distance(at) > 0.5 {
teleported += 1;
assert_eq!(z.extra.mount, None, "seed {seed}: still riding");
assert_eq!(s.extra.rider, None, "seed {seed}: still carrying");
assert!(
z.position.distance(at) < 2.0,
"seed {seed}: rider at {:?}, shulker left {at:?}",
z.position
);
} else {
assert_eq!(
z.extra.mount,
Some(id),
"seed {seed}: thrown off without a teleport"
);
}
game.mobs.clear();
}
assert!(teleported > 0, "no shulker teleported");
}
+2 -5
View File
@@ -1252,11 +1252,8 @@ impl Entity for ExperienceOrb {
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
if movement.y != 0.0 {
self.on_ground = movement.y < 0.0;
}
fn set_piston_on_ground(&mut self, on_ground: bool) {
self.on_ground = on_ground;
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
+4
View File
@@ -85,6 +85,10 @@ impl Game<'_> {
pub(super) fn server_address(&self) -> Option<&str> {
self.extras.reconnect.address()
}
/// The server this game connected to last was found on the local network.
pub(super) fn server_is_lan(&self) -> bool {
self.extras.reconnect.lan()
}
/// Auto-reconnect: the server let the player in.
pub(super) fn extras_joined(&mut self) {
self.extras.reconnect.joined(std::time::Instant::now());
+18
View File
@@ -307,6 +307,10 @@ impl Game<'_> {
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
// ItemFrame.interact: BLOCK_CHANGE by the player, after setItem or
// setRotation.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:block_change", at, ctx);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
let item = frame
@@ -369,6 +373,10 @@ impl Game<'_> {
self.entity_killed_event(at, true);
}
self.drop_frame_items(&f, with_frame, true);
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:block_change", at, ctx);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
@@ -478,6 +486,9 @@ impl Game<'_> {
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
@@ -506,6 +517,9 @@ impl Game<'_> {
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
@@ -550,6 +564,7 @@ impl Game<'_> {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(f.id));
let s = f.step();
// Pose: to the block centre, then X/Y rotation by facing.
let (x_rot, y_rot) = if s[1] == 0 {
@@ -688,6 +703,7 @@ impl Game<'_> {
for (stack, id, m, light, cull, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
self.mark_motion(entity_start, Self::MOTION_FRAME_ITEM | u64::from(id));
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
let state = self.resolve_item(
&stack,
@@ -707,6 +723,8 @@ impl Game<'_> {
self.cull_skinned(out, entity_start, skinned);
}
}
// Maps: keyed by their map id, which several frames can show, so they stay unmarked.
self.mark_motion(out.len(), 0);
// Nearest first: past the atlas's map slots, the far ones go without.
maps.sort_by(|a, b| {
let d = |m: &glam::Mat4| {
+22 -6
View File
@@ -108,8 +108,10 @@ impl Game<'_> {
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
/// for the main-hand stack: some predicate of its `can_break` or
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
/// or a list) matches the block at `pos`. Predicates with `nbt` or
/// `components` need the block entity's data and never match.
/// or a list) matches the block at `pos`, as `BlockPredicate.matches(BlockInWorld)`
/// tests it: the state, then `nbt` against the block entity's
/// `saveWithFullMetadata` (no block entity fails). `components` is not
/// looked at on this path.
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
use horizon_world::nbt::Tag;
let Some(tag) = self
@@ -128,9 +130,6 @@ impl Game<'_> {
.state(state)
.map(|s| &s.properties);
let matches = |p: &Tag| {
if p.get("nbt").is_some() || p.get("components").is_some() {
return false;
}
let block_ok = match p.get("blocks") {
None => true,
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
@@ -169,7 +168,24 @@ impl Game<'_> {
}),
Some(_) => false,
};
block_ok && state_ok
// NbtPredicate (TagParser.LENIENT_CODEC: SNBT or a compound),
// NbtUtils.compareNbt(pattern, data, true).
let nbt_ok = || match p.get("nbt") {
None => true,
Some(Tag::String(snbt)) => horizon_world::nbt::parse_snbt(&snbt.to_string_lossy())
.is_ok_and(|(pattern, _)| {
horizon_client::nbt_path::compare_nbt(
&pattern,
self.block_entity_nbt(pos).as_ref(),
)
}),
Some(pattern @ Tag::Compound(_)) => horizon_client::nbt_path::compare_nbt(
pattern,
self.block_entity_nbt(pos).as_ref(),
),
Some(_) => false,
};
block_ok && state_ok && nbt_ok()
};
match tag {
Tag::List(list) => list.iter().any(matches),
+3 -2
View File
@@ -870,10 +870,11 @@ impl Game<'_> {
pending: None,
load: LoadPhase::default(),
});
// MinecraftServer.prepareLevels: no forced chunks to load, so the
// stage is over at once; the player's chunks come after.
// MinecraftServer.prepareLevels: the saved tickets' chunks load; the
// player's chunks come after.
let view = &flags.load;
view.start(Stage::LoadInitialChunks, 0);
startup::time("server.prepare_levels", || self.prepare_levels())?;
view.finish(Stage::LoadInitialChunks);
let _ = ready.send(Ok((pipe, self.level.name.clone())));
startup::time("server.settle_spawn", || self.settle_spawn())
@@ -99,6 +99,7 @@ impl Game<'_> {
pub(super) fn inventory_press(&mut self, right: bool) {
use horizon_client::container_screen::Target;
use horizon_client::menu::Input;
self.direct_craft_stop();
if self.mod_browser_press(right) {
return;
}
+89
View File
@@ -82,6 +82,7 @@ impl Game<'_> {
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
j.playing = true;
j.song_clock_present = true;
j.ticks = 0;
}
self.set_has_record(pos, true);
@@ -99,6 +100,7 @@ impl Game<'_> {
return;
}
j.playing = false;
j.song_clock_present = false;
j.ticks = 0;
}
self.stop_song(pos);
@@ -119,6 +121,7 @@ impl Game<'_> {
let was_playing = j.playing;
j.items[0] = stack;
j.playing = song.is_some();
j.song_clock_present = song.is_some();
j.ticks = 0;
self.set_has_record(pos, has_record);
match song {
@@ -173,6 +176,7 @@ impl Game<'_> {
items: [Some(record)],
ticks: 0,
playing: true,
song_clock_present: true,
}),
);
self.set_has_record(pos, true);
@@ -274,6 +278,7 @@ impl Game<'_> {
let record = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Jukebox(j)) => {
j.playing = false;
j.song_clock_present = false;
j.ticks = 0;
j.items[0].take()
}
@@ -315,6 +320,7 @@ impl Game<'_> {
if s.finished(ticks) {
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.playing = false;
j.song_clock_present = false;
j.ticks = 0;
}
self.stop_song(pos);
@@ -533,3 +539,86 @@ mod jukebox_play_tests {
assert!(lines.is_empty(), "{lines:?}");
}
}
#[cfg(test)]
mod saved_song_clock_tests {
use crate::game::native_machines::tests::server_game;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_start_tick_stop_and_loaded_record_use_the_modeled_clock() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("forceload add 0 0 15 15"));
assert!(game.world.loaded(4, 4));
assert!(game.run_command("setblock 4 199 4 stone"));
assert!(game.run_command("setblock 4 200 4 jukebox"));
let clock = game.world.game_time;
let mut failures = Vec::new();
let mut check = |label: &str, tag: &horizon_world::nbt::Tag, want: Option<i64>| {
let got = tag.get("ticks_since_song_started").cloned();
let want = want.map(horizon_world::nbt::Tag::Long);
if got != want {
failures.push(format!("{label}: {got:?}, expected {want:?}"));
}
};
let pos = [4, 200, 4];
check(
"actual empty Game writer",
&game.block_entity_nbt(pos).unwrap(),
None,
);
assert!(game.run_command("item replace block 4 200 4 container.0 with music_disc_cat[minecraft:custom_data={h032:1}]"));
let j = game.jukebox(pos).unwrap();
assert!(j.playing);
assert_eq!(j.ticks, 0);
assert_eq!(j.record().unwrap().count, 1);
check(
"actual start keeps zero Long",
&game.block_entity_nbt(pos).unwrap(),
Some(0),
);
game.tick_jukebox((4, 200, 4));
assert_eq!(game.jukebox(pos).unwrap().ticks, 1);
check(
"actual tick keeps sole counter",
&game.block_entity_nbt(pos).unwrap(),
Some(1),
);
let before = game
.block_entity_nbt(pos)
.unwrap()
.get("RecordItem")
.cloned();
assert!(before.is_some());
game.jukebox_set_item(pos, None);
let j = game.jukebox(pos).unwrap();
assert!(!j.playing);
assert_eq!(j.ticks, 0);
assert!(j.record().is_none());
let saved = game.block_entity_nbt(pos).unwrap();
assert!(saved.get("RecordItem").is_none());
check("actual stop removes clock", &saved, None);
assert!(game.run_command("setblock 8 199 4 stone"));
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
let j = game.jukebox([8, 200, 4]).unwrap();
assert!(j.playing, "preserve legacy record-only adoption");
assert_eq!(j.ticks, 0);
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
assert_eq!(
saved.get("RecordItem"),
before.as_ref(),
"actual record/components preserved"
);
check("actual loaded record without clock", &saved, None);
assert_eq!(
game.world.game_time, clock,
"no scene ticks in this caller test; registered runtime progress is separately paired"
);
assert!(
failures.is_empty(),
"actual Game saved song-clock mismatches: {failures:?}"
);
}
}
+26
View File
@@ -1971,6 +1971,8 @@ impl Game<'_> {
e.on_ground = place.on_ground;
e.velocity = place.velocity;
e.needs_sync = m.needs_sync;
e.sync_position = m.sync_position;
e.stepped = true;
(e.idle, e.bare) = (idle, bare);
e.leash = leash;
if bare {
@@ -2118,6 +2120,19 @@ impl Game<'_> {
continue;
}
}
// PlayerList.placeNewPlayer: the name the user cache knew,
// before it learns this one, for a renamed player's join line.
let mut renamed = None;
if self.options.dedicated.is_some()
&& let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
&& let Some(old) = super::dedicated::cached_user_by_id(uuid)
{
if !old.name.eq_ignore_ascii_case(&name) {
renamed = Some(old.name.clone());
}
host.hub.former_names.insert(uuid, old.name);
}
self.lan_guest_joined(&mut host, conn, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
@@ -2149,6 +2164,15 @@ impl Game<'_> {
"INFO",
&horizon_client::lan::arrivals::held_line(&g.name),
);
} else if let Some(old) = renamed {
let line = Text::translate(
"multiplayer.player.joined.renamed",
&[Text::literal(g.name.clone()), Text::literal(old)],
);
super::dedicated::log(
"INFO",
&format!("System chat: {}", line.plain()),
);
} else {
super::dedicated::log(
"INFO",
@@ -2663,8 +2687,10 @@ impl Game<'_> {
}
}
// ServerEntity.sendChanges clears needsSync once it has looked.
// SteppedInterpolationTracker.updateTracking clears syncPosition.
for m in &mut self.mobs {
m.needs_sync = false;
m.sync_position = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
@@ -51,6 +51,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
sync_position: false,
stepped: false,
idle: false,
bare: false,
leash: None,
+30 -4
View File
@@ -649,7 +649,10 @@ impl Game<'_> {
{
let _ = p.ignite_for_ticks((sweep.ignite * 20.0) as i32);
}
// The swept player's thorns, dealt to the attacker below.
return g.thorns_damage();
}
Vec::new()
};
for (victim, id, team) in victims {
if sweep.skip == Some(id)
@@ -660,10 +663,33 @@ impl Game<'_> {
}
let hit = self.pvp_hit_of(host, sweep.attacker, victim, "sweep");
let Victim::Guest(conn) = victim;
self.guest_struck_by(host, conn, |g| {
let _hit = pvp_scope(hit);
land(g)
});
let thorns = self
.guest_struck_by(host, conn, |g| {
let _hit = pvp_scope(hit);
land(g)
})
.unwrap_or_default();
// doPostAttackEffects' victim side: its armour's thorns hurt the
// attacker, as on a direct hit (pvp_land).
let thorns_cause = self.player_attacker(host, victim.conn());
for damage in thorns {
match sweep.attacker {
None => {
if let Some((id, name)) = &thorns_cause {
self.note_named_attacker("minecraft:thorns", *id, name.clone());
}
self.hurt_player_by("minecraft:thorns", damage);
}
Some(conn) => {
self.guest_hit_by(host, conn, |g| {
if let Some((id, name)) = &thorns_cause {
g.note_named_attacker("minecraft:thorns", *id, name.clone());
}
g.hurt_player_by("minecraft:thorns", damage)
});
}
}
}
}
}
}
+33
View File
@@ -119,6 +119,39 @@ fn write_mob_leash_tag(
}
impl Game<'_> {
/// BlockAttachedEntity.tick / LeashFenceKnotEntity.survives for explicitly loaded knots.
pub(super) fn tick_explicit_knots(&mut self) {
let ticking = self.entity_ticking_area();
let world = &self.world;
let facts = self.res.facts();
let fences = self.res.items().block_tags.get("minecraft:fences");
let mut untied = Vec::new();
self.others.retain_mut(|knot| {
let Some(check) = knot.knot_check.as_mut() else {
return true;
};
let at = horizon_core::position::BlockPos::containing(knot.position);
if !ticking(at.x, at.z) || !world.chunks.contains_key(&(at.x >> 4, at.z >> 4)) {
return true;
}
// Vanilla compares the old postincrement value: first check is tick 101.
if *check >= 100 {
*check = 0;
let state = facts.get(world.block(at.x, at.y, at.z));
if !fences.is_some_and(|f| f.contains(&*state.name)) {
untied.push(knot.position.components());
return false;
}
} else {
*check += 1;
}
true
});
// LeashFenceKnotEntity.dropItem plays LEAD_UNTIED; it drops no lead item.
for at in untied {
self.lead_sound("untied", at);
}
}
pub(super) fn lead_sound(&mut self, sound: &str, at: [f64; 3]) {
self.audio
.play(&format!("minecraft:item.lead.{sound}"), Some(at), 1.0, 1.0);
+59
View File
@@ -298,6 +298,65 @@ impl Game<'_> {
})
}
/// `MinecraftServer.prepareLevels`: before the first tick, the chunks the
/// open levels' restored tickets hold (forced chunks, a native mod's) are
/// loaded (`ChunkLoadCounter`). The first tick then finds them ticking
/// and unpacks their saved block and fluid ticks at the game time they
/// were saved at (`startTickingChunk`), not at whichever tick they finish
/// streaming in.
pub(super) fn prepare_levels(&mut self) -> Result<()> {
if self.options.execution_role.is_client() {
return Ok(());
}
let deadline = Instant::now() + Duration::from_secs(600);
self.wait_for_ticket_chunks(deadline);
let back = self.dim;
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
if dim != back && self.levels.parked.contains_key(&dim) {
self.enter_level(None, dim)?;
self.wait_for_ticket_chunks(deadline);
}
}
if self.dim != back {
self.enter_level(None, back)?;
}
Ok(())
}
/// Stream the level in place until each chunk its tickets make block
/// ticking is a ticking chunk (lit, with its neighbours loaded).
fn wait_for_ticket_chunks(&mut self, deadline: Instant) {
// A `PORTAL` ticket reaches block ticking two chunks out, a forced one one.
const REACH: i32 = 2;
loop {
self.update_chunk_tickets();
let levels = self.tickets.levels();
let streaming = &self.streaming;
let ready = streaming
.forced
.iter()
.chain(&streaming.mod_forced)
.copied()
.chain(self.tickets.portal_chunks())
.all(|(cx, cz)| {
(cz - REACH..=cz + REACH).all(|z| {
(cx - REACH..=cx + REACH).all(|x| {
!levels.loaded_ticking(x, z) || super::ticking_chunk(&self.world, x, z)
})
})
});
if ready {
return;
}
if Instant::now() >= deadline {
log::warn!("Horizon: the chunks of the saved tickets did not load");
return;
}
self.update_world();
self.streaming.wait(Duration::from_millis(5));
}
}
/// Exchange the level in place with `p`.
fn swap_level(&mut self, p: &mut Parked) {
use std::mem::swap;
@@ -89,7 +89,9 @@ pub(super) fn build_renderer(
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
renderer.set_shared_material_maps(&atlas.material_maps);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
.hud
@@ -1997,6 +1997,9 @@ impl Game<'_> {
return;
}
self.riding = None;
if self.dismount_in_portal(self.carts[i].position) {
return;
}
let c = &self.carts[i];
let (bx, by, bz) = (
c.position.x.floor() as i32,
@@ -2688,6 +2691,7 @@ impl Game<'_> {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(c.id));
// The per-entity jitter (offsetSeed) that keeps stacked carts from z-fighting.
let seed = i64::from(c.id).wrapping_mul(493_286_711);
let seed = seed
+87 -6
View File
@@ -138,12 +138,28 @@ impl Game<'_> {
let id = self.riding?;
self.mobs.iter().position(|m| m.id == id)
}
/// `Entity.interact` / `Mob.canShearEquipment` / `Mob.shearItem`:
/// the pig's ordinary saddle comes off before Pig.mobInteract can ride it.
/// An occupied pig consumes no equipment interaction and cannot be mounted.
pub(super) fn shear_pig_saddle(&mut self, index: usize, item: &str) -> Option<bool> {
/// `Entity.interact` / `Mob.canShearEquipment` / `Mob.attemptToShearEquipment`
/// / `Mob.shearItem`: the saddle of a `#can_equip_saddle` mob comes off
/// before its mobInteract can ride it, with the saddle's shearing sound.
/// An occupied mount is not sheared: its mobInteract runs instead.
pub(super) fn shear_saddle(&mut self, index: usize, item: &str) -> Option<bool> {
let m = &self.mobs[index];
if item != "minecraft:shears" || m.species() != Species::Pig || m.is_baby() || !m.alive() {
// EntityTypeTags.CAN_EQUIP_SADDLE.
let saddleable = matches!(
m.species(),
Species::Horse
| Species::SkeletonHorse
| Species::ZombieHorse
| Species::Donkey
| Species::Mule
| Species::Pig
| Species::Strider
| Species::Camel
| Species::CamelHusk
| Species::Nautilus
| Species::ZombieNautilus
);
if item != "minecraft:shears" || !saddleable || m.is_baby() || !m.alive() {
return None;
}
let id = m.id;
@@ -155,15 +171,28 @@ impl Game<'_> {
.iter()
.any(|r| r.alive() && !r.removed && r.extra.mount == Some(id))
{
return Some(false);
// !canShearEquipment (isVehicle): the click goes on to mobInteract. A
// ridden pig or strider has nothing more to give, but a camel takes a
// second rider and a horse or nautilus opens its inventory.
return matches!(m.species(), Species::Pig | Species::Strider).then_some(false);
}
if self.keys.held(Action::Sneak) {
return None;
}
// attemptToShearEquipment takes the body slot before the saddle: horse
// and nautilus armor come off first (not modelled here).
if m.extra
.body
.as_deref()
.is_some_and(|b| b.ends_with("_horse_armor") || b.ends_with("_nautilus_armor"))
{
return None;
}
let saddle = m.saddle_item()?;
if &*saddle.item != "minecraft:saddle" {
return None;
}
let silent = m.silent;
let at = m.position;
let offset = horizon_client::mobs::riders::passenger_attachment(m)[1];
self.damage_held_tool();
@@ -174,6 +203,15 @@ impl Game<'_> {
self.spawn_at_location(at, offset, saddle.clone());
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_sheared_equipment", &saddle, &target);
// attemptToShearEquipment: playSound(equippable.shearingSound()).
if !silent {
self.audio.play(
"minecraft:item.saddle.unequip",
Some([at.x, at.y, at.z]),
1.0,
1.0,
);
}
Some(true)
}
/// `Pig` / `Strider.mobInteract`: saddle an adult, or ride a saddled one.
@@ -668,6 +706,9 @@ impl Game<'_> {
height: m.height(),
yaw: m.yaw,
};
if self.dismount_in_portal(mount.position) {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
@@ -759,6 +800,46 @@ impl Game<'_> {
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
self.mob_event_particles(mob, 6);
}
// `Entity.ejectPassengers` before the mob moved off `from`: each
// rider's `dismountVehicle` reads the vehicle where it stood.
MobEvent::EjectPassengers { mob, from } => {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let moved = (self.mobs[i].position, self.mobs[i].previous);
self.mobs[i].position = from;
self.mobs[i].previous = from;
if self.riding == Some(mob) {
self.stop_riding();
}
let guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.vehicle == Some(mob))
.map(|g| g.conn)
.collect()
});
for conn in guests {
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| g.stop_riding());
self.lan = Some(host);
}
}
let riders: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.extra.mount == Some(mob))
.map(|m| m.id)
.collect();
for rider in riders {
self.dismount_mob(rider);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
(m.position, m.previous) = moved;
m.extra.rider = None;
}
}
// `LivingEntity.stabAttack`: `target.stopRiding()`.
MobEvent::StabDismount { target } => match target {
Who::Player => self.stop_riding(),
@@ -32,6 +32,9 @@ impl Game<'_> {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
// Its own motion run, by the cell it moves to (fixed while it moves), for a shader
// pack's `at_velocity`; a finished one keeps that key until the chunk mesh takes over.
self.mark_motion(out.len(), super::motion_at(Self::MOTION_MOVING_BLOCK, p));
let step = piston.movement_direction().normal();
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
// `createMovingBlock`'s block position (seed, tint and light): the
@@ -54,6 +57,7 @@ impl Game<'_> {
self.moving_block_vertices(base, at, corner - camera, out);
}
}
self.mark_motion(out.len(), 0);
}
/// The section holding the block at `p` (`SectionPos.blockToSection`).
pub(super) fn section_of(&self, p: [i32; 3]) -> Option<(i32, i32, i32)> {
+8 -2
View File
@@ -44,7 +44,7 @@ pub(super) struct Pads {
virtual_input: bool,
/// Buttons a `pad ... tap` releases at the next poll.
taps: Vec<Button>,
edges: Edges,
pub(super) edges: Edges,
activity: gamepad::Activity,
context: Option<Context>,
blocked_sticks: [bool; 2],
@@ -215,6 +215,7 @@ impl Pads {
if self.feed_source != source {
self.feed_source = source;
self.menu.armed = None;
self.menu.stop_craft();
}
self.feed = Some(state);
}
@@ -231,6 +232,7 @@ impl Pads {
/// A replay ended: let go of everything it held.
pub(super) fn clear_injected(&mut self) {
self.menu.armed = None;
self.menu.stop_craft();
self.injected = PadState::default();
}
@@ -397,8 +399,11 @@ impl Game<'_> {
/// End held actions and captures before another context receives input.
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
// Before the releases below: a cancelled press must not click.
// Before the releases below: a cancelled press must not click, nor a cancelled trigger
// change the recipe book's category as it comes up.
self.pad.menu.armed = None;
self.pad.menu.trigger = None;
self.direct_craft_stop();
let held_sticks = [
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
!= [0.0; 2],
@@ -798,6 +803,7 @@ impl Game<'_> {
}
"release" => {
self.pad.menu.armed = None;
self.direct_craft_stop();
self.pad.injected = PadState::default();
self.pad.taps.clear();
Ok(json!({ "ok": true }))
+1 -1
View File
@@ -122,7 +122,7 @@ impl Game<'_> {
Under::Nothing
} else if self.recipe_under_pointer().is_some() {
Under::Recipe {
direct_craft: false,
direct_craft: self.direct_craft_usable(),
alternatives: self.recipe_has_alternatives_under_pointer(),
}
} else if self.recipe_book_under_pointer() {
@@ -264,6 +264,7 @@ impl Pads {
// Another pad, or none: its A going away isn't a release.
if self.devices.active != active {
self.menu.armed = None;
self.menu.stop_craft();
}
let Some(pad) = self
.devices
+912
View File
@@ -0,0 +1,912 @@
//! Crafting a recipe straight into the inventory with a controller (Horizon's own;
//! docs/controllers.md): LT + A crafts once, and with both still held past the repeat delay it
//! goes on until either is let go, the items run out or the inventory is full.
//!
//! Each craft is what a player does by hand with the recipe book, one request at a time: the
//! recipe placed (`ServerboundPlaceRecipePacket`), its result quick-moved
//! (`ServerboundContainerClickPacket`), and whatever stays in the grid, remainders included,
//! quick-moved back. The next request waits for the server's answer to the last one, nothing is
//! predicted, so the items are always the server's.
//!
//! The book's numbers are worked out here, and a server with recipes of its own can number them
//! differently, so a placement may make something else. A result that isn't the recipe's stops the
//! craft with nothing moved: the grid and the result stay as the click on the recipe left them,
//! for the player to take or put back by hand.
//!
//! No answer in time ends the craft at once and nothing more goes out: a quick move can't return
//! what a late answer has yet to place, and a server that isn't answering would leave it waiting
//! for ever. So what the server placed stays in the grid. The player sees it there, and the way
//! out is what it always is: closing the screen returns the grid, and the next LT + A returns it
//! before it places anything.
//!
//! Vanilla's quick move throws what doesn't fit on the ground (`CraftingMenu.quickMoveStack`'s
//! `player.drop`), so a result only moves with room for all of it as the client last heard the
//! inventory. A full inventory stops the craft with the ingredients back where they came from, or
//! in the grid if they can't go back. The server's inventory can be ahead of that: a pickup the
//! client hasn't heard of yet can fill the last room, and then the server's quick move drops the
//! rest of the result, as a vanilla shift-click on the result does.
//!
//! Requests to any server but the window's own (single player) keep their distance: placements
//! `PLACE_GAP` apart, under Paper's limit on them, and on someone else's server every request
//! `REQUEST_GAP` apart as well.
//!
//! However a craft ends while the server can still answer (a button let go, another press but
//! LT + A on its recipe, which asks for one more, the pad gone, focus lost), it ends one way: no new craft starts, the step in flight finishes, and
//! what's in the grid goes back ([`DirectCraft::stop`]).
use super::Game;
use horizon_client::{gamepad, items::ItemStack};
use std::{
sync::Arc,
time::{Duration, Instant},
};
/// How long a request waits for the server's answer before the craft ends.
const ANSWER_WAIT: Duration = Duration::from_secs(2);
/// The gap between any two requests to someone else's server: one a game tick (50 ms) at most,
/// so a held craft never sends faster than the game ticks.
const REQUEST_GAP: Duration = Duration::from_millis(50);
/// The gap between two placements on any server but the window's own. Paper's default
/// `packet-limiter` drops a `ServerboundPlaceRecipePacket` beyond 5 a second averaged over 4
/// seconds (`GlobalConfiguration.PacketLimiter`'s override for it: interval 4.0, max-packet-rate
/// 5.0, action DROP); a dropped one would go unanswered and end the craft. One every 250 ms is 4 a
/// second, under it.
const PLACE_GAP: Duration = Duration::from_millis(250);
/// The most crafts one craft makes: a full inventory of stacks, one item a craft.
const MOST_CRAFTS: u32 = 36 * 64;
/// What the last request is waiting to see.
#[derive(Clone, Debug, PartialEq)]
enum Answer {
/// The recipe placed: a result shows.
Placed,
/// The result moved: the slot is empty and the inventory holds this many of the item.
Moved { item: Arc<str>, total: u32 },
/// This many items were in this grid cell: fewer are now.
Returned { cell: usize, count: u32 },
/// The grid is empty: the old result goes.
NoResult,
}
/// The next thing to do.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Step {
Wait,
Stop,
Place,
MoveResult,
Return(usize),
}
/// The open menu as the client last heard it from the server.
struct Seen<'a> {
grid: &'a [Option<ItemStack>],
result: Option<&'a ItemStack>,
/// What the recipe makes (`Entry::results`).
recipe_results: &'a [ItemStack],
inventory: &'a [Option<ItemStack>],
craftable: bool,
max_stack: &'a dyn Fn(&ItemStack) -> u32,
}
impl Seen<'_> {
/// `result` is what the recipe makes. Item and count only: a recipe can pass its input's
/// components on to its result (`TransmuteRecipe`), so those can differ from the book's.
fn makes(&self, result: &ItemStack) -> bool {
self.recipe_results
.iter()
.any(|made| made.item == result.item && made.count == result.count)
}
fn count_of(&self, item: &str) -> u32 {
self.inventory
.iter()
.flatten()
.filter(|s| &*s.item == item)
.map(|s| s.count)
.sum()
}
/// How many of `stack` fit in the inventory (`moveItemStackTo`).
fn room_for(&self, stack: &ItemStack) -> u32 {
let max = (self.max_stack)(stack);
self.inventory
.iter()
.map(|slot| match slot {
None => max,
Some(s) if s.same_item_same_components(stack) => max.saturating_sub(s.count),
Some(_) => 0,
})
.sum()
}
}
/// A crafting book's recipe by what stays the same when the book is rebuilt: its recipe id, and
/// its display id among the entries that share the id. Its index in the book can move.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RecipeKey {
pub(super) id: String,
pub(super) display: i32,
}
/// A craft under way.
pub(super) struct DirectCraft {
recipe: RecipeKey,
/// The window it started in: another one ends it.
menu: i32,
/// The grid's size it started with: another one ends it.
cells: usize,
/// The crafts the presses asked for, and the crafts made.
asked: u32,
done: u32,
/// Both buttons are still down from the press: past `repeat_at` it keeps crafting.
holding: bool,
repeat_at: Instant,
/// The grid holds the recipe this craft placed, so its result is the craft's to take.
placed: bool,
answer: Option<(Answer, Instant)>,
next_request: Instant,
/// The gap kept after every request: `REQUEST_GAP` on someone else's server, else none.
request_gap: Duration,
next_place: Instant,
/// The gap kept after every placement: `PLACE_GAP` on any server but the window's own.
place_gap: Duration,
}
impl DirectCraft {
fn new(recipe: RecipeKey, menu: i32, cells: usize, repeat_at: Instant, now: Instant) -> Self {
Self {
recipe,
menu,
cells,
asked: 1,
done: 0,
holding: true,
repeat_at,
placed: false,
answer: None,
next_request: now,
request_gap: Duration::ZERO,
next_place: now,
place_gap: Duration::ZERO,
}
}
/// A button of the press came up: what was asked for still gets made, and no more.
fn let_go(&mut self) {
self.holding = false;
}
/// The end of a craft while the server can still answer: no new craft starts, the one in
/// flight finishes, and `step` returns the grid before it says `Stop`.
pub(super) fn stop(&mut self) {
self.holding = false;
let in_flight = self.placed || matches!(self.answer, Some((Answer::Placed, _)));
self.asked = self.done + u32::from(in_flight);
}
/// The server said the placement it was asked for can't be made: nothing's in flight.
fn refused(&mut self) {
if matches!(self.answer, Some((Answer::Placed, _))) {
self.answer = None;
}
self.stop();
}
/// LT + A on this recipe again before the last has finished: one more, on the same footing as
/// the first, and still one request at a time.
fn again(&mut self, repeat_at: Instant) {
self.asked = self.asked.saturating_add(1);
self.holding = true;
self.repeat_at = repeat_at;
}
/// The next step, and the gaps it leaves before the next request and placement.
fn step(&mut self, seen: &Seen, now: Instant) -> Step {
let step = self.next_step(seen, now);
if step != Step::Wait {
self.next_request = now + self.request_gap;
}
if step == Step::Place {
self.next_place = now + self.place_gap;
}
step
}
fn next_step(&mut self, seen: &Seen, now: Instant) -> Step {
if let Some((answer, deadline)) = &self.answer {
let answered = match answer {
Answer::Placed => seen.result.is_some(),
Answer::Moved { item, total } => {
seen.result.is_none() && seen.count_of(item) >= *total
}
Answer::Returned { cell, count } => {
let left = seen.grid.get(*cell).and_then(Option::as_ref);
left.map_or(0, |s| s.count) < *count
}
Answer::NoResult => seen.result.is_none(),
};
if !answered {
return if now >= *deadline {
Step::Stop
} else {
Step::Wait
};
}
match answer {
Answer::Placed => self.placed = true,
Answer::Moved { .. } => {
self.placed = false;
self.done += 1;
}
Answer::Returned { .. } | Answer::NoResult => {}
}
self.answer = None;
}
if now < self.next_request {
return Step::Wait;
}
let deadline = now + ANSWER_WAIT;
if self.placed {
let Some(result) = seen.result else {
return Step::Stop;
};
if !seen.makes(result) {
return Step::Stop;
}
if seen.room_for(result) >= result.count {
let total = seen.count_of(&result.item) + result.count;
let item = result.item.clone();
self.answer = Some((Answer::Moved { item, total }, deadline));
return Step::MoveResult;
}
// No room: what was placed goes back, and that's the end of it.
self.placed = false;
self.stop();
}
// What's in the grid leaves first (a quick move drops nothing), so the place below can't
// send it to `clearGrid`, which a creative player's menu may throw away.
if let Some((cell, stack)) = seen
.grid
.iter()
.enumerate()
.find_map(|(i, s)| s.as_ref().map(|s| (i, s)))
{
if seen.room_for(stack) == 0 {
return Step::Stop;
}
let count = stack.count;
self.answer = Some((Answer::Returned { cell, count }, deadline));
return Step::Return(cell);
}
if seen.result.is_some() {
self.answer = Some((Answer::NoResult, deadline));
return Step::Wait;
}
let asked = self.done < self.asked;
if (!asked && !self.holding) || self.done >= MOST_CRAFTS || !seen.craftable {
return Step::Stop;
}
if (!asked && now < self.repeat_at) || now < self.next_place {
return Step::Wait;
}
self.answer = Some((Answer::Placed, deadline));
Step::Place
}
}
impl Game<'_> {
/// A server direct crafting needs no say of the player's on: one found through LAN discovery
/// (the server list's announced worlds, `server_is_lan`), kept through a reconnect, or a split
/// guest's link by memory to the host's world on this window (`"local"`). One reached by
/// address isn't, even on this computer or the local network: an address can be a proxy, a
/// tunnel or a friend's relay as well.
fn direct_craft_free_server(&self) -> bool {
self.server_is_lan()
|| self
.remote
.as_ref()
.is_some_and(|r| r.session.address == "local")
}
/// Someone else's server: neither single player's own nor a free one
/// (`direct_craft_free_server`). Every request keeps its distance there, and crafting waits for
/// the player's say (`craft_on_servers`).
fn direct_craft_elsewhere(&self) -> bool {
self.integrated.is_none() && !self.direct_craft_free_server()
}
/// Whether direct crafting is allowed where the player is: only through a server, as every
/// world is played (its quick moves are the server's), and on someone else's only if the
/// player allowed it (`craft_on_servers`).
fn direct_craft_allowed(&self) -> bool {
self.remote.is_some()
&& (!self.direct_craft_elsewhere() || self.settings.controller.craft_on_servers)
}
/// The pad can direct-craft here, with nothing carried: the inventory's guide says so on a
/// recipe. Whether the recipe can be made isn't checked, as that counts the inventory.
pub(super) fn direct_craft_usable(&self) -> bool {
self.pad.in_use
&& self.carried.is_none()
&& self.mod_recipe_screen()
&& self.direct_craft_allowed()
}
/// This press is another LT + A on the running craft's recipe: it asks for one more craft
/// rather than ending the craft.
pub(super) fn direct_craft_takes_press(&self, button: gamepad::Button) -> bool {
button == gamepad::Button::South
&& self.pad.edges.is_held(gamepad::Button::LeftTrigger)
&& self.screen == super::Screen::Inventory
&& self.title.is_none()
&& !self.prompt_open()
&& self.pad.menu.craft.as_ref().is_some_and(|craft| {
self.recipe_under_pointer()
.and_then(|entry| self.recipe_stable_key(entry))
.is_some_and(|key| key == craft.recipe)
})
}
/// LT + A on a recipe: whether it started a craft, or asked the running one for another.
/// Refused with an item carried, on a book other than crafting, when the recipe can't be made,
/// where it isn't allowed, or while another recipe's craft is still finishing (one request at
/// a time); a refused press is plain A's.
pub(super) fn direct_craft_start(&mut self, entry: usize) -> bool {
let now = Instant::now();
let repeat_delay = self.settings.controller.repeat_delay;
let repeat_at = now + Duration::from_secs_f32(repeat_delay);
let Some(recipe) = self.recipe_stable_key(entry) else {
return false;
};
if let Some(craft) = &mut self.pad.menu.craft {
if craft.recipe != recipe {
return false;
}
craft.again(repeat_at);
return true;
}
if self.carried.is_some()
|| !self.mod_recipe_screen()
|| !self.direct_craft_allowed()
|| !self.recipe_craftable(entry)
{
return false;
}
let menu = self.remote_menu().0;
let cells = self.craft_size * self.craft_size;
let mut craft = DirectCraft::new(recipe, menu, cells, repeat_at, now);
if self.direct_craft_elsewhere() {
craft.request_gap = REQUEST_GAP;
}
if self.integrated.is_none() {
craft.place_gap = PLACE_GAP;
}
self.pad.menu.craft = Some(craft);
true
}
/// Anything that ends a running craft while the server can still answer; a click or key
/// behind it goes on to the screen.
pub(super) fn direct_craft_stop(&mut self) {
self.pad.menu.stop_craft();
}
/// The server said a placement can't be made.
pub(super) fn direct_craft_refused(&mut self) {
if let Some(craft) = &mut self.pad.menu.craft {
craft.refused();
}
}
/// Once per poll: the running craft's next step, when the last one has been answered.
pub(super) fn direct_craft_poll(&mut self) {
let Some(mut craft) = self.pad.menu.craft.take() else {
return;
};
let cells = self.craft_size * self.craft_size;
// The screen went, or isn't the one the craft's grid was on.
if !self.mod_recipe_screen() || self.remote_menu().0 != craft.menu || cells != craft.cells {
return;
}
// Something came into the hand: a quick move still takes the grid back.
if self.carried.is_some() {
craft.stop();
}
if !self.pad.edges.is_held(gamepad::Button::South)
|| !self.pad.edges.is_held(gamepad::Button::LeftTrigger)
{
craft.let_go();
}
// The book was rebuilt without the recipe: what's in the grid still goes back, and a
// result can't be told for the recipe's, so it stays.
let entry = self.recipe_entry_index(&craft.recipe);
if entry.is_none() {
craft.stop();
}
let craftable = entry.is_some_and(|entry| self.recipe_craftable(entry));
let result = self.slot_view(200);
let now = Instant::now();
let step = {
// Room counted too high would drop part of a result.
let max_stack = |s: &ItemStack| self.stack_max_size(s);
craft.step(
&Seen {
grid: &self.craft_grid[..cells],
result: result.as_ref(),
recipe_results: entry.map_or(&[], |entry| self.recipe_results(entry)),
inventory: &self.inventory[..36],
craftable,
max_stack: &max_stack,
},
now,
)
};
match (step, entry) {
(Step::Wait, _) => {}
(Step::Place, Some(entry)) => self.recipe_place_once(entry),
(Step::Stop, _) | (Step::Place, None) => return,
// Only ever started with a server (`direct_craft_allowed`): its quick moves are the
// server's, unpredicted.
(Step::MoveResult, _) => {
self.remote_quick_move_unpredicted(200);
}
(Step::Return(cell), _) => {
self.remote_quick_move_unpredicted(100 + cell);
}
}
self.pad.menu.craft = Some(craft);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str, count: u32) -> Option<ItemStack> {
Some(ItemStack::new(item, count))
}
/// A server's menu, as `Seen` shows it to the craft.
struct Menu {
grid: Vec<Option<ItemStack>>,
result: Option<ItemStack>,
recipe_results: Vec<ItemStack>,
inventory: Vec<Option<ItemStack>>,
craftable: bool,
}
impl Menu {
fn new(inventory: Vec<Option<ItemStack>>) -> Self {
Self {
grid: vec![None; 4],
result: None,
// Every result the tests' crafts make.
recipe_results: vec![
ItemStack::new("minecraft:oak_planks", 4),
ItemStack::new("minecraft:stick", 4),
ItemStack::new("minecraft:cake", 1),
],
inventory,
craftable: true,
}
}
fn step(&self, craft: &mut DirectCraft, now: Instant) -> Step {
let max_stack = |s: &ItemStack| {
if &*s.item == "minecraft:bucket" {
16
} else {
64
}
};
craft.step(
&Seen {
grid: &self.grid,
result: self.result.as_ref(),
recipe_results: &self.recipe_results,
inventory: &self.inventory,
craftable: self.craftable,
max_stack: &max_stack,
},
now,
)
}
}
/// A press that came up at once: it asks for one craft.
fn tap(now: Instant) -> DirectCraft {
let mut craft = held(now, now);
craft.let_go();
craft
}
/// A press still down, going on from `repeat_at`.
fn held(repeat_at: Instant, now: Instant) -> DirectCraft {
let recipe = RecipeKey {
id: "minecraft:stick".into(),
display: 0,
};
DirectCraft::new(recipe, 0, 4, repeat_at, now)
}
#[test]
fn nothing_goes_out_until_the_server_answers() {
let now = Instant::now();
let mut menu = Menu::new(vec![stack("minecraft:oak_log", 1), None]);
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.inventory[0] = None;
menu.grid[0] = stack("minecraft:oak_log", 1);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.result = stack("minecraft:oak_planks", 4);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
// The result went, but the slots holding it haven't come in yet.
menu.grid[0] = None;
menu.result = None;
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.inventory[0] = stack("minecraft:oak_planks", 4);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn no_answer_ends_the_craft_without_asking_again() {
let now = Instant::now();
let menu = Menu::new(vec![stack("minecraft:oak_log", 1)]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT / 2), Step::Wait);
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT), Step::Stop);
}
#[test]
fn a_craft_that_times_out_with_a_full_grid_sends_nothing_and_the_next_press_clears_it() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
// The ingredients came, the result never did: no return is asked of a silent server.
menu.grid[0] = stack("minecraft:oak_log", 1);
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT), Step::Stop);
// The way out: another LT + A takes the grid back before it places anything.
let later = now + ANSWER_WAIT;
let mut next = tap(later);
assert_eq!(menu.step(&mut next, later), Step::Return(0));
menu.grid[0] = None;
menu.inventory[0] = stack("minecraft:oak_log", 1);
assert_eq!(menu.step(&mut next, later), Step::Place);
}
#[test]
fn a_result_that_is_not_the_recipes_stops_the_craft_with_nothing_moved() {
let now = Instant::now();
// Another item, then the recipe's item in another count: neither is what was asked for.
for other in [
ItemStack::new("minecraft:diamond", 1),
ItemStack::new("minecraft:oak_planks", 2),
] {
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.grid[0] = stack("minecraft:oak_log", 1);
menu.result = Some(other);
// A stop, neither `MoveResult` nor a `Return`: the grid is the player's to see to.
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
}
#[test]
fn a_stop_with_the_placement_in_flight_still_moves_nothing_unexpected() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
craft.stop();
menu.grid[0] = stack("minecraft:oak_log", 1);
menu.result = stack("minecraft:diamond", 1);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn any_of_a_recipes_results_is_its_own() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
// A recipe of many results, as a tipped arrow's: each potion is the same recipe.
menu.recipe_results = vec![
ItemStack::new("minecraft:tipped_arrow", 8),
ItemStack::new("minecraft:spectral_arrow", 8),
];
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.result = stack("minecraft:spectral_arrow", 8);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
}
#[test]
fn a_hold_goes_on_until_the_items_run_out() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
for done in 1..=3 {
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", 4 * done);
}
menu.craftable = false;
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn a_result_without_room_for_all_of_it_never_moves() {
let now = Instant::now();
let mut menu = Menu::new(vec![
stack("minecraft:oak_planks", 62),
stack("minecraft:oak_log", 2),
]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.inventory[1] = stack("minecraft:oak_log", 1);
menu.grid[0] = stack("minecraft:oak_log", 1);
menu.result = stack("minecraft:oak_planks", 4);
// Room for two of the four: the log goes back, and the craft ends there.
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
menu.grid[0] = None;
menu.result = None;
menu.inventory[1] = stack("minecraft:oak_log", 2);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn a_full_inventory_leaves_the_remainder_in_the_grid() {
let now = Instant::now();
let mut menu = Menu::new(vec![stack("minecraft:dirt", 64)]);
let mut craft = held(now, now);
menu.grid[2] = stack("minecraft:bucket", 1);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn a_remainder_goes_back_before_the_next_placement() {
let now = Instant::now();
let mut menu = Menu::new(vec![None, None]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.result = stack("minecraft:cake", 1);
menu.grid[1] = stack("minecraft:milk_bucket", 1);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.grid[1] = stack("minecraft:bucket", 1);
menu.inventory[0] = stack("minecraft:cake", 1);
assert_eq!(menu.step(&mut craft, now), Step::Return(1));
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.grid[1] = None;
menu.inventory[1] = stack("minecraft:bucket", 1);
assert_eq!(menu.step(&mut craft, now), Step::Place);
}
#[test]
fn what_the_player_left_in_the_grid_goes_back_rather_than_being_crafted() {
let now = Instant::now();
let mut menu = Menu::new(vec![None]);
menu.grid[0] = stack("minecraft:oak_log", 1);
menu.result = stack("minecraft:oak_planks", 4);
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
menu.grid[0] = None;
menu.inventory[0] = stack("minecraft:oak_log", 1);
// The old result hasn't gone yet: no placement over it.
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.result = None;
assert_eq!(menu.step(&mut craft, now), Step::Place);
}
/// One craft from its placement to its result in the inventory, `total` of them by then.
fn make_one(menu: &mut Menu, craft: &mut DirectCraft, total: u32, now: Instant) {
assert_eq!(menu.step(craft, now), Step::Place);
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(craft, now), Step::MoveResult);
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", total);
}
#[test]
fn a_tap_makes_one_and_a_hold_waits_for_the_delay() {
let now = Instant::now();
let later = now + Duration::from_millis(350);
let mut menu = Menu::new(vec![None; 2]);
let mut craft = tap(now);
make_one(&mut menu, &mut craft, 4, now);
assert_eq!(menu.step(&mut craft, later), Step::Stop);
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(later, now);
make_one(&mut menu, &mut craft, 4, now);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
assert_eq!(menu.step(&mut craft, later), Step::Place);
}
#[test]
fn letting_go_before_the_delay_ends_the_wait_at_once() {
let now = Instant::now();
let later = now + Duration::from_secs(60);
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(later, now);
make_one(&mut menu, &mut craft, 4, now);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
craft.let_go();
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn letting_go_finishes_the_craft_in_flight_and_empties_the_grid() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 3]);
let mut craft = held(now, now);
make_one(&mut menu, &mut craft, 4, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
// Let go with the placement unanswered.
craft.let_go();
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.result = stack("minecraft:cake", 1);
menu.grid[1] = stack("minecraft:milk_bucket", 1);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.grid[1] = stack("minecraft:bucket", 1);
menu.inventory[1] = stack("minecraft:cake", 1);
assert_eq!(menu.step(&mut craft, now), Step::Return(1));
menu.grid[1] = None;
menu.inventory[2] = stack("minecraft:bucket", 1);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn stopping_before_anything_went_out_sends_nothing() {
let now = Instant::now();
let menu = Menu::new(vec![None]);
let mut craft = tap(now);
craft.stop();
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn stopping_keeps_only_the_craft_in_flight() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
make_one(&mut menu, &mut craft, 4, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
craft.stop();
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
craft.stop();
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", 8);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn a_refused_placement_ends_it_with_the_grid_returned() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
craft.refused();
menu.grid[0] = stack("minecraft:oak_log", 1);
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
menu.grid[0] = None;
menu.inventory[0] = stack("minecraft:oak_log", 1);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn another_press_of_the_recipe_waits_its_turn() {
let now = Instant::now();
let later = now + Duration::from_secs(60);
let mut menu = Menu::new(vec![None; 2]);
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
craft.stop();
craft.again(later);
craft.let_go();
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", 4);
// Asked for, so it needs no delay.
make_one(&mut menu, &mut craft, 8, now);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn quick_taps_while_one_is_in_flight_each_make_a_craft() {
let now = Instant::now();
let later = now + Duration::from_secs(60);
let mut menu = Menu::new(vec![None; 2]);
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Place);
// Two more taps of LT + A on the recipe, both up again before the first craft answers.
for _ in 0..2 {
craft.again(later);
craft.let_go();
}
assert_eq!(menu.step(&mut craft, now), Step::Wait);
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", 4);
make_one(&mut menu, &mut craft, 8, now);
make_one(&mut menu, &mut craft, 12, now);
assert_eq!(menu.step(&mut craft, now), Step::Stop);
}
#[test]
fn placements_keep_their_gap_while_the_other_requests_do_not() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
craft.place_gap = PLACE_GAP;
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.result = stack("minecraft:stick", 4);
// The result moves as soon as it shows.
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
menu.result = None;
menu.inventory[0] = stack("minecraft:stick", 4);
// The next placement waits out the gap from the last.
assert_eq!(menu.step(&mut craft, now + PLACE_GAP / 2), Step::Wait);
assert_eq!(menu.step(&mut craft, now + PLACE_GAP), Step::Place);
}
#[test]
fn every_request_keeps_its_gap_when_asked_to() {
let now = Instant::now();
let mut menu = Menu::new(vec![None; 2]);
let mut craft = held(now, now);
craft.request_gap = REQUEST_GAP;
assert_eq!(menu.step(&mut craft, now), Step::Place);
menu.result = stack("minecraft:stick", 4);
assert_eq!(menu.step(&mut craft, now), Step::Wait);
assert_eq!(menu.step(&mut craft, now + REQUEST_GAP), Step::MoveResult);
}
#[test]
fn a_grid_that_shrank_is_no_panic() {
let now = Instant::now();
let mut menu = Menu::new(vec![None]);
menu.grid[3] = stack("minecraft:oak_log", 2);
let mut craft = tap(now);
assert_eq!(menu.step(&mut craft, now), Step::Return(3));
menu.grid.truncate(1);
assert_eq!(menu.step(&mut craft, now), Step::Place);
}
#[test]
fn room_counts_only_stacks_that_can_take_it() {
let inventory = vec![
stack("minecraft:stick", 60),
stack("minecraft:bucket", 16),
None,
];
let max_stack = |s: &ItemStack| {
if &*s.item == "minecraft:bucket" {
16
} else {
64
}
};
let seen = Seen {
grid: &[],
result: None,
recipe_results: &[],
inventory: &inventory,
craftable: true,
max_stack: &max_stack,
};
assert_eq!(seen.room_for(&ItemStack::new("minecraft:stick", 1)), 68);
assert_eq!(seen.room_for(&ItemStack::new("minecraft:bucket", 1)), 16);
let damaged = ItemStack {
damage: 3,
..ItemStack::new("minecraft:stick", 1)
};
assert_eq!(seen.room_for(&damaged), 64);
}
}
@@ -159,6 +159,11 @@ pub(super) struct MenuCursor {
repeats: u32,
/// The on-screen keyboard, open over a text field.
pub(super) osk: Option<Keyboard>,
/// The recipe being crafted straight into the inventory (`pad_craft`).
pub(super) craft: Option<super::pad_craft::DirectCraft>,
/// LT held over the crafting book: it changes the category as it comes up, unless A was
/// pressed meanwhile (LT + A crafts, `pad_craft`).
pub(super) trigger: Option<HeldTrigger>,
/// The keyboard was closed on this screen (it doesn't open by itself again).
osk_dismissed: bool,
/// The keyboard drives the focus (`key_focus`: Tab, the arrows, Enter):
@@ -197,7 +202,21 @@ pub(super) struct MenuCursor {
prompt_crosshair: Option<(Screen, (f32, f32))>,
}
/// LT held over the crafting book.
#[derive(Clone, Copy)]
pub(super) struct HeldTrigger {
/// A was pressed while it was held: it was LT + A, not a category change.
pressed_a: bool,
}
impl MenuCursor {
/// A running direct craft ends, as cleanly as the server still lets it (`DirectCraft::stop`).
pub(super) fn stop_craft(&mut self) {
if let Some(craft) = &mut self.craft {
craft.stop();
}
}
/// `Screen.clearFocus`: nothing is focused any more, and no click focus
/// is kept for the next Tab (`ComponentPath.applyFocus(false)`).
fn clear_focus(&mut self) {
@@ -738,6 +757,12 @@ impl Game<'_> {
) -> Result<()> {
if !down {
match button {
Button::LeftTrigger if self.pad.menu.trigger.is_some() => {
let pressed_a = self.pad.menu.trigger.take().is_some_and(|t| t.pressed_a);
if !pressed_a && self.pad_recipe_book_showing() {
self.pad_tab(event_loop, false);
}
}
Button::South if self.pad.menu.armed.is_some() => {
let armed = self.pad.menu.armed.take();
if let Some(at) = armed_click(armed, self.pad.menu.focus) {
@@ -757,6 +782,17 @@ impl Game<'_> {
}
// The pad takes the focus back from the keyboard.
self.pad.menu.keyboard = false;
// A with LT held over the crafting book is LT + A: LT coming up changes no category.
if button == Button::South
&& let Some(trigger) = &mut self.pad.menu.trigger
{
trigger.pressed_a = true;
}
// Any press ends a running craft but another LT + A on its recipe, which asks for one
// more further down: quick taps while one is in flight each make a craft.
if !self.direct_craft_takes_press(button) {
self.direct_craft_stop();
}
// A prompt over the screen answers first; no press reaches both.
let prompt = self.prompt_open();
// Bindings waiting for a button: this one (Start cancels).
@@ -852,6 +888,21 @@ impl Game<'_> {
return Ok(());
}
}
// LT + A on a recipe crafts it into the inventory.
if button == Button::South
&& !prompt
&& self.pad.edges.is_held(Button::LeftTrigger)
&& self.screen == Screen::Inventory
&& self.title.is_none()
{
self.pad_take_pointer(event_loop);
// Refused, the press is plain A's.
if let Some(entry) = self.recipe_under_pointer()
&& self.direct_craft_start(entry)
{
return Ok(());
}
}
// The book's controls take A alone; X, Y and the throws are for slots and recipes.
let book_control = !prompt
&& self.screen == Screen::Inventory
@@ -938,6 +989,15 @@ impl Game<'_> {
Button::LeftBumper | Button::RightBumper if self.pad_recipe_book_showing() => {
self.pad_recipe_page(button == Button::RightBumper);
}
// On the crafting book LT waits for its release, as LT + A crafts there; RT, and
// either on a furnace's book, change the category as they go down.
Button::LeftTrigger
if self.pad_recipe_book_showing()
&& self.active_book()
== Some(horizon_client::recipe_book::BookType::Crafting) =>
{
self.pad.menu.trigger = Some(HeldTrigger { pressed_a: false });
}
Button::LeftTrigger | Button::RightTrigger if self.pad_recipe_book_showing() => {
self.pad_tab(event_loop, button == Button::RightTrigger);
}
@@ -1373,11 +1433,13 @@ impl Game<'_> {
menu.focus = None;
menu.pending = None;
menu.stick_scrolled = None;
menu.trigger = None;
menu.anchors = None;
menu.layout = None;
menu.nudge = None;
menu.osk = None;
menu.osk_dismissed = false;
menu.craft = None;
menu.focus_name = None;
menu.prompt = None;
menu.prompt_restart = None;
@@ -1386,6 +1448,7 @@ impl Game<'_> {
return;
}
self.pad.menu.open_polls = self.pad.menu.open_polls.saturating_add(1);
self.direct_craft_poll();
// Once the focus or the screen moved on from a slider it's let go for good, or coming back
// to it would find it still held.
if !self.pad_holding_slider() {
@@ -1871,6 +1934,8 @@ impl Game<'_> {
"holding_slider": self.pad_holding_slider(),
"pointer": self.pad.menu.pos.map(|_| self.cursor),
"keyboard": self.pad.menu.osk.is_some(),
"crafting": self.pad.menu.craft.is_some(),
"craft_hint": self.direct_craft_usable(),
"targets": self.pad_targets().iter().map(target).collect::<Vec<_>>(),
"actions": self.pad_actions_status(),
"glyphs": self.pad_glyphs_status(),
@@ -1898,6 +1963,7 @@ impl Game<'_> {
self.pad.menu.stick = None;
self.pad.menu.wheel = [0.0; 2];
self.pad.menu.moving = false;
self.pad.menu.trigger = None;
self.pad.menu.glide = None;
self.pad.menu.gesture = None;
self.pad.menu.pending = None;
@@ -444,6 +444,7 @@ impl Game<'_> {
1.0 / 32.0,
);
let entity_start = out.len();
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(p.id));
let light = self.world.light(p.pos[0], p.pos[1], p.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let mut quad =
+391 -4
View File
@@ -1060,12 +1060,19 @@ impl Game<'_> {
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
strip_finalize(&mut tag);
let position = entity_nbt::position(&tag)?;
let mut position = entity_nbt::position(&tag)?;
let item_transformation = if kind == "minecraft:item_display" {
super::native_entities::ItemDisplayTransformation::from_tag(tag.get("transformation"))
} else {
None
};
let knot_check = (kind == "minecraft:leash_knot").then_some(0);
if knot_check.is_some() {
// BlockAttachedEntity.setPos -> LeashFenceKnotEntity.recalculateBoundingBox.
let block = BlockPos::containing(position);
position =
horizon_client::mobs::leash::knot_state([block.x, block.y, block.z]).position;
}
let rotation = match tag.get("Rotation") {
Some(Tag::List(l)) => {
let f = |i: usize| match l.get(i) {
@@ -1087,6 +1094,7 @@ impl Game<'_> {
yaw: rotation[0],
pitch: rotation[1],
item_transformation,
knot_check,
});
Some(id)
}
@@ -1129,9 +1137,12 @@ impl Game<'_> {
c.entry("response".into()).or_insert(Tag::Byte(0));
}
}
// A marker is saved as `Entity.saveWithoutId` writes it: its common
// fields are there even when the loaded compound omitted them.
if matches!(o.kind.as_str(), "minecraft:marker" | "marker") {
// Marker and LeashFenceKnotEntity still use Entity.saveWithoutId's
// common fields when their additional-save methods write nothing.
if matches!(
o.kind.as_str(),
"minecraft:marker" | "marker" | "minecraft:leash_knot"
) {
entity_nbt::fill_common_defaults(&mut tag);
}
tag
@@ -1629,6 +1640,23 @@ impl Game<'_> {
.push(self.fangs_to_nbt(f, uuid));
}
}
// `AreaEffectCloud.addAdditionalSaveData`: a lingering potion's cloud is
// an entity of its chunk. A dragon's own breath carries no potion and
// is not written.
for c in &self.breath_clouds {
let at = Vec3::new(c.at[0], c.at[1], c.at[2]);
if c.potion.is_none() || !keys.contains(&chunk_of(at)) {
continue;
}
let uuid = self
.persist
.uuids
.get(&c.id)
.copied()
.unwrap_or_else(random_uuid);
let tag = super::potions::potion_cloud_to_nbt(c, uuid, self.persist.raw.get(&c.id));
out.entry(chunk_of(at)).or_default().push(tag);
}
for o in &self.others {
if keys.contains(&chunk_of(o.position)) {
let uuid = self
@@ -1727,6 +1755,15 @@ impl Game<'_> {
}
keep
});
// The potion clouds written above leave with their chunk; a
// dragon's breath (no potion) was not written and stays.
self.breath_clouds.retain(|c| {
let keep = c.potion.is_none() || !gone(Vec3::new(c.at[0], c.at[1], c.at[2]));
if !keep {
removed.push(c.id);
}
keep
});
// Riders leave with their root vehicle, a boat's with the boat.
let leaving: HashSet<u32> = (0..self.mobs.len())
.filter(|&i| {
@@ -2724,6 +2761,74 @@ mod tests {
}
}
/// A lingering potion's cloud is an entity of its chunk: unloading the chunk writes it and
/// takes it out of the level, so loading the chunk again gives back one cloud, not two.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lingering_cloud_leaves_with_its_chunk() {
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let cloud = Tag::compound([
("id", Tag::String("minecraft:area_effect_cloud".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(4.5f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(4.5f64.to_bits()),
]),
),
("UUID", Tag::IntArray(vec![5, 6, 7, 8])),
("Duration", Tag::Int(600)),
(
"potion_contents",
Tag::compound([("potion", Tag::String("minecraft:poison".into()))]),
),
]);
let clouds = |game: &Game| {
game.breath_clouds
.iter()
.filter(|c| c.potion.is_some())
.count()
};
game.world.loaded_entities.push(((0, 0), vec![cloud]));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "the cloud loads");
let unloaded = game.entity_chunks(&HashSet::from([(0, 0)]), true);
let written: Vec<Tag> = unloaded.into_iter().flat_map(|(_, tags)| tags).collect();
assert_eq!(written.len(), 1, "the unload writes the cloud");
assert_eq!(clouds(&game), 0, "and takes it out of the level");
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
}
#[test]
fn a_lone_rider_saves_the_horse() {
let horse = 7;
@@ -3551,6 +3656,288 @@ mod item_display_saved_transform_tests {
}
}
#[cfg(test)]
mod summoned_knot_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h129-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("difficulty normal"));
assert!(game.run_command("forceload add 0 0"));
for (x, z) in [(0, 1), (1, 0), (1, 1)] {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
assert!(
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random = LegacyRandom::new(129);
test(&mut game);
}
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
game.others
.iter()
.find(|o| game.persist.uuid_of(o.id) == Some([129, 0, 0, n]))
.unwrap()
.id
}
fn summon_knots(game: &mut Game<'_>) -> [u32; 3] {
for x in [6, 14] {
assert!(game.run_command(&format!("setblock {x} 200 6 oak_fence strict")));
}
for (n, x) in [(1, 6), (2, 10), (3, 14)] {
assert!(game.run_command(&format!("summon leash_knot {x}.2 200.8 6.9 {{UUID:[I;129,0,0,{n}],CustomName:\"knot\",Tags:[\"h129\"]}}")));
}
[1, 2, 3].map(|n| knot_id(game, n))
}
#[test]
#[ignore = "prepared resources; registered explicit knot load and real saved projection"]
fn summoned_knots_adopt_normalized_position_and_save_common_defaults() {
with_game(|game| {
let clock = game.world.game_time;
let ids = summon_knots(game);
for (i, id) in ids.into_iter().enumerate() {
let o = game.others.iter().find(|o| o.id == id).unwrap();
assert_eq!(o.kind, "minecraft:leash_knot");
assert_eq!(game.persist.uuid_of(id), Some([129, 0, 0, i as i32 + 1]));
let expected = Vec3::new(6.5 + 4.0 * i as f64, 200.375, 6.5);
assert_eq!(
o.position, expected,
"actual loaded model, not a getter-only answer"
);
let tag = game.entity_nbt(Target::Other(id)).unwrap();
assert_eq!(
tag.get("Pos"),
Some(&Tag::List(
expected
.components()
.map(|v| Tag::Double(v.to_bits()))
.to_vec()
))
);
for (key, value) in [
("Air", Tag::Short(300)),
("Invulnerable", Tag::Byte(0)),
("OnGround", Tag::Byte(0)),
("PortalCooldown", Tag::Int(0)),
("fall_distance", Tag::Double(0.0f64.to_bits())),
] {
assert_eq!(tag.get(key), Some(&value), "{key}");
}
assert_eq!(
tag.get("Tags"),
Some(&Tag::List(vec![Tag::String("h129".into())]))
);
// The same real model, with no raw source, must still save defaults.
let raw = game.persist.raw.remove(&id).unwrap();
let fresh = game.entity_nbt(Target::Other(id)).unwrap();
assert_eq!(fresh.get("Air"), Some(&Tag::Short(300)));
assert_eq!(fresh.get("Pos"), tag.get("Pos"));
game.persist.raw.insert(id, raw);
}
assert_eq!(game.world.game_time, clock);
});
}
#[test]
#[ignore = "prepared resources; actual Game world ticks and support removal"]
fn summoned_knots_check_actual_fence_support_on_tick_101() {
with_game(|game| {
let clock = game.world.game_time;
let ids = summon_knots(game);
assert!(
game.run_command(
"summon marker 8.2 200.8 10.9 {UUID:[I;129,0,0,4],data:{audit:7}}"
)
);
let marker = game
.others
.iter()
.find(|o| o.kind == "minecraft:marker")
.unwrap()
.id;
let marker_before = game.entity_nbt(Target::Other(marker)).unwrap();
for tick in 1..=101 {
game.tick().unwrap();
assert_eq!(game.world.game_time, clock + tick);
if tick == 1 {
assert!(game.run_command("setblock 14 200 6 air strict"));
}
if tick <= 100 {
assert!(
ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)),
"alive through tick{tick}"
);
}
}
assert!(
game.others.iter().any(|o| o.id == ids[0]),
"actual fence survives"
);
assert!(
!game.others.iter().any(|o| o.id == ids[1]),
"unsupported actual entity gone at101"
);
assert!(
!game.others.iter().any(|o| o.id == ids[2]),
"removed support actual entity gone at101"
);
assert_eq!(
game.entity_nbt(Target::Other(marker)).unwrap(),
marker_before
);
assert_eq!(
game.mobs
.iter()
.find(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
.unwrap()
.health,
10.0
);
assert!(game.entity_nbt(Target::Other(ids[1])).is_none());
assert!(game.run_command("kill @e[type=leash_knot]"));
assert!(!game.others.iter().any(|o| o.kind == "minecraft:leash_knot"));
});
}
#[test]
#[ignore = "prepared resources; negative fractional registered position"]
fn negative_fractional_knot_position_uses_floor_before_centering() {
with_game(|game| {
game.world
.chunks
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(-1, -1))
.unwrap();
assert!(game.run_command("setblock -2 199 -3 stone strict"));
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
let id = knot_id(game, 5);
assert_eq!(
game.others.iter().find(|o| o.id == id).unwrap().position,
Vec3::new(-1.5, 200.375, -2.5)
);
});
}
#[test]
#[ignore = "prepared resources; paused and actual ticket-excluded ticks"]
fn explicit_knot_check_counts_only_running_entity_ticking_turns() {
with_game(|game| {
let ids = summon_knots(game);
let clock = game.world.game_time;
for _ in 0..100 {
game.tick().unwrap();
}
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
assert!(game.run_command("tick freeze"));
for _ in 0..3 {
game.tick().unwrap();
}
assert_eq!(game.world.game_time, clock + 100);
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
assert!(game.run_command("gamerule spectators_generate_chunks false"));
assert!(game.run_command("gamemode spectator @s"));
assert!(game.run_command("forceload remove all"));
assert!(game.run_command("tick unfreeze"));
for _ in 0..3 {
game.tick().unwrap();
assert!(!game.entity_ticking_area()(10, 6));
}
assert_eq!(game.world.game_time, clock + 103);
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
assert!(game.run_command("gamemode creative @s"));
assert!(game.run_command("forceload add 0 0"));
game.tick().unwrap();
assert!(game.entity_ticking_area()(10, 6));
assert!(game.others.iter().any(|o| o.id == ids[0]));
assert!(
!game.others.iter().any(|o| o.id == ids[1]),
"101 eligible ticks, not elapsed world time"
);
});
}
#[test]
#[ignore = "prepared resources; existing natural lead/knot adapter control"]
fn explicit_knot_removal_preserves_a_real_lead_created_knot() {
with_game(|game| {
use horizon_client::mobs::leash::Holder;
assert!(game.run_command("tp @s 12.5 200 2.5"));
assert!(game.run_command("item replace entity @s weapon.mainhand with lead"));
assert!(game.run_command("setblock 14 200 2 oak_fence strict"));
let pig = game
.mobs
.iter()
.position(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
.unwrap();
assert!(game.interact_leash(pig, "minecraft:lead"));
assert!(game.use_knot([14, 200, 2], false));
assert_eq!(game.mobs[pig].extra.leash, Some(Holder::Knot([14, 200, 2])));
game.lan_assign_ids();
let natural = game.knot_ids[&[14, 200, 2]];
let ids = summon_knots(game);
for _ in 0..101 {
game.tick().unwrap();
}
game.lan_assign_ids();
assert_eq!(game.knot_ids[&[14, 200, 2]], natural);
let pig = game
.mobs
.iter()
.find(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
.unwrap();
assert_eq!(pig.extra.leash, Some(Holder::Knot([14, 200, 2])));
assert!(!game.others.iter().any(|o| o.id == ids[1]));
});
}
}
#[cfg(test)]
mod camel_pose_saved_tests {
use super::*;
@@ -8,6 +8,7 @@ impl Game<'_> {
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
self.direct_craft_stop();
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
// every key, before it decides anything.
self.creative_ignore_text = false;
+519 -85
View File
@@ -111,6 +111,152 @@ fn thrown_left_owner(ridden: &[Aabb], position: Vec3, velocity: Vec3) -> bool {
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// `AreaEffectCloud.readAdditionalSaveData` for the fields a client cloud keeps:
/// its position, lifetime, radius and use settings, and the potion it carries.
/// The custom particle and the owner reference are not modelled on a client
/// cloud, and the entity id is the caller's (`load_potion_cloud`).
pub(super) fn potion_cloud_from_nbt(tag: &Tag) -> Option<(Vec3, Breath)> {
use horizon_client::save::{float, number, text};
let at = horizon_client::entity_nbt::position(tag)?;
let int = |key, default| number(tag.get(key)).map_or(default, |v| v as i32);
let real = |key, default| float(tag.get(key)).map_or(default, |v| v as f32);
let contents = tag.get("potion_contents");
let mut effects = contents
.and_then(|c| text(c.get("potion")))
.and_then(|id| brewing::potion(&id))
.map_or_else(Vec::new, |p| p.effects.to_vec());
let (mut hidden, mut ambient) = (Vec::new(), Vec::new());
if let Some(Tag::List(custom)) = contents.and_then(|c| c.get("custom_effects")) {
for effect in custom {
let Some(def) = text(effect.get("id")).and_then(|id| brewing::effect(&id)) else {
continue;
};
let duration = number(effect.get("duration")).unwrap_or(0) as i32;
let amp = number(effect.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32;
effects.push((def.id, duration, amp));
if number(effect.get("show_particles")) == Some(0) {
hidden.push(def.id);
}
if number(effect.get("ambient")).unwrap_or(0) != 0 {
ambient.push(def.id);
}
}
}
let color = contents
.and_then(|c| number(c.get("custom_color")))
.map_or_else(
|| {
horizon_client_core::potions::effects_color(
effects
.iter()
.filter(|(id, ..)| !hidden.contains(id))
.map(|(id, _, amp)| (*id, *amp)),
)
},
|color| color as u32,
);
Some((
at,
Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: real("Radius", 3.0).clamp(0.0, 32.0),
age: int("Age", 0),
duration: int("Duration", -1),
from_dragon: false,
victims: Vec::new(),
potion: Some(PotionCloud {
settings: CloudSettings {
wait_time: int("WaitTime", 20),
reapplication_delay: int("ReapplicationDelay", 20),
radius_on_use: real("RadiusOnUse", 0.0),
duration_on_use: int("DurationOnUse", 0),
duration_scale: real("potion_duration_scale", 1.0),
},
effects,
hidden,
ambient,
radius_per_tick: real("RadiusPerTick", 0.0),
color,
victims: Vec::new(),
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
owner: None,
guest: None,
}),
},
))
}
/// `AreaEffectCloud.addAdditionalSaveData`: the lifetime, radius and use
/// settings with the effects the cloud applies, as `custom_effects` with the
/// colour they were drawn in. The client keeps no potion id, so a cloud that
/// came from a named potion is written effect by effect; the custom particle
/// and the owner have no counterpart here.
pub(super) fn potion_cloud_to_nbt(c: &Breath, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
use horizon_world::nbt::Compound;
let Some(potion) = &c.potion else {
return Tag::End;
};
let at = Vec3::new(c.at[0], c.at[1], c.at[2]);
let mut tag = horizon_client::entity_nbt::entity_base_tag(
"minecraft:area_effect_cloud",
at,
Vec3::ZERO,
[0.0, 0.0],
uuid,
raw,
);
let custom: Vec<Tag> = potion
.effects
.iter()
.map(|(id, duration, amplifier)| {
let mut effect = Compound::new();
effect.insert("id".into(), Tag::String(brewing::namespaced(id).into()));
effect.insert("duration".into(), Tag::Int(*duration));
effect.insert("amplifier".into(), Tag::Int(*amplifier));
if potion.hidden.contains(id) {
effect.insert("show_particles".into(), Tag::Byte(0));
}
if potion.ambient.contains(id) {
effect.insert("ambient".into(), Tag::Byte(1));
}
Tag::Compound(effect)
})
.collect();
let mut contents = Compound::new();
contents.insert("custom_effects".into(), Tag::List(custom));
contents.insert("custom_color".into(), Tag::Int(potion.color as i32));
let Tag::Compound(map) = &mut tag else {
return tag;
};
map.insert("Age".into(), Tag::Int(c.age));
map.insert("Duration".into(), Tag::Int(c.duration));
map.insert("Radius".into(), Tag::Float(c.radius.to_bits()));
map.insert(
"RadiusPerTick".into(),
Tag::Float(potion.radius_per_tick.to_bits()),
);
map.insert("WaitTime".into(), Tag::Int(potion.settings.wait_time));
map.insert(
"ReapplicationDelay".into(),
Tag::Int(potion.settings.reapplication_delay),
);
map.insert(
"RadiusOnUse".into(),
Tag::Float(potion.settings.radius_on_use.to_bits()),
);
map.insert(
"DurationOnUse".into(),
Tag::Int(potion.settings.duration_on_use),
);
map.insert(
"potion_duration_scale".into(),
Tag::Float(potion.settings.duration_scale.to_bits()),
);
map.insert("potion_contents".into(), Tag::Compound(contents));
tag
}
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
pub(super) enum Victim {
@@ -529,11 +675,12 @@ fn thrown_kind(stack: &ItemStack) -> &'static str {
}
/// `BottleItem.use`: find a living `AreaEffectCloud` owned by an `EnderDragon`
/// within the player's box grown by 2, and shrink its radius by 0.5.
/// within the player's box grown by 2, and shrink its radius by 0.5. Gives
/// the cloud's position when one was found.
pub(super) fn collect_dragon_breath(
player_box: Aabb,
clouds: &mut [super::dragon::Breath],
) -> bool {
) -> Option<[f64; 3]> {
let search_box = player_box.inflate(Vec3::new(2.0, 2.0, 2.0));
if let Some(c) = clouds.iter_mut().find(|c| {
if !c.from_dragon || c.potion.is_some() || super::dragon::breath_expired(c.age, c.duration)
@@ -548,9 +695,9 @@ pub(super) fn collect_dragon_breath(
search_box.intersects(cloud_box)
}) {
c.radius = (c.radius - 0.5).max(0.0);
true
Some(c.at)
} else {
false
None
}
}
@@ -614,6 +761,8 @@ impl Game<'_> {
v,
Some(self.player_display_text()),
);
// ThrowablePotionItem.use / ExperienceBottleItem.use: awardStat(ITEM_USED).
self.award_stat("used", &stack.item, 1);
self.swing_arm();
self.consume_held();
Ok(true)
@@ -623,9 +772,11 @@ impl Game<'_> {
// AreaEffectCloud owned by an EnderDragon within the player's box
// grown by 2. If one is found, it shrinks the cloud's radius by 0.5,
// plays item.bottle.fill_dragonbreath at the player, emits
// GameEvent.FLUID_PICKUP, and turns one bottle into dragon_breath.
// GameEvent.FLUID_PICKUP, fires PLAYER_INTERACTED_WITH_ENTITY on the
// cloud, and turns one bottle into dragon_breath.
if let Some(player) = self.player.as_ref()
&& collect_dragon_breath(player.bounds(), &mut self.breath_clouds)
&& let Some(cloud_at) =
collect_dragon_breath(player.bounds(), &mut self.breath_clouds)
{
let player_pos = [player.position.x, player.position.y, player.position.z];
self.audio.play(
@@ -635,6 +786,18 @@ impl Game<'_> {
1.0,
);
self.post_entity_event("minecraft:fluid_pickup", Who::Player);
let cloud = horizon_client::advancements::EntityView {
kind: "minecraft:area_effect_cloud".into(),
position: cloud_at,
..Default::default()
};
self.trigger_item_entity(
"minecraft:player_interacted_with_entity",
stack,
&cloud,
);
// BottleItem.turnBottleIntoItem.
self.award_stat("used", &stack.item, 1);
self.fill_held(ItemStack::new("minecraft:dragon_breath", 1));
return Ok(true);
}
@@ -645,13 +808,24 @@ impl Game<'_> {
if self.res.facts().get(state).fluid != horizon_client::blocks::Fluid::Water {
return Ok(false);
}
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
f64::from(hit.pos[2]) + 0.5,
];
// The sound at the player, the game event at the water block.
let Some(at) = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z])
else {
return Ok(false);
};
self.audio
.play("minecraft:item.bottle.fill", Some(at), 1.0, 1.0);
self.post_block_game_event(
"minecraft:fluid_pickup",
hit.pos,
Some(Who::Player),
None,
);
// BottleItem.turnBottleIntoItem.
self.award_stat("used", &stack.item, 1);
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
self.fill_held(water);
Ok(true)
@@ -1419,73 +1593,10 @@ impl Game<'_> {
/// simulation as lingering potions, with their own timing and potion data.
/// The caller retains their UUID and original compound for command queries.
pub(super) fn load_potion_cloud(&mut self, tag: &Tag) -> Option<u32> {
use horizon_client::save::{float, number, text};
let at = horizon_client::entity_nbt::position(tag)?;
let int = |key, default| number(tag.get(key)).map_or(default, |v| v as i32);
let real = |key, default| float(tag.get(key)).map_or(default, |v| v as f32);
let contents = tag.get("potion_contents");
let mut effects = contents
.and_then(|c| text(c.get("potion")))
.and_then(|id| brewing::potion(&id))
.map_or_else(Vec::new, |p| p.effects.to_vec());
let (mut hidden, mut ambient) = (Vec::new(), Vec::new());
if let Some(Tag::List(custom)) = contents.and_then(|c| c.get("custom_effects")) {
for effect in custom {
let Some(def) = text(effect.get("id")).and_then(|id| brewing::effect(&id)) else {
continue;
};
let duration = number(effect.get("duration")).unwrap_or(0) as i32;
let amp = number(effect.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32;
effects.push((def.id, duration, amp));
if number(effect.get("show_particles")) == Some(0) {
hidden.push(def.id);
}
if number(effect.get("ambient")).unwrap_or(0) != 0 {
ambient.push(def.id);
}
}
}
let color = contents
.and_then(|c| number(c.get("custom_color")))
.map_or_else(
|| {
horizon_client_core::potions::effects_color(
effects
.iter()
.filter(|(id, ..)| !hidden.contains(id))
.map(|(id, _, amp)| (*id, *amp)),
)
},
|color| color as u32,
);
let (_, mut cloud) = potion_cloud_from_nbt(tag)?;
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id,
at: [at.x, at.y, at.z],
radius: real("Radius", 3.0).clamp(0.0, 32.0),
age: int("Age", 0),
duration: int("Duration", -1),
from_dragon: false,
victims: Vec::new(),
potion: Some(PotionCloud {
settings: CloudSettings {
wait_time: int("WaitTime", 20),
reapplication_delay: int("ReapplicationDelay", 20),
radius_on_use: real("RadiusOnUse", 0.0),
duration_on_use: int("DurationOnUse", 0),
duration_scale: real("potion_duration_scale", 1.0),
},
effects,
hidden,
ambient,
radius_per_tick: real("RadiusPerTick", 0.0),
color,
victims: Vec::new(),
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
owner: None,
guest: None,
}),
});
cloud.id = id;
self.breath_clouds.push(cloud);
Some(id)
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
@@ -2178,6 +2289,84 @@ mod tests {
use super::*;
use horizon_client::screen_effects::nausea_blend_added;
/// `AreaEffectCloud.addAdditionalSaveData` with `readAdditionalSaveData`: a
/// lingering cloud written at a save reads back with its lifetime, radius,
/// use settings and the effects it sprays, with the hidden and ambient
/// flags it carried.
#[test]
fn a_lingering_cloud_survives_a_save() {
let cloud = Breath {
id: 7,
at: [1.5, 64.0, -2.5],
radius: 3.5,
age: 12,
duration: 400,
from_dragon: false,
victims: Vec::new(),
potion: Some(PotionCloud {
settings: CloudSettings {
wait_time: 10,
reapplication_delay: 25,
radius_on_use: -0.5,
duration_on_use: -5,
duration_scale: 0.5,
},
effects: vec![("poison", 200, 1), ("slowness", 100, 0)],
hidden: vec!["slowness"],
ambient: vec!["poison"],
radius_per_tick: -0.008_750,
color: 0x0033_FF44,
victims: Vec::new(),
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
owner: None,
guest: None,
}),
};
let tag = potion_cloud_to_nbt(&cloud, [1, 2, 3, 4], None);
let (at, back) = potion_cloud_from_nbt(&tag).expect("the cloud reads back");
assert_eq!(at, Vec3::new(1.5, 64.0, -2.5), "the position");
assert_eq!((back.age, back.duration), (12, 400));
assert!((back.radius - 3.5).abs() < 1.0e-6, "{}", back.radius);
let potion = back.potion.expect("a potion cloud");
assert_eq!(
(
potion.settings.wait_time,
potion.settings.reapplication_delay,
potion.settings.duration_on_use
),
(10, 25, -5)
);
assert!((potion.settings.radius_on_use + 0.5).abs() < 1.0e-6);
assert!((potion.settings.duration_scale - 0.5).abs() < 1.0e-6);
assert!((potion.radius_per_tick + 0.008_750).abs() < 1.0e-6);
assert_eq!(
potion.effects,
vec![("poison", 200, 1), ("slowness", 100, 0)]
);
assert_eq!(potion.hidden, vec!["slowness"]);
assert_eq!(potion.ambient, vec!["poison"]);
assert_eq!(potion.color, 0x0033_FF44, "the colour it was drawn in");
// The vanilla field names the reader of another client would look for.
for key in [
"Age",
"Duration",
"Radius",
"RadiusPerTick",
"WaitTime",
"ReapplicationDelay",
"RadiusOnUse",
"DurationOnUse",
"potion_duration_scale",
"potion_contents",
] {
assert!(tag.get(key).is_some(), "{key}");
}
assert_eq!(
horizon_client::entity_nbt::kind(&tag).as_deref(),
Some("minecraft:area_effect_cloud")
);
}
/// `WitherBoss.canBeAffected` refuses only the wither effect: its `addEffect`
/// refuses the rest, but a cloud of strength or healing still counts the boss
/// as a victim (`AreaEffectCloud.serverTick` asks `canBeAffected`).
@@ -2314,9 +2503,9 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(collect_dragon_breath(player_box, &mut clouds));
assert!(collect_dragon_breath(player_box, &mut clouds).is_some());
assert_eq!(clouds[0].radius, 4.5);
assert!(collect_dragon_breath(player_box, &mut clouds));
assert!(collect_dragon_breath(player_box, &mut clouds).is_some());
assert_eq!(clouds[0].radius, 4.0);
}
@@ -2336,7 +2525,7 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(!collect_dragon_breath(player_box, &mut non_dragon));
assert!(collect_dragon_breath(player_box, &mut non_dragon).is_none());
assert_eq!(non_dragon[0].radius, 3.0);
// Lingering potion cloud (`potion: Some(...)`).
@@ -2361,7 +2550,7 @@ mod tests {
guest: None,
}),
}];
assert!(!collect_dragon_breath(player_box, &mut lingering));
assert!(collect_dragon_breath(player_box, &mut lingering).is_none());
assert_eq!(lingering[0].radius, 3.0);
// Its last tick before the wait-shifted end still holds breath
@@ -2376,7 +2565,7 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(collect_dragon_breath(player_box, &mut last));
assert!(collect_dragon_breath(player_box, &mut last).is_some());
assert_eq!(last[0].radius, 4.5);
// Expired cloud (`tickCount - waitTime >= duration`).
@@ -2390,7 +2579,7 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(!collect_dragon_breath(player_box, &mut expired));
assert!(collect_dragon_breath(player_box, &mut expired).is_none());
assert_eq!(expired[0].radius, 5.0);
// Out of horizontal reach: cloud at 10 blocks away with radius 1:
@@ -2405,7 +2594,7 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(!collect_dragon_breath(player_box, &mut far));
assert!(collect_dragon_breath(player_box, &mut far).is_none());
assert_eq!(far[0].radius, 1.0);
// Out of vertical reach: cloud at Y=64.0 (Y bounds [64.0, 64.5]),
@@ -2421,7 +2610,7 @@ mod tests {
victims: Vec::new(),
potion: None,
}];
assert!(!collect_dragon_breath(high_player, &mut ground_cloud));
assert!(collect_dragon_breath(high_player, &mut ground_cloud).is_none());
assert_eq!(ground_cloud[0].radius, 1.0);
}
}
@@ -2558,6 +2747,251 @@ mod xp_bottle_tests {
}
}
#[cfg(test)]
mod mob_health_boost_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
use horizon_core::{geometry::Vec3, position::ChunkPos};
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h205-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
test(&mut game);
}
fn pig(game: &Game<'_>, n: i32) -> usize {
let i: Vec<usize> = (0..game.mobs.len())
.filter(|&i| game.persist.uuid_of(game.mobs[i].id) == Some([205, 0, 0, n]))
.collect();
assert_eq!(i.len(), 1, "one live pig with UUID {n}");
i[0]
}
fn health(game: &Game<'_>, n: i32) -> f32 {
game.mobs[pig(game, n)].health
}
fn boost(game: &Game<'_>, n: i32) -> Option<(i32, i32)> {
let i = pig(game, n);
game.mobs[i]
.extra
.effects
.iter()
.find(|e| e.0 == "minecraft:health_boost")
.map(|e| (e.1, e.2))
}
/// Expected values from the official 26.3 server: Health Boost II adds 8
/// to max_health without healing; Instant Health heals up to it on the next
/// tick; removing the effect (`/effect clear`, or running out) clamps
/// Health to the maximum without it (LivingEntity.onAttributeUpdated).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and a scratch HORIZON_HOME"]
fn health_boost_heals_to_its_maximum_and_clamps_when_it_goes() {
with_game(|game| {
// name, UUID word, Health, setup
let cases: [(&str, i32, f32, &[&str]); 5] = [
(
"boost",
1,
10.0,
&["effect give @e[tag=T,limit=1] minecraft:health_boost 100 1"],
),
(
"expiry",
2,
10.0,
&["effect give @e[tag=T,limit=1] minecraft:health_boost 2 1"],
),
("noboost", 3, 10.0, &[]),
("heal", 5, 5.0, &[]),
(
"base",
6,
10.0,
&["attribute @e[tag=T,limit=1] minecraft:max_health base set 14"],
),
];
for (name, n, h, setup) in cases {
let tag = format!("h205_{name}");
assert!(game.run_command(&format!(
"summon minecraft:pig {}.5 200 4.5 {{UUID:[I;205,0,0,{n}],Tags:[\"{tag}\"],PersistenceRequired:1b,Silent:1b,NoAI:1b,Health:{h}f}}",
2 * n
)));
for c in setup {
assert!(game.run_command(&c.replace('T', &tag)), "{name}: {c}");
}
}
// Setup controls: the effect is there, nothing healed on the add.
assert_eq!(boost(game, 1), Some((2000, 1)));
assert_eq!(boost(game, 2), Some((40, 1)));
for (_, n, h, _) in cases {
assert_eq!(health(game, n), h, "UUID {n}: no heal on the add");
}
for (name, n, _, _) in cases {
let level = if name == "heal" { 0 } else { 1 };
assert!(game.run_command(&format!(
"effect give @e[tag=h205_{name},limit=1] minecraft:instant_health 1 {level}"
)));
let _ = n;
}
game.tick_world(true).unwrap();
// The one tick: the boosts counted down once.
assert_eq!(boost(game, 1), Some((1999, 1)), "the mobs ticked once");
let mut failures = Vec::new();
let check = |game: &Game<'_>,
failures: &mut Vec<String>,
phase: &str,
expected: [(i32, f32); 5]| {
for (n, h) in expected {
if health(game, n) != h {
failures.push(format!(
"{phase}: UUID {n} Health {}, expected {h}",
health(game, n)
));
}
}
};
check(
game,
&mut failures,
"healed",
[(1, 18.0), (2, 18.0), (3, 10.0), (5, 9.0), (6, 14.0)],
);
assert!(
game.run_command("effect clear @e[tag=h205_boost,limit=1] minecraft:health_boost")
);
check(
game,
&mut failures,
"cleared",
[(1, 10.0), (2, 18.0), (3, 10.0), (5, 9.0), (6, 14.0)],
);
for _ in 0..40 {
game.tick_world(true).unwrap();
}
if boost(game, 2).is_some() {
failures.push("expiry: the effect did not run out in 40 ticks".into());
}
check(
game,
&mut failures,
"expired",
[(1, 10.0), (2, 10.0), (3, 10.0), (5, 9.0), (6, 14.0)],
);
assert!(failures.is_empty(), "mob health boost: {failures:#?}");
});
}
/// Existing-behavior preservation, not parity: a sulfur cube's size and a
/// tame wolf write their maximum straight into the cache, outside the
/// attribute model. Health Boost leaves those caches (and Health) as they
/// are; the attribute model's own refresh never replaces them.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and a scratch HORIZON_HOME"]
fn health_boost_keeps_a_size_or_tame_maximum_owned_outside_the_attributes() {
with_game(|game| {
// tag, UUID word, summon, amplifier
let cases = [
// size 3: cache 12, the attribute model 20
("h205_cube", 20, "sulfur_cube 4.5 200 8.5 {Size:2", 1),
// size 5: cache 20 = the model's 20 by coincidence
("h205_cube_equal", 22, "sulfur_cube 6.5 200 8.5 {Size:4", 1),
// tame: cache 40, model 8; Health Boost II
("h205_wolf", 21, "wolf 8.5 200 8.5 {", 1),
// tame: Health Boost VIII (+32) makes the model 40 = the cache
("h205_wolf_equal", 23, "wolf 10.5 200 8.5 {", 7),
];
let mut ids = Vec::new();
for (tag, n, summon, _) in cases {
let sep = if summon.ends_with('{') { "" } else { "," };
assert!(game.run_command(&format!(
"summon minecraft:{summon}{sep}UUID:[I;205,0,0,{n}],Tags:[\"{tag}\"],NoAI:1b,PersistenceRequired:1b,Silent:1b}}"
)));
let i = pig(game, n);
if tag.starts_with("h205_wolf") {
horizon_client::mobs::tame::set_tame(&mut game.mobs[i], true);
}
ids.push(i);
}
let state = |game: &Game<'_>| -> Vec<(f32, f32)> {
ids.iter()
.map(|&i| (game.mobs[i].attrs.max_health, game.mobs[i].health))
.collect()
};
let before = state(game);
assert_eq!(
before.iter().map(|s| s.0).collect::<Vec<_>>(),
[12.0, 20.0, 40.0, 40.0]
);
assert_eq!(
horizon_client::mobs::finalize::max_health(&game.mobs[ids[1]]),
20.0,
"the coincidence: the model's value equals the size cache"
);
for (tag, _, _, amp) in cases {
assert!(game.run_command(&format!(
"effect give @e[tag={tag},limit=1] minecraft:health_boost 100 {amp}"
)));
}
assert_eq!(
horizon_client::mobs::finalize::max_health(&game.mobs[ids[3]]),
40.0,
"the coincidence: the boosted model equals the tame cache"
);
assert_eq!(
state(game),
before,
"the size/tame cache and Health keep their owner"
);
for (tag, _, _, _) in cases {
assert!(game.run_command(&format!(
"effect clear @e[tag={tag},limit=1] minecraft:health_boost"
)));
}
assert_eq!(state(game), before, "and after the clear");
});
}
}
#[cfg(test)]
mod depleted_absorption_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
+52 -1
View File
@@ -116,7 +116,7 @@ fn sprite(name: &str) -> String {
impl Game<'_> {
/// The book the open screen has, if any.
fn active_book(&self) -> Option<BookType> {
pub(super) fn active_book(&self) -> Option<BookType> {
if self.screen != Screen::Inventory || self.station.is_some() {
return None;
}
@@ -908,6 +908,55 @@ impl Game<'_> {
inside(self.cursor, x, y, w, h)
})
}
/// Whether the crafting book's entry can be crafted from what the menu holds now.
pub(super) fn recipe_craftable(&mut self, entry_index: usize) -> bool {
let available = self.book_available(BookType::Crafting);
self.recipe_ui.book.as_ref().is_some_and(|book| {
book.entries
.get(entry_index)
.is_some_and(|entry| book.can_craft(entry, &available))
})
}
/// The crafting book's entry by what a rebuilt book keeps of it (`pad_craft::RecipeKey`).
pub(super) fn recipe_stable_key(
&self,
entry_index: usize,
) -> Option<super::pad_craft::RecipeKey> {
let entry = self.recipe_ui.book.as_ref()?.entries.get(entry_index)?;
Some(super::pad_craft::RecipeKey {
id: entry.id.clone(),
display: entry.display_id,
})
}
/// Where the crafting book holds the recipe now: none once the book has it no more.
pub(super) fn recipe_entry_index(&self, key: &super::pad_craft::RecipeKey) -> Option<usize> {
self.recipe_ui
.book
.as_ref()?
.entries
.iter()
.position(|e| e.id == key.id && e.display_id == key.display)
}
/// What the crafting book's entry makes: none when the book has no such entry.
pub(super) fn recipe_results(&self, entry_index: usize) -> &[ItemStack] {
self.recipe_ui
.book
.as_ref()
.and_then(|book| book.entries.get(entry_index))
.map_or(&[], Entry::results)
}
/// Places one set of the crafting book's entry, as a click on its button places it.
pub(super) fn recipe_place_once(&mut self, entry_index: usize) {
self.recipe_ui.last_placed = self
.recipe_ui
.book
.as_ref()
.and_then(|b| b.entries.get(entry_index))
.map(|e| e.id.clone());
self.recipe_ui.ghost.clear();
self.recipe_ui.overlay = None;
self.place_recipe(BookType::Crafting, entry_index, false);
}
/// The recipe book's buttons a controller can focus: the book button,
/// recipes, page arrows, the craftable filter and the tabs (GUI pixels;
/// the search box registers itself as a field).
@@ -1299,6 +1348,8 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.fill_ghost(book_type, &entry);
}
// The server is missing an item the client counted: nothing was placed.
self.direct_craft_refused();
}
// ---- ghost recipe ----
+107 -10
View File
@@ -8010,7 +8010,7 @@ impl Game<'_> {
return;
}
// startDestroyBlock refuses a restricted break before predicting it.
if self.remote_break_restricted() {
if self.remote_break_restricted(hit.pos) {
self.mining = None;
return;
}
@@ -8193,12 +8193,21 @@ impl Game<'_> {
}
self.mining = None;
}
/// `Player.blockActionRestricted` as far as this client reads it: a
/// spectator never breaks, an adventure player only with a main-hand
/// item the server gave `can_break`. Whether its predicate matches the
/// block is left to the server, which sends the block back if not.
fn remote_break_restricted(&self) -> bool {
/// `Player.blockActionRestricted` for the block at `pos`: a spectator
/// never breaks, an adventure player only with a main-hand item whose
/// `can_break` matches the block (`ItemStack.canBreakBlockInAdventureMode`).
/// A predicate this client could not decode (exact components) only
/// tells that the server gave one, and the server sends the block back
/// if it does not match.
fn remote_break_restricted(&self, pos: [i32; 3]) -> bool {
use horizon_protocol::item::{WirePatch, component::CAN_BREAK};
if self
.held()
.and_then(|s| s.data())
.is_some_and(|d| d.other("minecraft:can_break").is_some())
{
return self.block_action_restricted(pos);
}
self.spectator()
|| (!self.may_build()
&& !self
@@ -8294,7 +8303,18 @@ impl Game<'_> {
)
})
.unwrap_or((false, false));
let passes = is_painting || frame_passes || boat_passes || cart_passes;
// Cushion.interact on a client: PASS when sneaked at or when
// something already sits on it, CONSUME (no swing) otherwise.
let is_cushion = self.cushions.iter().any(|c| c.id as i32 == hit.id);
let cushion_passes = is_cushion
&& (sneaking
|| self.riding.is_some_and(|r| r as i32 == hit.id)
|| self
.mobs
.iter()
.any(|m| m.extra.mount.is_some_and(|r| r as i32 == hit.id)));
let passes =
is_painting || frame_passes || boat_passes || cart_passes || cushion_passes;
// Otherwise the main hand's turn takes it; there is no off hand turn.
if response.is_none() && hand == Hand::Off && !passes {
return false;
@@ -8326,8 +8346,9 @@ impl Game<'_> {
}
// Minecart.interact boards the cart and answers CONSUME on the client,
// which ends the click without an arm swing. AbstractBoat.interact
// answers SUCCESS there, which swings.
if remote_object_swings(response, is_rideable_cart) {
// answers SUCCESS there, which swings. Cushion.interact answers
// CONSUME there too.
if remote_object_swings(response, is_rideable_cart || is_cushion) {
self.with_hand(hand, Game::swing_arm);
}
return true;
@@ -8553,6 +8574,28 @@ impl Game<'_> {
self.with_hand(hand, Game::swing_arm);
}
return true;
} else if let Some(stack) = held.as_ref()
&& &*stack.item == "minecraft:potion"
&& stack.potion() == Some("minecraft:water")
&& hit.face != [0, -1, 0]
&& self.block_in_tag("minecraft:convertible_to_mud", &self.block_name(hit.pos))
&& (self.may_build()
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)))
{
// PotionItem.useOn on a client level: SUCCESS. The bottle is
// poured out into the hand and the block turns to mud here at
// once; the sounds (`playSound(null, ...)`) and the splash
// particles (`sendParticles`) are the server's.
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
let pos =
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
self.predict_blocks(&vec![(pos, mud)], seq);
}
self.with_hand(hand, |g| {
g.filled_result(ItemStack::new("minecraft:glass_bottle", 1))
});
self.with_hand(hand, Game::swing_arm);
return true;
} else if let Some(stack) = held.as_ref()
&& (self.predict_item_on_block(stack, &hit, hand)
|| self.predict_map_on_banner(stack, &hit))
@@ -9143,6 +9186,28 @@ impl Game<'_> {
}
return succeeded;
}
// KnowledgeBookItem.use on this client: the book is used up either
// way. With recipes it is SUCCESS (swings, ends the click); with none
// it is FAIL, and the other hand gets its turn. The recipes go as
// their NBT on the wire, and the item's own default is an empty list,
// so a stack whose patch sets them has some.
if &*held.item == "minecraft:knowledge_book" {
/// `DataComponents.RECIPES`' registry id (26.3).
const RECIPES: i32 = 68;
let has_recipes = held
.wire_patch()
.is_some_and(|p| horizon_protocol::item::WirePatch::adds(&p, RECIPES))
|| held
.data()
.and_then(|d| d.other("minecraft:recipes"))
.is_some_and(|t| matches!(t, Tag::List(l) if !l.is_empty()));
self.with_hand(hand, Game::consume_held);
if has_recipes {
self.apply_use_cooldown(&held);
self.with_hand(hand, Game::swing_arm);
}
return has_recipes;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
@@ -9775,6 +9840,23 @@ impl Game<'_> {
.own_attribute("minecraft:attack_knockback")
.map_or(0.0, |v| v as f32);
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
// attackVisualEffects: a RemotePlayer's hurtClient is true, so a
// critical hit (full strength, canCriticalAttack) with damage left
// after baseDamageScaleFactor runs LocalPlayer.crit, this client's
// own CRIT emitter beside the one the server's Animate brings.
// magicBoost is 0 on a client, so no ENCHANTED_HIT one.
if damage * (0.2 + strength * strength * 0.8) > 0.0 && self.critical_attack(strength) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let m = &self.mobs[mob];
let feet = [m.position.x, m.position.y, m.position.z];
let (width, height) = (m.kind.width, m.kind.height);
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
}
if !horizon_client::attack_strength::attacker_slows_after_hit(
damage, knockback, strength, sprinting,
) {
@@ -9845,7 +9927,7 @@ impl Game<'_> {
self.swing_arm();
self.send(S::Punch(Empty));
}
} else if self.remote_break_restricted() {
} else if self.remote_break_restricted(hit.pos) {
// continueDestroyBlock returned false: no swing.
} else if !air {
self.remote_mining(false);
@@ -10150,6 +10232,20 @@ impl Game<'_> {
}
true
}
/// A QUICK_MOVE on the server's open menu with nothing predicted: the
/// client's slots stay as they are and every slot it moved comes back from
/// the server (`broadcastChanges`), the answer the next step waits for.
/// A prediction the server agrees with gets no answer at all.
pub(super) fn remote_quick_move_unpredicted(&mut self, internal: usize) -> bool {
let (container, menu) = self.remote_menu();
let Some(slot) = Self::menu_slot_index(&menu, internal) else {
return false;
};
let carried = self.carried.clone();
let click = (slot as i16, 0, Input::QuickMove, Vec::new(), carried);
self.send_clicks(container, vec![click]);
true
}
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
/// a predicted QUICK_MOVE for every slot the engine moves, each sent.
pub(super) fn remote_quick_move_all(&mut self, slot: usize, moved: &ItemStack) {
@@ -11327,6 +11423,7 @@ pub(super) fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
| "goat_horn"
| "bundle"
| "brush"
| "knowledge_book"
)
}
@@ -7,7 +7,7 @@ use super::Game;
use horizon_client::actions::Action;
use horizon_client::{
first_person::Hand,
mobs::{Mob, Species, armadillo, equine, llama, nautilus},
mobs::{Mob, Species, armadillo, equine, llama, nautilus, panda, villager},
};
impl Game<'_> {
@@ -47,6 +47,21 @@ impl Game<'_> {
if m.species() == Species::Armadillo && armadillo::client_rejects_food(m, item) {
return false;
}
// Panda.mobInteract: a scared panda passes and one lying on its back
// takes any item (it gets up on the server). Its bamboo is taken on a
// client only by a baby that ages up; an adult's breeding and eating
// need a ServerLevel, so the click passes to the item.
if m.species() == Species::Panda {
if panda::scared(m) {
return false;
}
if panda::is_on_back(m) {
return true;
}
if m.kind.food.iter().any(|f| f.as_str() == item) {
return baby && !m.extra.age_locked;
}
}
// AgeableMob.mobInteract: a golden dandelion on a baby that can be age
// locked is SUCCESS on a client too (the server sets the lock).
if self.age_lock_takes(m, item) {
@@ -63,8 +78,11 @@ impl Game<'_> {
}
let sneaking = self.keys.held(Action::Sneak);
match m.species() {
// Villager / WanderingTrader.mobInteract: trading, or a baby's head shake.
Species::Villager | Species::WanderingTrader => item != "minecraft:villager_spawn_egg",
// Villager.mobInteract: trading, or a baby's head shake. A sleeping
// villager falls back to super.mobInteract, which passes.
Species::Villager => item != "minecraft:villager_spawn_egg" && !villager::sleeping(m),
// WanderingTrader.mobInteract: trading.
Species::WanderingTrader => item != "minecraft:villager_spawn_egg",
// AbstractHorse, Camel: ridden (or their inventory opened); a baby only eats.
s if equine::is_equine(s)
|| llama::is_llama(s)
@@ -72,8 +90,14 @@ impl Game<'_> {
{
!baby
}
// Pig / Strider.mobInteract: a saddled one is ridden.
Species::Pig | Species::Strider => m.extra.saddled && !sneaking,
// Pig / Strider.mobInteract: a saddled one nobody rides is ridden
// (`!isVehicle()`).
Species::Pig | Species::Strider => {
m.extra.saddled
&& !sneaking
&& self.riding != Some(m.id)
&& !self.mobs.iter().any(|o| o.extra.mount == Some(m.id))
}
// HappyGhast.mobInteract: a harnessed adult is ridden.
Species::HappyGhast => !baby && m.extra.body.is_some(),
// TamableAnimal (wolf, cat, parrot) on a client: CONSUME once tame.
@@ -162,6 +186,11 @@ impl Game<'_> {
Hand::Off => self.offhand.as_ref(),
};
let item = stack.map_or("", |s| &*s.item);
// NameTagItem.interactLivingEntity: only a named tag answers (SUCCESS);
// an unnamed one passes on to the trader's trading (SUCCESS_SERVER).
if m.species() == Species::WanderingTrader && item == "minecraft:name_tag" {
return stack.is_some_and(|s| s.custom_name().is_some());
}
mob_swings(m, item)
}
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
@@ -254,8 +283,15 @@ fn mob_swings(m: &Mob, item: &str) -> bool {
{
return false;
}
// Panda.mobInteract: its bamboo answers SUCCESS_SERVER.
// Panda.mobInteract: getting up off its back is SUCCESS, and its
// bamboo answers SUCCESS_SERVER.
Species::Panda if panda::is_on_back(m) => return true,
Species::Panda if food => return false,
// WanderingTrader.mobInteract: trading answers SUCCESS_SERVER, and so
// does a spawn egg (Mob.checkAndHandleImportantInteractions). A lead is
// CONSUME on the client (Entity.interact: a trader can't be leashed), so
// nothing swings; a named name tag is decided by the caller.
Species::WanderingTrader if item != "minecraft:name_tag" => return false,
// Cat.mobInteract: food tames an untamed cat, and heals a hurt
// tame one: SUCCESS before Animal.mobInteract is reached.
Species::Cat if food && (!m.extra.tame || hurt) => return true,
+16 -4
View File
@@ -239,7 +239,9 @@ impl Game<'_> {
return;
};
let file = multiplayer::servers_file(&saves());
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
// ServerList.get: the shown entries, then the hidden ones.
let (shown, hidden) = servers::load_all(&file);
let entry = shown.into_iter().chain(hidden).find(|s| s.ip == address);
let setting = entry.as_ref().and_then(|e| e.accept_textures);
state.address = entry.map(|e| e.ip);
state.packs.connect(Prompt::for_server(setting));
@@ -297,8 +299,10 @@ impl Game<'_> {
/// The joined server's entry in the server list: its `acceptTextures`.
fn server_setting(&self) -> Option<bool> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
let (shown, hidden) = servers::load_all(&multiplayer::servers_file(&saves()));
shown
.into_iter()
.chain(hidden)
.find(|s| s.ip == address)?
.accept_textures
}
@@ -348,11 +352,19 @@ impl Game<'_> {
// ServerList.saveSingleServer.
if let Some(address) = &self.server_packs.address {
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
let (mut list, mut hidden) = servers::load_all(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accept_textures = setting;
found = true;
}
if let Err(e) = servers::save(&file, &list) {
// Not among the shown entries: the hidden ones (a Direct Connection's).
if !found {
for entry in hidden.iter_mut().filter(|s| &s.ip == address) {
entry.accept_textures = setting;
}
}
if let Err(e) = servers::save_all(&file, &list, &hidden) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
@@ -2534,6 +2534,26 @@ impl Game<'_> {
}
}
}
// AdventureModePredicate.addToTooltip under its header, for
// can_break and then can_place_on. A block tag's blocks are listed by
// id, as this side keeps tags unordered.
for (text, c) in horizon_client::item_tooltip::adventure_lines(
stack,
&display,
&|tag| {
let mut blocks: Vec<String> = self
.items()
.block_tags
.get(tag)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
blocks.sort();
blocks
},
&translate,
) {
push(&mut lines, text, c);
}
if display.shows("minecraft:damage")
&& let Some(info) = item_info
&& info.max_damage > 0
+4 -3
View File
@@ -289,13 +289,14 @@ impl Game<'_> {
self.award_custom_stat("total_world_time", 1);
if alive {
self.award_custom_stat("time_since_death", 1);
if !sleeping {
self.award_custom_stat("time_since_rest", 1);
}
}
if crouching {
self.award_custom_stat("sneak_time", 1);
}
// ServerPlayer.doTick: dead or alive, every tick out of bed.
if !sleeping {
self.award_custom_stat("time_since_rest", 1);
}
}
fn tick_movement_stats(&mut self) {
let Some(p) = self.player.as_ref() else {
@@ -0,0 +1,187 @@
//! `/data modify storage … value[] set value "bad"`: `StorageDataAccessor`
//! edits the stored compound itself, and `AllElementsNode.setTag` clears a
//! non-empty array before it rejects the string. The command fails, and the
//! storage keeps the cleared array, with no `setData` and so no save of its
//! own (tools/test_storage_alias_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_world::nbt::{Tag, parse_snbt};
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game
}
fn stored(game: &Game<'_>, id: &str) -> Tag {
game.score.storage.get(id)
}
fn snbt(text: &str) -> Tag {
parse_snbt(text).unwrap().0
}
/// Whether saving the storage now writes a file (it is dirty).
fn writes(game: &mut Game<'_>, name: &str) -> bool {
let dir = std::env::temp_dir().join(format!("h246-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
game.score.storage.save(&dir);
let wrote = dir.join("data").exists();
let _ = std::fs::remove_dir_all(&dir);
wrote
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_rejected_wildcard_set_keeps_the_storage_s_cleared_array() {
let mut game = game();
assert!(game.run_command("data merge storage h246:witness {v:123}"));
for (kind, initial, cleared) in [
("byte", "[B;1b,2b]", "[B;]"),
("int", "[I;1,2]", "[I;]"),
("long", "[L;1L,2L]", "[L;]"),
] {
let id = format!("h246:{kind}");
assert!(game.run_command(&format!("data merge storage {id} {{value:{initial}}}")));
assert!(writes(&mut game, kind), "{kind}: the merge is saved");
assert!(
!game.run_command(&format!(
"data modify storage {id} value[] set value \"bad\""
)),
"{kind}: the command fails"
);
assert_eq!(
stored(&game, &id),
snbt(&format!("{{value:{cleared}}}")),
"{kind}: the array was cleared in place"
);
assert!(!writes(&mut game, kind), "{kind}: no save of its own");
assert_eq!(stored(&game, "h246:witness"), snbt("{v:123}"));
}
// Nothing to change: nothing cleared.
assert!(game.run_command("data merge storage h246:same {value:[I;7,7]}"));
assert!(!game.run_command("data modify storage h246:same value[] set value 7"));
assert_eq!(stored(&game, "h246:same"), snbt("{value:[I;7,7]}"));
}
/// Runs `command`, which must fail because nothing changed.
fn fails_unchanged(game: &mut Game<'_>, command: &str) {
game.chat_capture = Some(Vec::new());
assert!(!game.run_command(command), "{command}");
let said = game.chat_capture.take().unwrap_or_default();
assert_eq!(
said.first()
.map(horizon_client::text::Text::plain)
.as_deref(),
Some("Nothing changed. The specified properties already have these values"),
"{command}"
);
}
/// The storage file a save writes under `dir`, read back.
fn saved(
game: &mut Game<'_>,
name: &str,
) -> Option<horizon_client::command_storage::CommandStorage> {
let dir = std::env::temp_dir().join(format!("h246-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
game.score.storage.save(&dir);
let back = dir
.join("data")
.exists()
.then(|| horizon_client::command_storage::CommandStorage::load(&dir));
let _ = std::fs::remove_dir_all(&dir);
back
}
/// Added controls (after the baseline): the failure keeps the storage's
/// save state as it was, a later set saves what the failure left, and a
/// failure on an id that is not stored, or one that changed nothing, stores
/// nothing new. An entity's data stays a copy.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_failure_keeps_the_save_state_and_stores_nothing_new() {
let mut game = game();
// Already marked for saving: the failure leaves it so, and the save
// writes the cleared array.
assert!(game.run_command("data merge storage h246:dirty {value:[I;1,2]}"));
assert!(!game.run_command("data modify storage h246:dirty value[] set value \"bad\""));
let back = saved(&mut game, "dirty").expect("still marked for saving");
assert_eq!(back.get("h246:dirty"), snbt("{value:[I;]}"));
// Saved, then failed: nothing to write, until a later set saves both.
assert!(game.run_command("data merge storage h246:clean {value:[I;1,2]}"));
assert!(saved(&mut game, "clean-before").is_some());
assert!(!game.run_command("data modify storage h246:clean value[] set value \"bad\""));
assert!(
saved(&mut game, "clean-after").is_none(),
"the failure saves nothing"
);
assert!(game.run_command("data merge storage h246:later {v:1}"));
let back = saved(&mut game, "later").expect("the set marks it");
assert_eq!(back.get("h246:clean"), snbt("{value:[I;]}"));
assert_eq!(back.get("h246:later"), snbt("{v:1}"));
// A failure on an id that is not stored stores nothing.
let keys = game.score.storage.keys();
fails_unchanged(
&mut game,
"data modify storage h246ghost:none {} merge value {}",
);
assert_eq!(game.score.storage.keys(), keys, "no new id");
assert!(game.run_command("data merge storage h246:mark {v:2}"));
let back = saved(&mut game, "ghost").unwrap();
assert!(
!back.keys().iter().any(|k| k.starts_with("h246ghost:")),
"no new namespace"
);
// A failure that changed nothing keeps every id and its contents.
assert!(game.run_command("data merge storage h246:same {value:[I;7,7]}"));
let keys = game.score.storage.keys();
fails_unchanged(
&mut game,
"data modify storage h246:same value[] set value 7",
);
assert_eq!(game.score.storage.keys(), keys);
assert_eq!(stored(&game, "h246:same"), snbt("{value:[I;7,7]}"));
// An entity's data is a copy: its UUID stays.
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
assert!(game.run_command("summon armor_stand 4.5 100 4.5 {NoGravity:1b,UUID:[I;582,0,0,1]}"));
let stand = "00000246-0000-0000-0000-000000000001";
fails_unchanged(
&mut game,
&format!("data modify entity {stand} UUID[] set value \"bad\""),
);
assert!(game.run_command(&format!(
"execute if data entity {stand} {{UUID:[I;582,0,0,1]}}"
)));
}
+1
View File
@@ -490,6 +490,7 @@ impl Game<'_> {
) {
continue;
}
self.mark_motion(out.len(), Self::MOTION_OBJECT | u64::from(t.id));
let fuse = t.fuse as f32 - alpha as f32 + 1.0;
let scale = if fuse < 10.0 {
let g = (1.0 - fuse / 10.0).clamp(0.0, 1.0);
+92
View File
@@ -188,6 +188,14 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
};
text
}
// AdventureModePredicate: the blocks an adventure player may break
// or place on, which this client matches as the server does.
Component::Raw(bytes) if matches!(*kind, component::CAN_BREAK | CAN_PLACE_ON) => {
let Some(predicate) = adventure_predicate_tag(bytes, builtin) else {
continue;
};
predicate
}
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
@@ -422,6 +430,90 @@ fn block_entity_data_tag(bytes: &[u8], builtin: &Builtin) -> Option<Tag> {
Some(Tag::Compound(data))
}
/// `DataComponents.CAN_PLACE_ON`'s registry id (26.3); `CAN_BREAK` follows it.
const CAN_PLACE_ON: i32 = 14;
/// `AdventureModePredicate.STREAM_CODEC` (a list of `BlockPredicate`s) as
/// `AdventureModePredicate.CODEC` saves it: one predicate, or a list of them
/// (`ExtraCodecs.compactListCodec`). Each keeps its `blocks` (a `#tag`, one
/// block id or a list), `state` and `nbt`; `DataComponentMatchers` are read
/// past (`BlockPredicate.matches(BlockInWorld)` does not test them), and a
/// predicate with exact components, whose values this side cannot read, gives
/// up on the whole component.
fn adventure_predicate_tag(bytes: &[u8], builtin: &Builtin) -> Option<Tag> {
let blocks = builtin.get("minecraft:block")?;
let mut r = Reader::new(bytes);
let n = r.count(r.remaining()).ok()?;
let mut predicates = Vec::with_capacity(n.min(64));
for _ in 0..n {
let mut p: Vec<(&str, Tag)> = Vec::new();
// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
if r.bool().ok()? {
let set = match r.varint().ok()? {
0 => Tag::String(format!("#{}", r.string(32767).ok()?).as_str().into()),
size => {
let size = usize::try_from(size).ok()?.checked_sub(1)?;
if size > r.remaining() {
return None;
}
let mut ids = (0..size)
.map(|_| Some(Tag::String(blocks.name(r.varint().ok()?)?.into())))
.collect::<Option<Vec<_>>>()?;
if ids.len() == 1 {
ids.pop()?
} else {
Tag::List(ids)
}
}
};
p.push(("blocks", set));
}
// StatePropertiesPredicate: each property an exact value, or a range.
if r.bool().ok()? {
let mut state: Vec<(String, Tag)> = Vec::new();
for _ in 0..r.count(r.remaining()).ok()? {
let name = r.string(32767).ok()?;
let matcher = if r.bool().ok()? {
Tag::String(r.string(32767).ok()?.as_str().into())
} else {
let mut range = Vec::new();
for bound in ["min", "max"] {
if r.bool().ok()? {
range.push((bound, Tag::String(r.string(32767).ok()?.as_str().into())));
}
}
Tag::compound(range)
};
state.push((name, matcher));
}
p.push((
"state",
Tag::compound(state.iter().map(|(k, v)| (k.as_str(), v.clone()))),
));
}
// NbtPredicate.STREAM_CODEC: a compound.
if r.bool().ok()? {
p.push(("nbt", r.nbt().ok()?));
}
// DataComponentExactPredicate: typed component values.
if r.count(r.remaining()).ok()? != 0 {
return None;
}
// DataComponentPredicate: a type id and the predicate as NBT.
for _ in 0..r.count(r.remaining()).ok()? {
r.bool().ok()?;
r.varint().ok()?;
r.nbt().ok()?;
}
predicates.push(Tag::compound(p));
}
if predicates.len() == 1 {
predicates.pop()
} else {
Some(Tag::List(predicates))
}
}
/// `SignText.STREAM_CODEC` as `SignText.CODEC` saves it: the four messages,
/// the filtered four when the server sent them, the dye colour and the glow.
fn sign_text_tag(bytes: &[u8]) -> Option<Tag> {
+78
View File
@@ -671,6 +671,84 @@ pub fn unbreakable_line(
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
}
/// `ItemStack.addDetailsToTooltip`'s adventure lines, `can_break` then
/// `can_place_on`: each shown one adds an empty line, its gray header
/// (`AdventureModePredicate.CAN_BREAK_HEADER` / `CAN_PLACE_HEADER`) and
/// `AdventureModePredicate.computeTooltip`: `item.canUse.unknown` in gray when
/// some predicate names no blocks, else every distinct block of the
/// predicates' holder sets by its name (`Block.getName`) in dark gray.
/// `tag_blocks` lists the blocks of a block tag.
pub fn adventure_lines(
stack: &ItemStack,
display: &TooltipDisplay,
tag_blocks: &dyn Fn(&str) -> Vec<String>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
let (gray, dark_gray) = (rgba(170, 170, 170, 255), rgba(85, 85, 85, 255));
let mut lines = Vec::new();
for (component, header) in [
("minecraft:can_break", "item.canBreak"),
("minecraft:can_place_on", "item.canPlace"),
] {
let Some(tag) = stack.data().and_then(|d| d.other(component)) else {
continue;
};
if !display.shows(component) {
continue;
}
lines.push((String::new(), u32::MAX));
lines.push((text(header), gray));
let predicates: Vec<&Tag> = match tag {
Tag::List(list) => list.iter().collect(),
single => vec![single],
};
if predicates.iter().any(|p| p.get("blocks").is_none()) {
lines.push((text("item.canUse.unknown"), gray));
continue;
}
let mut blocks: Vec<String> = Vec::new();
for p in predicates {
let ids = match p.get("blocks") {
Some(Tag::String(entry)) => {
let entry = entry.to_string_lossy();
match entry.strip_prefix('#') {
Some(tag) => tag_blocks(&namespaced(tag)),
None => vec![namespaced(&entry)],
}
}
Some(Tag::List(entries)) => entries
.iter()
.filter_map(|e| match e {
Tag::String(id) => Some(namespaced(&id.to_string_lossy())),
_ => None,
})
.collect(),
_ => Vec::new(),
};
for id in ids {
if !blocks.contains(&id) {
blocks.push(id);
}
}
}
for id in blocks {
lines.push((
text(&format!("block.{}", id.replacen(':', ".", 1))),
dark_gray,
));
}
}
lines
}
/// `OminousBottleAmplifier.addToTooltip`: the effect the bottle gives,
/// `MobEffectInstance(BAD_OMEN, 120000, amplifier)`, listed as
/// `PotionContents.addPotionTooltip` lists it, in the colour of Bad Omen's
+101 -8
View File
@@ -75,6 +75,13 @@ pub struct EntityState {
pub horizontal_collision: bool,
/// `Entity.needsSync`: send its changes this tick, due or not.
pub needs_sync: bool,
/// `Entity.syncPosition`: a living entity bounced this tick; where it is
/// becomes a step of the next move sent (`SteppedInterpolationTracker`).
pub sync_position: bool,
/// `LivingEntity.createInterpolationHandler`: a `SteppedInterpolationTracker`
/// on the server, so its moves are sent as stepped paths. Every other entity
/// has a no-op tracker and sends a linear one.
pub stepped: bool,
/// Outside `DistanceManager.inEntityTickingRange`: `ChunkMap.tick` sends
/// its changes only if it changed section or needs sync (its lead aside,
/// which `Leashable` sends at once).
@@ -119,6 +126,8 @@ impl EntityState {
self.vertical_collision = false;
self.horizontal_collision = false;
self.needs_sync = false;
self.sync_position = false;
self.stepped = false;
self.idle = false;
self.bare = false;
self.leash = None;
@@ -252,6 +261,11 @@ struct ServerEntity {
velocity: [f64; 3],
/// NewMinecartBehavior.lerpSteps accumulated across tracking intervals.
minecart_steps: Vec<play::MinecartStep>,
/// SteppedInterpolationTracker: the positions where it bounced since the
/// last move sent, with the ticks between them, and the ticks since the
/// last one (-1 before the first update).
tracked_steps: Vec<([f64; 3], i32)>,
ticks_since_step: i32,
/// trackedDataValues: the entity data the clients hold.
meta: Vec<(u8, i32, Value)>,
equipment: Vec<(u8, Option<Stack>)>,
@@ -294,6 +308,42 @@ fn encode(v: f64) -> i64 {
horizon_core::jmath::round(v * 4096.0)
}
/// `VecDeltaCodec.tryEncode`: the path as deltas from `base`, a stepped one
/// step by step from the step before; None when a delta is too big for a short.
fn try_encode(base: [f64; 3], path: &PositionPath) -> Option<VecDelta> {
let delta = |from: [f64; 3], to: [f64; 3]| {
let d = [0, 1, 2].map(|k| encode(to[k]) - encode(from[k]));
d.iter()
.all(|v| i16::try_from(*v).is_ok())
.then(|| d.map(|v| v as i16))
};
match path {
PositionPath::Linear(p) => delta(base, *p).map(VecDelta::Linear),
PositionPath::Stepped(steps) => {
let mut from = base;
let mut out = Vec::with_capacity(steps.len());
for (p, ticks) in steps {
out.push((delta(from, *p)?, *ticks));
from = *p;
}
Some(VecDelta::Stepped(out))
}
}
}
/// `VecDelta.hasDeltaX` and `hasDeltaZ` (any step's, for a stepped delta), as
/// the nonzero x and z `full_precision_required` reads.
fn delta_axes(delta: &VecDelta) -> [i64; 3] {
match delta {
VecDelta::Linear(d) => d.map(i64::from),
VecDelta::Stepped(steps) => [
i64::from(steps.iter().any(|(d, _)| d[0] != 0)),
0,
i64::from(steps.iter().any(|(d, _)| d[2] != 0)),
],
}
}
/// `ServerEntity.isFullPrecisionEncodingRequired`: an item whose last move was
/// stopped by a block rests where the collision solver left it, which is not on
/// the 1/4096 grid a delta decodes to; vanilla sends its exact position instead.
@@ -331,6 +381,8 @@ impl ServerEntity {
head: pack_degrees(e.head_yaw),
velocity: e.velocity,
minecart_steps: e.minecart_steps.clone().unwrap_or_default(),
tracked_steps: Vec::new(),
ticks_since_step: -1,
meta: e.meta.clone(),
equipment: e.equipment.clone(),
attributes: e.attributes.clone(),
@@ -404,6 +456,15 @@ impl ServerEntity {
}
}
/// `SteppedInterpolationTracker.addStep`: a step at `position` with the
/// ticks since the last one, if any passed.
fn add_step(&mut self, position: [f64; 3]) {
if self.ticks_since_step > 0 {
self.tracked_steps.push((position, self.ticks_since_step));
self.ticks_since_step = 0;
}
}
/// Changes since the last update (ServerEntity.sendChanges): movement and
/// rotation every update interval (positions at least every 60 ticks, a
/// full position sync every 400 or when the entity lands or leaves the
@@ -421,6 +482,23 @@ impl ServerEntity {
self.passengers.extend_from_slice(passengers);
}
self.send_leash(e, out);
// SteppedInterpolationTracker.updateTracking: a tick more since the
// last step, and a step where the entity bounced (Entity.syncPosition,
// which only living entities' movement sets).
if e.stepped {
self.ticks_since_step += 1;
if e.sync_position {
self.add_step(e.position);
}
}
// ServerEntity.sendChanges: a bounce moves the update clock onto its next
// send tick (UpdateInterval.nextInterval; 0 for NEVER), so it goes out now.
if e.sync_position {
self.tick = self
.tick
.checked_div(self.interval)
.map_or(0, |n| (n + 1) * self.interval);
}
// SynchedEntityData.isDirty: once for every client.
let dirty = e.meta != self.meta;
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
@@ -443,6 +521,9 @@ impl ServerEntity {
)));
self.rotation = rotation;
}
// interpolationTracker.clear: a passenger's steps are never sent.
self.tracked_steps.clear();
self.ticks_since_step = 0;
self.base = e.position;
self.was_riding = true;
self.send_dirty(e, dirty, out);
@@ -462,9 +543,20 @@ impl ServerEntity {
self.teleport_delay += 1;
let d: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
let send_position = self.arrow || d >= 7.629_394_5e-6 || self.tick.is_multiple_of(60);
// SteppedInterpolationTracker.getPositionPath, then clear: the
// current position is the last step of a bounced path.
if e.stepped {
self.add_step(e.position);
}
let path = if self.tracked_steps.is_empty() {
PositionPath::Linear(e.position)
} else {
PositionPath::Stepped(std::mem::take(&mut self.tracked_steps))
};
self.ticks_since_step = 0;
let sync = EntityPositionSync {
id: e.id,
path: PositionPath::Linear(e.position),
path: path.clone(),
yaw: e.yaw,
pitch: e.pitch,
on_ground: e.on_ground,
@@ -480,15 +572,12 @@ impl ServerEntity {
self.was_riding = false;
Some(play::Clientbound::EntityPositionSync(sync))
} else if send_position {
let delta = [0, 1, 2].map(|k| encode(e.position[k]) - encode(self.base[k]));
if delta.iter().any(|v| i16::try_from(*v).is_err())
|| full_precision_required(e, delta)
{
Some(play::Clientbound::EntityPositionSync(sync))
} else {
let delta = try_encode(self.base, &path)
.filter(|d| !full_precision_required(e, delta_axes(d)));
if let Some(delta) = delta {
let m = MoveEntity {
id: e.id,
delta: Some(VecDelta::Linear(delta.map(|v| v as i16))),
delta: Some(delta),
rotation: rotate.then_some((rotation.1, rotation.0)),
on_ground: e.on_ground,
};
@@ -497,6 +586,8 @@ impl ServerEntity {
} else {
play::Clientbound::MoveEntityPos(MoveEntityPos(m))
})
} else {
Some(play::Clientbound::EntityPositionSync(sync))
}
} else if rotate {
Some(play::Clientbound::MoveEntityRot(MoveEntityRot(
@@ -1363,6 +1454,8 @@ mod tests {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
sync_position: false,
stepped: false,
idle: false,
bare: false,
leash: None,
+408
View File
@@ -958,6 +958,294 @@ pub fn stack_to_network(
) -> Option<Stack> {
network_stack(&item_id, &synced_id, s)
}
/// `ProvidesTrimMaterial.STREAM_CODEC`, `ProvidesBannerPatterns.STREAM_CODEC` and
/// `AdventureModePredicate.STREAM_CODEC`: encode supported typed saved forms.
/// A failed child discards this entire local buffer, including its prefix.
fn builtin_component(
name: &str,
value: &horizon_world::nbt::Tag,
ids: &dyn Fn(&str, &str) -> Option<i32>,
) -> Option<horizon_protocol::item::Component> {
use horizon_protocol::buf::Writer;
use horizon_world::nbt::Tag;
let mut w = Writer::new();
match name {
"minecraft:provides_trim_material" => match value {
Tag::String(name) => {
let name = name.to_string().ok()?;
let id = ids(
"minecraft:trim_material",
&horizon_world::datafix::dynamic::namespaced(&name),
)?;
if id < 0 {
return None;
}
w.varint(id.checked_add(1)?);
}
Tag::Compound(map) => {
if map.keys().any(|k| {
!matches!(
k.to_string().ok().as_deref(),
Some("palette_id" | "description")
)
}) {
return None;
}
let Tag::String(palette) = value.get("palette_id")? else {
return None;
};
let description @ Tag::String(_) = value.get("description")? else {
return None;
};
w.varint(0);
w.string(&horizon_world::datafix::dynamic::namespaced(
&palette.to_string().ok()?,
));
w.bytes(&horizon_world::nbt::write_network(description, Default::default()).ok()?);
}
_ => return None,
},
"minecraft:provides_banner_patterns" => {
write_holder_set("minecraft:banner_pattern", value, ids, &mut w)?
}
"minecraft:can_place_on" | "minecraft:can_break" => {
let predicates = match value {
Tag::List(list) if !list.is_empty() => list.as_slice(),
Tag::Compound(_) => std::slice::from_ref(value),
_ => return None,
};
w.varint(i32::try_from(predicates.len()).ok()?);
for predicate in predicates {
write_block_predicate(predicate, ids, &mut w)?;
}
}
_ => return None,
}
Some(horizon_protocol::item::Component::Raw(w.data))
}
/// `ByteBufCodecs.holderSet`: tags or direct registry entries, without item-id remapping.
fn write_holder_set(
registry: &str,
value: &horizon_world::nbt::Tag,
ids: &dyn Fn(&str, &str) -> Option<i32>,
w: &mut horizon_protocol::buf::Writer,
) -> Option<()> {
use horizon_world::nbt::Tag;
if let Tag::String(name) = value {
let name = name.to_string().ok()?;
if let Some(tag) = name.strip_prefix('#') {
if tag.is_empty() {
return None;
}
w.varint(0);
w.string(&horizon_world::datafix::dynamic::namespaced(tag));
return Some(());
}
}
let entries = match value {
Tag::List(list) => list.as_slice(),
Tag::String(_) => std::slice::from_ref(value),
_ => return None,
};
w.varint(i32::try_from(entries.len()).ok()?.checked_add(1)?);
for entry in entries {
let Tag::String(name) = entry else {
return None;
};
let name = name.to_string().ok()?;
if name.starts_with('#') {
return None;
}
let id = ids(
registry,
&horizon_world::datafix::dynamic::namespaced(&name),
)?;
if id < 0 {
return None;
}
w.varint(id);
}
Some(())
}
/// `BlockPredicate.STREAM_CODEC`: every admitted field is a conjunction.
fn write_block_predicate(
value: &horizon_world::nbt::Tag,
ids: &dyn Fn(&str, &str) -> Option<i32>,
w: &mut horizon_protocol::buf::Writer,
) -> Option<()> {
use horizon_world::nbt::Tag;
let Tag::Compound(map) = value else {
return None;
};
if map.keys().any(|k| {
!matches!(
k.to_string().ok().as_deref(),
Some("blocks" | "state" | "nbt" | "components" | "predicates")
)
}) {
return None;
}
w.bool(value.get("blocks").is_some());
if let Some(blocks) = value.get("blocks") {
write_holder_set("minecraft:block", blocks, ids, w)?;
}
w.bool(value.get("state").is_some());
if let Some(Tag::Compound(state)) = value.get("state") {
// StatePropertiesPredicate's codec iterates a Java HashMap. Keep the
// existing map implementation and a private UTF-16 String key adapter.
#[derive(PartialEq, Eq)]
struct Key<'a>(&'a horizon_world::nbt::JavaString);
impl horizon_world::java_hash_map::JavaKey for Key<'_> {
fn java_hash(&self) -> i32 {
self.0
.0
.iter()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(*c as i32))
}
fn comparable_compare(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.0.cmp(other.0))
}
fn tie_break(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp(other.0)
}
}
let mut ordered = horizon_world::java_hash_map::JavaHashMap::default();
for (key, matcher) in state {
ordered.put(Key(key), matcher);
}
w.varint(i32::try_from(state.len()).ok()?);
let mut cursor = ordered.cursor();
while let Some((key, matcher)) = ordered.next_entry(&mut cursor).ok()? {
w.string(&key.0.to_string().ok()?);
match matcher {
Tag::String(exact) => {
w.bool(true);
w.string(&exact.to_string().ok()?);
}
Tag::Compound(range) => {
if range
.keys()
.any(|k| !matches!(k.to_string().ok().as_deref(), Some("min" | "max")))
{
return None;
}
w.bool(false);
for bound in ["min", "max"] {
w.bool(matcher.get(bound).is_some());
if let Some(v) = matcher.get(bound) {
let Tag::String(v) = v else {
return None;
};
w.string(&v.to_string().ok()?);
}
}
}
_ => return None,
}
}
} else if value.get("state").is_some() {
return None;
}
w.bool(value.get("nbt").is_some());
if let Some(nbt) = value.get("nbt") {
let parsed;
let nbt = if let Tag::String(text) = nbt {
let text = text.to_string().ok()?;
let (tag, end) = horizon_world::nbt::parse_snbt(&text).ok()?;
if !text[end..].trim().is_empty() {
return None;
}
parsed = tag;
&parsed
} else {
nbt
};
if !matches!(nbt, Tag::Compound(_)) {
return None;
}
w.bytes(&horizon_world::nbt::write_network(nbt, Default::default()).ok()?);
}
write_matchers(value, ids, w)
}
/// `DataComponentMatchers.STREAM_CODEC`: bounded exact-name and partial custom-data forms.
fn write_matchers(
value: &horizon_world::nbt::Tag,
ids: &dyn Fn(&str, &str) -> Option<i32>,
w: &mut horizon_protocol::buf::Writer,
) -> Option<()> {
use horizon_world::nbt::Tag;
for (field, expected, registry) in [
(
"components",
"minecraft:custom_name",
"minecraft:data_component_type",
),
(
"predicates",
"minecraft:custom_data",
"minecraft:data_component_predicate_type",
),
] {
let Some(matchers) = value.get(field) else {
w.varint(0);
continue;
};
let Tag::Compound(map) = matchers else {
return None;
};
w.varint(i32::try_from(map.len()).ok()?);
for (name, matcher) in map {
let name = horizon_world::datafix::dynamic::namespaced(&name.to_string().ok()?);
if name != expected {
return None;
}
let id = ids(registry, &name)?;
if id < 0 {
return None;
}
if field == "components" {
let Tag::String(_) = matcher else {
return None;
};
w.varint(id);
w.bytes(&horizon_world::nbt::write_network(matcher, Default::default()).ok()?);
} else {
let parsed;
let compound = if let Tag::String(text) = matcher {
let text = text.to_string().ok()?;
let (tag, end) = horizon_world::nbt::parse_snbt(&text).ok()?;
if !text[end..].trim().is_empty() {
return None;
}
parsed = tag;
&parsed
} else {
matcher
};
if !matches!(compound, Tag::Compound(_)) {
return None;
}
// The official partial-matcher stream codec consumes a TAG String
// here, unlike BlockPredicate's COMPOUND_TAG above.
let text = crate::snbt::to_snbt(compound);
w.bool(true);
w.varint(id);
w.bytes(
&horizon_world::nbt::write_network(
&Tag::String(text.as_str().into()),
Default::default(),
)
.ok()?,
);
}
}
}
Some(())
}
fn network_stack(
item_id: &dyn Fn(&str) -> i32,
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
@@ -1010,6 +1298,33 @@ fn network_stack(
horizon_protocol::item::Component::Unit,
));
}
// These built-ins use their own stream codecs, not the custom NBT route.
for (name, kind) in [
(
"minecraft:provides_trim_material",
horizon_protocol::item::component::PROVIDES_TRIM_MATERIAL,
),
(
"minecraft:provides_banner_patterns",
horizon_protocol::item::component::PROVIDES_BANNER_PATTERNS,
),
(
"minecraft:can_place_on",
horizon_protocol::item::component::CAN_PLACE_ON,
),
(
"minecraft:can_break",
horizon_protocol::item::component::CAN_BREAK,
),
] {
if d.other(&format!("!{name}")).is_some() {
stack.removed.push(kind);
} else if let Some(value) = d.other(name)
&& let Some(component) = builtin_component(name, value, synced_id)
{
stack.added.push((kind, component));
}
}
for (name, value) in &d.other {
let (removed, kind) = match name.strip_prefix('!') {
Some(kind) => (true, kind),
@@ -1470,6 +1785,99 @@ mod tests {
use horizon_protocol::item::{Component, component};
use horizon_world::nbt::Tag;
#[test]
fn builtin_predicate_streams_keep_typed_conjunctions_and_reject_whole_unknown_forms() {
let ids = |registry: &str, name: &str| match (registry, name) {
("minecraft:block", "minecraft:chest") => Some(245),
("minecraft:data_component_type", "minecraft:custom_name") => Some(6),
("minecraft:data_component_predicate_type", "minecraft:custom_data") => Some(4),
_ => None,
};
// Original value boundaries independently consumed by the pinned Java codecs.
for (input, expected) in [
(
r#"{blocks:"minecraft:chest",nbt:"{Lock:{}}"}"#,
"010102f50100010a0a00044c6f636b00000000",
),
(
r#"{blocks:"minecraft:chest",components:{"minecraft:custom_name":"Box"}}"#,
"010102f50100000106080003426f7800",
),
(
r#"{blocks:"minecraft:chest",predicates:{"minecraft:custom_data":{a:1}}}"#,
"010102f5010000000101040800057b613a317d",
),
] {
let tag = horizon_world::nbt::parse_snbt(input).unwrap().0;
let encoded = builtin_component("minecraft:can_break", &tag, &ids).unwrap();
let Component::Raw(bytes) = encoded else {
panic!("fresh encoding")
};
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, expected, "{input}");
let mut stack = horizon_protocol::item::Stack::new(1, 1);
stack
.added
.push((component::CAN_BREAK, Component::Raw(bytes)));
let mut w = Writer::new();
horizon_protocol::item::Slot::write(&Some(stack), &mut w);
let mut r = Reader::new(&w.data);
let decoded = horizon_protocol::item::Slot::read(&mut r).unwrap().unwrap();
assert!(decoded.component(component::CAN_BREAK).is_some());
assert_eq!(r.remaining(), 0);
}
for input in [
r#"{blocks:"minecraft:chest",unknown:1}"#,
r#"{blocks:"minecraft:chest",state:{age:{min:"3",unknown:"7"}}}"#,
r#"{blocks:"minecraft:chest",components:{"minecraft:custom_name":"Box","minecraft:damage":1}}"#,
r#"{blocks:"minecraft:chest",predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{}}}"#,
r#"[{blocks:"minecraft:chest"},{blocks:"minecraft:missing"}]"#,
r#"{blocks:"minecraft:chest",nbt:"{a:1} trailing"}"#,
] {
let tag = horizon_world::nbt::parse_snbt(input).unwrap().0;
assert!(
builtin_component("minecraft:can_break", &tag, &ids).is_none(),
"{input}"
);
}
}
#[test]
fn builtin_removal_markers_survive_actual_saved_stack_adoption() {
for (name, kind) in [
(
"minecraft:provides_trim_material",
component::PROVIDES_TRIM_MATERIAL,
),
(
"minecraft:provides_banner_patterns",
component::PROVIDES_BANNER_PATTERNS,
),
("minecraft:can_place_on", component::CAN_PLACE_ON),
("minecraft:can_break", component::CAN_BREAK),
] {
let input = Tag::compound([
("id", Tag::String("minecraft:stone".into())),
("count", Tag::Int(1)),
(
"components",
Tag::Compound(
[(
format!("!{name}").as_str().into(),
Tag::Compound(Default::default()),
)]
.into(),
),
),
]);
let actual = horizon_items::item_nbt::item_from_nbt(&input).unwrap();
assert!(actual.data().unwrap().other(&format!("!{name}")).is_some());
let sent = stack_to_network(|_| 1, |_, _| None, &Some(actual)).unwrap();
assert_eq!(sent.removed, [kind]);
assert!(sent.added.is_empty());
}
}
#[test]
fn lore_lines_reach_the_client() {
let lines = vec![
+31 -10
View File
@@ -378,6 +378,9 @@ pub struct Hub {
/// The names a server mod shows over players' heads
/// (`native_set_name_tag`), by UUID: their list entries' profile name.
pub name_tags: std::collections::HashMap<u128, String>,
/// The name the server's user cache knew a joining player by, by UUID, looked up before the
/// cache learns the new one (PlayerList.placeNewPlayer's `oldName`); taken by the join line.
pub former_names: std::collections::HashMap<u128, String>,
last_clock: i64,
last_rate: f32,
ticking: (f32, bool),
@@ -551,6 +554,26 @@ impl Hub {
}
}
impl Hub {
/// PlayerList.placeNewPlayer's join line: `multiplayer.player.joined`, or
/// `multiplayer.player.joined.renamed` with the cached old name when the
/// player's name changed (ignoring case) since the user cache last saw it.
fn joined_line(&mut self, uuid: u128, name: &str) -> Tag {
let display = self.player_display_name(uuid, name);
match self
.former_names
.remove(&uuid)
.filter(|old| !old.eq_ignore_ascii_case(name))
{
Some(old) => yellow_names(
"multiplayer.player.joined.renamed",
vec![display, string(&old)],
),
None => yellow_names("multiplayer.player.joined", vec![display]),
}
}
}
/// A yellow translation of player names (`multiplayer.player.joined`).
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
Tag::compound([
@@ -747,6 +770,7 @@ impl Hub {
list_names: std::collections::HashMap::new(),
display_names: std::collections::HashMap::new(),
name_tags: std::collections::HashMap::new(),
former_names: std::collections::HashMap::new(),
}
}
@@ -1025,7 +1049,8 @@ impl Hub {
}));
}
/// ListPlayersCommand's reply: the players in join order (the host of a
/// LAN world first), as display names or names with ids, joined by
/// LAN world first), as display names (Player.getDisplayName, with the
/// team's colour and affixes) or names with ids, joined by
/// gray commas (ComponentUtils.formatList).
pub fn list_reply(&self, uuids: bool) -> Tag {
let players: Vec<(u128, &str)> = self
@@ -1047,7 +1072,7 @@ impl Hub {
),
])
} else {
display_name(uuid, name)
self.player_display_name(uuid, name)
}
};
let names = match players.len() {
@@ -1453,10 +1478,7 @@ impl Hub {
// not placed.
if !held {
let joined = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![self.player_display_name(self.guests[i].uuid, &name)],
),
content: self.joined_line(uuid, &name),
overlay: false,
})
.encode();
@@ -1624,10 +1646,7 @@ impl Hub {
};
if announce {
let joined = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![self.player_display_name(uuid, &name)],
),
content: self.joined_line(uuid, &name),
overlay: false,
})
.encode();
@@ -3304,6 +3323,8 @@ impl Hub {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
sync_position: false,
stepped: false,
idle: false,
bare: false,
minecart_steps: None,
+16
View File
@@ -1462,6 +1462,9 @@ pub struct Mob {
/// `Entity.needsSync`: knocked back, pushed or jumped since its server
/// last sent it, so the next update goes out at once.
pub needs_sync: bool,
/// `Entity.syncPosition`: it bounced this tick (`restituteMovementAfterCollisions`),
/// so its tracker keeps this position as a step of the next move it sends.
pub sync_position: bool,
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` with the modifiers of the
/// items it wears or holds, which the game works out each tick for a mob
/// that has any (the mob itself knows no item): `None` for the rest.
@@ -1727,6 +1730,8 @@ pub enum MobEvent {
ChestOpen {
chest: [i32; 3],
open: bool,
/// The golem, the source of the container's game event.
mob: u32,
},
/// `BeeEnterHiveGoal.start`: the bee goes into its hive (the host stores
/// its tag with `BeehiveBlockEntity.addOccupant` and discards it).
@@ -2247,6 +2252,12 @@ pub enum MobEvent {
Buck {
mob: u32,
},
/// `Entity.unRide`'s `ejectPassengers` before a move (a shulker's
/// teleport): whoever rides `mob` gets off where it stood, `from`.
EjectPassengers {
mob: u32,
from: Vec3,
},
/// A piglin's admiring is over: barter or keep the item.
FinishAdmiring {
mob: u32,
@@ -3263,6 +3274,7 @@ impl Mob {
horizontal_collision: false,
vertical_collision_below: false,
needs_sync: false,
sync_position: false,
gear_knockback_resistance: None,
hurt_time: 0,
invulnerable: 0,
@@ -4358,6 +4370,10 @@ impl Mob {
} else {
self.extra.effects.push((effect, ticks, amplifier));
}
// MobEffect.addAttributeModifiers, then refreshDirtyAttributes.
if effect == "minecraft:health_boost" {
finalize::health_boost_changed(self);
}
}
pub fn speed_bonus(&self) -> f64 {
ai::speed_bonus(self)
+23 -9
View File
@@ -725,9 +725,9 @@ pub fn tick(
super::equine::ai_step(m, w, events);
}
// LivingEntity.aiStep ends in the water's hurt with or without AI;
// a NoAI enderman's hurts still teleport it (Enderman.hurtServer).
// a NoAI enderman's or shulker's hurts still teleport it (hurtServer).
hurt_by_water(m, w, events);
enderman_hurt_teleports(m, w, events);
hurt_teleports(m, w, events);
// Ravager.aiStep runs outside serverAiStep: NoAI still advances
// attack/stun/roar, including the stun-to-roar transition.
if m.species() == Species::Ravager {
@@ -1314,8 +1314,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
// invulnerable time of that tick has already dropped.
let was_on_fire = m.fire_ticks > 0;
block_contact_damage(m, fire);
// Enderman.hurtServer teleports inside the hurt (fire, lava, a block).
enderman_hurt_teleports(m, w, events);
// Enderman.hurtServer and Shulker.hurtServer teleport inside the hurt
// (fire, lava, a block, an effect).
hurt_teleports(m, w, events);
if !m.alive() {
m.death_time += 1;
}
@@ -1734,7 +1735,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
// Mannequin.isEffectiveAi: an immovable one never travels.
m.travel(w, input, events);
// A fall's hurt (checkFallDamage) teleports at once.
enderman_hurt_teleports(m, w, events);
hurt_teleports(m, w, events);
}
// LivingEntity.aiStep: applyEffectsFromBlocks follows travel.
m.apply_effects_from_blocks(w, events);
@@ -1756,16 +1757,20 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
fn hurt_by_water(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
enderman_hurt_teleports(m, w, events);
hurt_teleports(m, w, events);
}
}
/// The teleport `Enderman.hurtServer` makes inside a hurt, after the hurt's
/// sound; with or without AI.
fn enderman_hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
/// The teleport `Enderman.hurtServer` or `Shulker.hurtServer` makes inside a
/// hurt, after the hurt's sound; with or without AI.
fn hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::Enderman && m.extra.counter2 > 0 {
events.append(&mut m.outbox);
super::enderman::run_pending_teleports(m, w, events);
}
if m.species() == Species::Shulker && m.extra.flag {
events.append(&mut m.outbox);
super::shulker::hurt_teleport(m, w, events);
}
}
impl Mob {
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
@@ -3107,7 +3112,16 @@ fn tick_effects(m: &mut Mob, mods: Option<&dyn super::ModEffectTicks>) {
m.absorption = (m.absorption - 4.0 * (e.2 + 1) as f32).max(0.0);
}
}
let boost_gone = m
.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:health_boost" && e.1 <= 0 && e.1 != -1);
m.extra.effects.retain(|e| e.1 > 0 || e.1 == -1);
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes.
if boost_gone {
super::finalize::health_boost_changed(m);
}
m.extra
.hidden_effects
.retain(|h| m.extra.effects.iter().any(|e| e.0 == *h));
@@ -475,6 +475,13 @@ impl Goal for TakeBlock {
pos: [x, y, z],
state: cx.w.world.air,
});
// level.gameEvent(BLOCK_DESTROY, pos, Context.of(enderman, blockState)).
cx.events.push(MobEvent::GameEvent {
event: "minecraft:block_destroy",
at: [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5],
source: m.id,
state,
});
m.extra.carried = Some(state);
}
}
@@ -749,6 +749,20 @@ pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) {
pub fn max_health(m: &Mob) -> f32 {
value(m, Attr::MaxHealth) as f32
}
/// `LivingEntity.onAttributeUpdated(MAX_HEALTH)` after a Health Boost
/// change: the cached maximum follows the attribute model again, and Health
/// above it is lowered (nothing is healed). Sulfur cubes and wolves write
/// their maximum straight into the cache (`sulfur_cube::set_size`,
/// `tame::set_tame`), outside the model, so they keep theirs.
pub fn health_boost_changed(m: &mut Mob) {
if matches!(m.species(), Species::SulfurCube | Species::Wolf) {
return;
}
refresh(m, Attr::MaxHealth);
if m.health > m.attrs.max_health {
m.health = m.attrs.max_health;
}
}
// ---- species facts --------------------------------------------------------
+5 -3
View File
@@ -1984,9 +1984,11 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
fn bounce_event(&mut self) -> Result<(), ()> {
Ok(())
}
/// Nothing to mark: the bounce is already the velocity of this tick, and
/// the mob tracker sends the position and velocity it reads.
fn mark_position_for_sync(&mut self) {}
/// `Entity.syncPosition`: the tracker keeps where the mob bounced as a
/// step of its next move (`SteppedInterpolationTracker.updateTracking`).
fn mark_position_for_sync(&mut self) {
self.mob.sync_position = true;
}
fn set_velocity(&mut self, velocity: Vec3) -> Result<(), ()> {
self.mob.velocity = velocity;
Ok(())
+48 -14
View File
@@ -271,9 +271,10 @@ fn pack_diff(pos: [i32; 3], to: [i32; 3]) -> i32 {
| ((to[1] - pos[1] + 8) & 0xFF) << 8
| (to[2] - pos[2] + 8) & 0xFF
}
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks.
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks; never
/// without AI.
fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
if !m.alive() {
if m.no_ai || !m.alive() {
return false;
}
let old = block_pos(m);
@@ -290,6 +291,9 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
);
if t[1] <= w.world.bottom_y()
|| !w.facts.is_air(w.world.block(t[0], t[1], t[2]))
// WorldBorder.isWithinBounds(BlockPos): the block's corner, no margin.
|| w.border
.is_some_and(|b| !b.is_within_bounds(f64::from(t[0]), f64::from(t[2]), 0.0))
|| !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6)))
{
continue;
@@ -305,6 +309,13 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
) {
continue;
}
// unRide: whoever rides it gets off where it stands, and it gets
// off its own vehicle (it lands at the target below).
events.push(MobEvent::EjectPassengers {
mob: m.id,
from: m.position,
});
super::riders::stop_riding(m, None);
m.extra.variant3 = face;
events.push(MobEvent::Sound(
"entity.shulker.teleport",
@@ -528,34 +539,57 @@ pub fn accepts(m: &Mob, source: &Damage) -> bool {
/// chance to teleport away.
pub fn after_hurt(m: &mut Mob) {
if m.health < m.attrs.max_health * 0.5 && m.random.next_int_bound(4).unwrap_or(1) == 0 {
// The teleport needs the world: ask for it on the next tick.
// The teleport needs the world, which `Mob::damage` has not: whoever
// dealt the hurt runs it right after (`hurt_teleport`,
// `run_pending_teleports`).
m.extra.flag = true;
}
}
/// `Shulker.hurtServer` after a shulker bullet's damage: unless the
/// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet`
/// runs on the next tick. Whether the shell is open is read here, at hurt
/// time, so a goal opening it before that tick cannot change the outcome.
/// runs right after the hurt (`run_pending_teleports`). Whether the shell is
/// open is read here, at hurt time.
pub fn hit_by_bullet(m: &mut Mob) {
if m.species() == Species::Shulker && !m.extra.flag && m.extra.counter != 0 {
m.extra.flag2 = true;
}
}
/// The deferred teleport of `after_hurt` and `hitByShulkerBullet`.
/// Whether a hurt left `Shulker.hurtServer` a teleport to make.
pub fn teleport_pending(m: &Mob) -> bool {
m.species() == Species::Shulker && (m.extra.flag || m.extra.flag2)
}
/// `Shulker.hurtServer`'s low-health `teleportSomewhere()`, inside the hurt
/// `after_hurt` drew it for (a hurt within the shulker's own tick).
pub fn hurt_teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::Shulker && m.extra.flag {
m.extra.flag = false;
teleport(m, w, events);
}
}
/// The teleport `Shulker.hurtServer` makes inside a hurt: the low-health
/// `teleportSomewhere()` (`after_hurt`), else a bullet's
/// `hitByShulkerBullet()` (`hit_by_bullet`).
pub fn run_pending_teleports(
m: &mut Mob,
w: &MobWorld,
level_random: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
hurt_teleport(m, w, events);
if m.extra.flag2 {
m.extra.flag2 = false;
hit_by_shulker_bullet(m, w, level_random, events);
}
}
/// What a hurt left that no one ran right after it (a hurt the game layer
/// does not settle at once).
pub fn ai_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
if m.extra.flag {
m.extra.flag = false;
teleport(m, w, events);
}
if m.extra.flag2 {
m.extra.flag2 = false;
hit_by_shulker_bullet(m, w, level_random, events);
}
run_pending_teleports(m, w, level_random, events);
}
/// `Shulker.hitByShulkerBullet` (only queued by an open shulker): it
/// teleports, and leaves a new one of its colour behind unless shulkers
@@ -307,7 +307,7 @@ fn opener_area(w: &MobWorld, p: [i32; 3]) -> Aabb {
/// 3): the opener count changes and the first opener or the last closer
/// plays the chest's sound (`ChestBlockEntity.playSound`, at the middle of
/// a double chest from its right half, none from its left).
fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool) {
fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool, mob: u32) {
let w = cx.w;
let count = w
.world
@@ -347,7 +347,11 @@ fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool) {
let pitch = cx.lr.next_float() * 0.1 + 0.9;
cx.events.push(MobEvent::Sound(sound, at, 0.5, pitch));
}
cx.events.push(MobEvent::ChestOpen { chest: p, open });
cx.events.push(MobEvent::ChestOpen {
chest: p,
open,
mob,
});
}
impl Transport {
@@ -582,7 +586,7 @@ impl Transport {
if self.ticks == 1 {
// container.startOpen, before setOpenedChestPos.
for h in &t.halves {
open_or_close(cx, *h, true);
open_or_close(cx, *h, true, m.id);
}
m.extra.opened_chest = Some(t.pos);
m.extra.variant3 = state;
@@ -597,7 +601,7 @@ impl Transport {
// keeps Container's empty list, so a double chest is left
// to the openers recheck).
if t.halves.len() == 1 && has_open(cx.w, m, t.pos) {
open_or_close(cx, t.pos, false);
open_or_close(cx, t.pos, false, m.id);
}
m.extra.opened_chest = None;
}
+37 -4
View File
@@ -1747,7 +1747,7 @@ impl Screen {
},
Some(50),
) => {
let mut list = servers::load(&servers_file(saves));
let (mut list, mut hidden) = servers::load_all(&servers_file(saves));
let mut entry = ServerEntry::new(name.trim(), address.trim());
entry.accept_textures = *packs;
match index.and_then(|i| list.get_mut(i)) {
@@ -1756,9 +1756,20 @@ impl Screen {
e.ip = entry.ip;
e.accept_textures = entry.accept_textures;
}
None => list.push(entry),
// addServerCallback: ServerList.unhide brings back a hidden entry
// for the address, given the new name and icon
// (copyNameIconFrom); it keeps its other settings.
None => match hidden.iter().position(|h| h.ip == entry.ip) {
Some(h) => {
let mut e = hidden.remove(h);
e.name = entry.name;
e.icon = entry.icon;
list.push(e);
}
None => list.push(entry),
},
}
let _ = servers::save(&servers_file(saves), &list);
let _ = servers::save_all(&servers_file(saves), &list, &hidden);
*self = Self::list(saves);
}
(
@@ -1776,8 +1787,10 @@ impl Screen {
) => *self = Self::list(saves),
(Self::Disconnected { retry, .. }, Some(55)) => *retry = None,
(Self::Direct { address }, Some(52)) => {
let address = direct_address(address);
remember_direct_join(saves, &address);
return Outcome::Connect {
address: direct_address(address),
address,
lan: false,
};
}
@@ -2098,6 +2111,26 @@ impl Screen {
}
}
/// `JoinMultiplayerScreen.directJoinCallback`: an address no entry has, shown
/// or hidden (`ServerList.get`), gets a hidden one named
/// `selectServer.defaultName`, which keeps the server's resource pack and Code
/// of Conduct answers. A friend's invite gets none.
pub fn remember_direct_join(saves: &Path, address: &str) {
if address.starts_with(crate::net::FRIEND) {
return;
}
let file = servers_file(saves);
let _guard = SAVING.lock();
let (shown, hidden) = servers::load_all(&file);
if shown.iter().chain(&hidden).any(|s| s.ip == address) {
return;
}
let name = crate::lang::get_or("selectServer.defaultName", "Minecraft Server");
if let Err(e) = servers::add_hidden(&file, ServerEntry::new(&name, address)) {
log::warn!("could not save {}: {e}", file.display());
}
}
/// Direct Connection's address; a full invite code pasted there joins the
/// friend (a short relay code could be a host name, so it needs Join a Friend).
fn direct_address(address: &str) -> String {
+4
View File
@@ -144,6 +144,10 @@ impl Tracker {
pub fn address(&self) -> Option<&str> {
self.address.as_deref()
}
/// That server was found on the local network.
pub fn lan(&self) -> bool {
self.lan
}
/// A connection to `address` as `name` starts: a new address starts
/// counting anew.
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
+133 -4
View File
@@ -695,7 +695,9 @@ pub fn encode_block_entity(
if let Some(record) = j.record() {
entries.push(("RecordItem", item_to_nbt(record)));
}
entries.push(("ticks_since_song_started", Tag::Long(j.ticks)));
if j.song_clock_present {
entries.push(("ticks_since_song_started", Tag::Long(j.ticks)));
}
}
BlockEntity::Beacon(b) => {
if let Some(p) = &b.primary {
@@ -799,8 +801,10 @@ pub fn encode_block_entity(
}
}
// Encoded above.
BlockEntity::DaylightDetector | BlockEntity::CopperGolemStatue(_) | BlockEntity::Mod(_) => {
}
BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::CopperGolemStatue(_)
| BlockEntity::Mod(_) => {}
// PotentSulfurBlockEntity.saveAdditional.
BlockEntity::PotentSulfur(sulfur) => {
entries.push(("countdown", Tag::Int(sulfur.waiting_countdown)))
@@ -938,13 +942,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
}
if id == "minecraft:jukebox" {
let record = tag.get("RecordItem").and_then(item_from_nbt);
let ticks = number(tag.get("ticks_since_song_started")).unwrap_or(0);
let clock = number(tag.get("ticks_since_song_started"));
let ticks = clock.unwrap_or(0);
// JukeboxBlockEntity.loadAdditional adopts a saved clock separately
// from a record-only load. Catalog-dependent load validation is separate.
let song_clock_present = record.is_some() && clock.is_some();
let playing = record.is_some();
return Some((
pos,
BlockEntity::Jukebox(crate::containers::Jukebox {
items: [record],
ticks,
song_clock_present,
playing,
}),
));
@@ -1124,6 +1133,7 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Mod(_) => {}
// `BlockEntity.loadAdditional`: the components the block entity keeps.
BlockEntity::CopperGolemStatue(components) => {
@@ -8753,3 +8763,122 @@ mod far_census {
}
}
}
#[cfg(test)]
mod jukebox_song_clock_tests {
use super::*;
use crate::containers::{BlockEntity, Jukebox};
fn input(clock: Option<i64>, with_record: bool) -> Tag {
let record = if with_record {
"RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}},"
} else {
""
};
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:4,y:200,z:4,{record}{clock}}}"
))
.unwrap()
.0
}
fn record_kept(tag: &Tag) {
let record = tag.get("RecordItem").expect("actual modeled record");
assert_eq!(
record.get("id"),
Some(&Tag::String("minecraft:music_disc_cat".into()))
);
assert_eq!(record.get("count"), Some(&Tag::Int(1)));
assert_eq!(
record
.get("components")
.and_then(|c| c.get("minecraft:custom_data"))
.and_then(|c| c.get("h032")),
Some(&Tag::Int(1))
);
}
#[test]
fn model_save_preserves_song_clock_presence_without_raw() {
let mut failures = Vec::new();
let mut check = |label: &str, tag: &Tag, expected: Option<i64>| {
let got = tag.get("ticks_since_song_started").cloned();
let want = expected.map(Tag::Long);
if got != want {
failures.push(format!("{label}: {got:?}, expected {want:?}"));
}
};
for (label, clock, with_record) in [
("empty", None, false),
("record-only", None, true),
("loaded-zero", Some(0), true),
("loaded-41", Some(41), true),
] {
let (pos, model) =
decode_block_entity(&input(clock, with_record)).expect("actual load path");
let BlockEntity::Jukebox(j) = &model else {
panic!("wrong modeled type")
};
assert_eq!(j.record().is_some(), with_record);
assert_eq!(j.ticks, clock.unwrap_or(0));
// H032 must preserve the separate legacy record-only adoption policy.
assert_eq!(j.playing, with_record);
let saved = encode_block_entity(pos, &model, "minecraft:jukebox");
let command = block_entity_data(pos, &model, "minecraft:jukebox", None);
check(label, &saved, clock);
check(label, &command, clock);
if with_record {
record_kept(&saved);
record_kept(&command);
} else {
assert!(saved.get("RecordItem").is_none());
}
if clock.is_some() {
let mut detached = model.clone();
let BlockEntity::Jukebox(j) = &mut detached else {
unreachable!()
};
j.playing = false;
// Decoded clock state, not this separate legacy flag, owns saving.
let saved = encode_block_entity(pos, &detached, "minecraft:jukebox");
check("clock independent of legacy playing", &saved, clock);
record_kept(&saved);
}
}
let empty = BlockEntity::Jukebox(Jukebox::default());
check(
"default modeled/no raw",
&encode_block_entity((4, 200, 4), &empty, "minecraft:jukebox"),
None,
);
let raw = input(Some(41), true);
let (pos, loaded_model) = decode_block_entity(&raw).unwrap();
let loaded = encode_block_entity(pos, &loaded_model, "minecraft:jukebox");
let BlockEntity::Jukebox(j) = loaded_model else {
unreachable!()
};
let current = BlockEntity::Jukebox(Jukebox {
items: j.items,
playing: true,
..Default::default()
});
let current = encode_block_entity(pos, &current, "minecraft:jukebox");
check("modeled record-only/no raw", &current, None);
record_kept(&current);
// The chunk writer uses this real overlay after encoding its model.
// A stale input clock must not resurrect the removed modeled field.
let merged = merge_block_entity(&raw, &loaded, &current);
check("contradictory raw clock", &merged, None);
record_kept(&merged);
let canonical = crate::block_entity_nbt::canonical(&merged).unwrap();
check(
"chunk canonical after actual model overlay",
&canonical,
None,
);
record_kept(&canonical);
assert!(
failures.is_empty(),
"modeled song clock presence mismatches: {failures:?}"
);
}
}
+44
View File
@@ -211,6 +211,8 @@ pub struct WorldEntry {
pub file_fixing: bool,
/// `BackupStatus.DOWNGRADE`: a newer version saved the world.
pub downgrade: bool,
/// `LevelSummary.isExperimental`: an experimental feature flag is enabled.
pub experimental: bool,
}
impl WorldEntry {
/// `LevelSummary.isCompatible`: the saved series is this build's.
@@ -249,6 +251,7 @@ impl WorldEntry {
incompatible: None,
file_fixing: false,
downgrade: false,
experimental: false,
}
}
}
@@ -586,12 +589,38 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
incompatible: crate::save::incompatible_version(&path),
file_fixing: crate::save::needs_file_fixes(&path),
downgrade: crate::save::downgraded_from(&path).is_some(),
experimental: experimental_features(data),
})
})
.collect();
worlds.sort_by_key(|w| std::cmp::Reverse(w.modified));
worlds
}
/// `FeatureFlags.isExperimental` of `LevelStorageSource.parseFeatureFlagsFromSummary`:
/// level.dat's `enabled_features` name a known flag other than `minecraft:vanilla`
/// (unknown names are dropped).
fn experimental_features(data: &horizon_world::nbt::Tag) -> bool {
const EXPERIMENTAL: [&str; 3] = [
"minecraft:trade_rebalance",
"minecraft:redstone_experiments",
"minecraft:minecart_improvements",
];
let Some(horizon_world::nbt::Tag::List(flags)) = data.get("enabled_features") else {
return false;
};
flags.iter().any(|flag| {
let horizon_world::nbt::Tag::String(name) = flag else {
return false;
};
let name = name.to_string_lossy();
let id = if name.contains(':') {
name
} else {
format!("minecraft:{name}")
};
EXPERIMENTAL.contains(&id.as_str())
})
}
/// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED),
/// else `gameMode.` + its GameType name, then commands and the version.
fn world_info(world: &WorldEntry) -> crate::text::Text {
@@ -644,6 +673,18 @@ fn world_info(world: &WorldEntry) -> crate::text::Text {
} else {
String::new()
};
// An experimental world says so in yellow (ChatFormatting.YELLOW).
let (mode, commands) = if world.experimental {
let mode = mode
.append(crate::text::Text::literal(format!("{commands}, ")))
.append(crate::text::Text::colored(
lang::get_or("selectWorld.experimental", "Experimental"),
0xFFFF55,
));
(mode, String::new())
} else {
(mode, commands)
};
// A world that asks for a backup shows its version in italics, red when downgrading.
let version = crate::text::Text::literal(world.version.clone());
let version = if !world.should_backup() {
@@ -4121,6 +4162,9 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
Mode::Multiplayer(screen) => match screen.confirm() {
crate::multiplayer::Outcome::Connect { address, lan } => {
if matches!(screen, crate::multiplayer::Screen::Direct { .. }) {
crate::multiplayer::remember_direct_join(&self.saves, &address);
}
(Action::Connect { address, lan }, true)
}
_ => (Action::None, false),
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff. Show more