Compare commits

...
Author SHA1 Message Date
Ludovic Marie 3e06612e08 Merge pull request #11826 from iseaprojects/i18n/es-es-remaining
🤖 Agent: queued. es.json was merged key by key with main (91 translations kept, main's new keys too), so no Spanish string main added is lost.
2026-11-08 03:02:29 +01:00
Ludovic Marie 15c0246614 Merge pull request #12147 from RINNECODER/up2/port-occludes
🤖 Agent: queued after #12145 (its base, queued). Main merged in and the stack rebuilt on #12145's head; verify is green at this head.
2026-11-08 03:02:25 +01:00
Ludovic Marie face534db3 Merge pull request #12132 from Evan-lhm/fix/launcher-blur-zero-sharp
🤖 Agent: queued. A 0 px blur skips the blur pass, so the plates are sharp and the GPU does no work for it.
2026-11-08 03:02:22 +01:00
Ludovic Marie 0a6eca805d Merge pull request #12154 from RINNECODER/up2/pack-comment-settings
🤖 Agent: queued. Comment settings end at their line, as the Iris probes in `read_stage` show it reads them line by line; live code still reads to the `;`.
2026-11-08 02:59:34 +01:00
Ludovic Marie ed0be44325 Merge pull request #12117 from sergue212fbright/fix/sign-text-formatting
🤖 Agent: queued. Bold is drawn as the shifted second pass and italic as the skew vanilla's `BakedGlyph` uses, and after the #11863 merge styled glyphs keep the HDR emission tier; the 102 sign cases pass.
2026-11-08 02:58:24 +01:00
Ludovic Marie 97e46ce1d5 Merge pull request #12050 from TheMrZZ/fix/h140-nautilus-cooldown-memory
🤖 Agent: queued. The Nautilus cooldown memory now exists only while it counts down and is erased at 0, as `Brain.setMemoryWithExpiry` does; its harness runs in parity.
2026-11-08 02:58:20 +01:00
Ludovic Marie 5669d66de3 Merge pull request #11990 from TheMrZZ/fix/h285-adventure-predicate-saved-forms
🤖 Agent: queued. A one-block predicate now saves as the bare id and partial `custom_data` keeps only the keys given, which is how `BlockPredicate`'s codec writes them; the residual corpus documents what is left.
2026-11-08 02:58:17 +01:00
Ludovic Marie f54fba8726 Merge pull request #12123 from NikitaMGrimm/tnt-piston-ground
🤖 Agent: queued. A sideways push leaves `onGround` false, as `PistonMovingBlockEntity.moveCollidedEntities` only sets it for upward pushes; main merged in since my review, no other change.
2026-11-08 02:58:13 +01:00
Ludovic Marie f86b98b203 Merge pull request #12118 from NikitaMGrimm/minecart-drop-motion
🤖 Agent: queued. A destroyed minecart drops its item and contents with `Containers.dropContents`' motions; same items, now in vanilla's spread.
2026-11-08 02:58:10 +01:00
Ludovic Marie 9842e75046 Merge pull request #12107 from martipops/settings/shell-fixes
🤖 Agent: queued. Building the settings rows once removes the per-frame rebuild, and Down from the search field lands in the list.
2026-11-08 02:58:07 +01:00
Ludovic Marie 99956fff22 Merge pull request #12106 from martipops/controller/nintendo-buttons
🤖 Agent: queued. The Nintendo row picks by position or by letter only in the glyph table; the bindings themselves don't move.
2026-11-08 02:58:03 +01:00
Ludovic Marie b23ad347d1 Merge pull request #12105 from NikitaMGrimm/execute-block-nbt
🤖 Agent: queued. `execute if block`, `fill` and `clone` now compare the block entity's NBT part of the predicate, as `BlockPredicateArgument` does.
2026-11-08 02:58:00 +01:00
Ludovic Marie 350e156a34 Merge pull request #12088 from KBroneZ/claude/datapack-jars-launcher
🤖 Agent: queued. Last of the series, after #12087: a profile with only data packs now plays instead of reporting nothing to install.
2026-11-08 02:57:55 +01:00
Ludovic Marie 502c15aba6 Merge pull request #12086 from KBroneZ/claude/new-world-datapacks
🤖 Agent: queued. First of the 12086 → 12087 → 12088 series (each includes the one before): new worlds get the game folder's `new-world-datapacks`, copied at creation only.
2026-11-08 02:55:14 +01:00
Ludovic Marie 147980da63 Merge pull request #12082 from KBroneZ/claude/found-on-this-computer
🤖 Agent: queued. Only reads other launchers' folders (no writes), and the 86 lang files carry the new strings.
2026-11-08 02:55:09 +01:00
Ludovic Marie 97e99a45b4 Merge pull request #12073 from KyttuXD/fix/metal-cutout-leaves-derivatives
🤖 Agent: queued. Metal now always draws vanilla terrain flat, whatever the section count, which is the path that was already used under the threshold.
2026-11-08 02:55:05 +01:00
Ludovic Marie 37cda82c81 Merge pull request #12060 from ZiQu-dev/fix/oit-cull-depth-metal
🤖 Agent: queued. The 4-ulp depth tolerance fixes the speckles on Metal where the OIT pass's depths differ by a few ulps from the opaque pass's.
2026-11-08 02:55:01 +01:00
Ludovic Marie c76ed64570 Merge pull request #12058 from Mik00000/perf/dimension-tick
🤖 Agent: queued. Ending the tick on the first level skips the dimension swap that did nothing, and the ray chunk cache is per raycast, so no stale chunk outlives one ray.
2026-11-08 02:54:58 +01:00
Ludovic Marie f63ad014e1 Merge pull request #12048 from ilguerro/perf/9996-load-palres
🤖 Agent: queued. One cost to know: the registry-wide cache has 8192 slots that are never reclaimed; past that it falls back to decoding, so it only stops helping, it doesn't grow.
2026-11-08 02:54:54 +01:00
Ludovic Marie 7b33d702f5 Merge pull request #12047 from ilguerro/perf/9996-load-packed
🤖 Agent: queued. The SWAR check tests 8 palette indices per word and has an exhaustive test against the scalar loop, so a bad index is still caught at the same position.
2026-11-08 02:54:50 +01:00
Ludovic Marie dfae932fec Merge pull request #12016 from MSedgeMC/fix/drowned-trident-continue
🤖 Agent: queued. The trident attack continues while `alive && (holding a trident || !nav.done)`, which is vanilla's condition rewritten; a drowned that drops its trident now switches to melee.
2026-11-08 02:54:47 +01:00
Ludovic Marie bf3a5f162f Merge pull request #12008 from MSedgeMC/fix/cat-tempt-tamed
🤖 Agent: queued. A tamed cat no longer starts the tempt goal, as `Cat.TemptGoal`'s `canUse` returns false once tamed.
2026-11-08 02:54:43 +01:00
Ludovic Marie 14fc6d55e0 Merge pull request #11985 from TheMrZZ/ci/overlap-note-faster
🤖 Agent: queued. Tools only: one `git cat-file --batch-check` for the heads instead of a process per PR, and the diffs fetched in parallel; same notes out.
2026-11-08 02:54:39 +01:00
Ludovic Marie 4e6a8131c2 Merge pull request #11980 from MSedgeMC/fix/trade-start-conditions
🤖 Agent: queued. `TradeWithPlayerGoal.canUse` also stops on hurt and on distance > 16; it reads `cx.w.player` like the existing LookAtTradingPlayer goal does, so guests on a server share that approximation.
2026-11-08 02:54:36 +01:00
Ludovic Marie 01451da341 Merge pull request #11977 from MSedgeMC/fix/ghast-still-float
🤖 Agent: queued. The happy ghast's still timeout now ends the float goal as in vanilla, so a parked ghast stays put instead of drifting.
2026-11-08 02:54:32 +01:00
Ludovic Marie 179ebb840a Merge pull request #11965 from MSedgeMC/fix/cube-jump-passenger
🤖 Agent: queued. A ridden cube mob now skips its jump-move as vanilla's `SlimeMoveControl` does while it is a passenger, so a slime on a boat no longer hops off its seat.
2026-11-08 02:54:28 +01:00
Ludovic Marie fca63b5eb8 Merge pull request #11868 from MrCloudy2/fix/rider-teleport-look
🤖 Agent: queued. Matches `ClientPacketListener.handleMovePlayer`: a passenger keeps its seat when the server's position packet comes, instead of snapping out of the vehicle for a tick.
2026-11-08 02:54:25 +01:00
Horizon agentandClaude Opus 5.5 85d56215e6 Merge main into #11826
es.json: main's file with this PR's 91 translations applied key by key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:28:45 +01:00
Ludovic Marie b13dbf0687 Merge pull request #11837 from yahya-nusseibeh/fix/control-socket-path
🤖 Agent: queued. When `/tmp` is shared the socket goes into a private `horizon-<uid>` folder, so another user on the machine can't connect to or squat on the control socket.
2026-11-08 02:20:33 +01:00
Ludovic Marie 0cdb230b54 Merge pull request #11815 from ilguerro/fix/thorns-rest
🤖 Agent: queued at a3fd3597f (main merged in; the draw now uses `random_source::entity()`). Thorns rolls in EquipmentSlot order from the wearer's own random, as `EnchantmentHelper.doPostAttackEffects` does.
2026-11-08 02:20:29 +01:00
Ludovic Marie 6e919e4356 Merge pull request #11806 from MrCloudy2/fix/shield-disable-cooldown
🤖 Agent: queued. The defender now gets the Cooldown packet the server sets on the shield (`Player.disableShield`), so its client greys the shield out for the 100 ticks instead of letting it block client-side.
2026-11-08 02:20:26 +01:00
Ludovic Marie 92cf9ac888 Merge pull request #11799 from Detimes/fix-old-attribute-names
🤖 Agent: queued at 14bfb672f (main and #11750 merged in). UPDATE/REMOVE_MOB_EFFECT ids are renumbered for older clients; effects they don't know are dropped instead of showing as another effect.
2026-11-08 02:20:22 +01:00
Ludovic Marie 0961939b71 Merge pull request #11791 from MrCloudy2/fix/spear-stab-players
🤖 Agent: queued. One cost to keep in mind: `stab_players_for` builds a Vec for every guest using any item each tick (shields, food too); small, but a follow-up could return early when the item is not a spear.
2026-11-08 02:19:16 +01:00
Ludovic Marie 2cd05e462e Merge pull request #11789 from MSedgeMC/fix/camel-sit-room
🤖 Agent: queued. Same check as `Camel.canCamelChangePose` (the standing box against the blocks above), and the test uses a block at y+2 that blocks only the 2.375-tall standing pose.
2026-11-08 02:19:11 +01:00
Ludovic Marie 541f331f1e Merge pull request #11762 from Mayonesu0/perf/launcher-write-data-file
🤖 Agent: queued. The data file is written off the UI thread after `complete_assets`, and a stale one is dropped when the assets change, so a repaired install doesn't read an old index.
2026-11-08 02:19:07 +01:00
Ludovic Marie 973287971d Merge pull request #11759 from doodlyyousuf/251107-fix-guest-left-mob-xp
🤖 Agent: queued. Matches `LivingEntity.dropExperience`/`dropCustomDeathLoot`: experience and equipment follow `lastHurtByPlayerMemoryTime > 0`, while LAST_DAMAGE_PLAYER still needs the player to be resolvable, and the new test checks both sides.
2026-11-08 02:19:04 +01:00
Ludovic Marie 7648d874b9 Merge pull request #11750 from Detimes/fix-11722-old-pick-item
🤖 Agent: queued at fee1085be (main merged in). A 1.21.3 pick_item now becomes the same container swap a 1.21.4+ client sends, so older clients keep middle-click in survival.
2026-11-08 02:19:00 +01:00
Ludovic Marie b91f5f5892 Merge pull request #11741 from yahya-nusseibeh/fix/server-undrained-packets
🤖 Agent: queued. The count goes up at the one `NetEvent::Packet` send in `conn.rs` and down at the hub's drain, so the 2× inbox cap bounds what a flooding client can queue between ticks without touching the hot path otherwise.
2026-11-08 02:18:57 +01:00
Ludovic Marie f159a106a8 Merge pull request #11713 from Akirabane/feat/transfer-stop-wait
🤖 Agent: queued. The stop waits only with `[transfer]` on, and its 90 s bound stays under the supervisor's 120 s kill, so a proxy that never answers can't hang a stop. Built with 14 other PRs on main (pr_ready --quick green apart from known NixOS-only tests).
2026-11-08 02:18:54 +01:00
Ludovic Marie c805ee70c5 Merge pull request #11782 from Teiwazik/render/native-section-fade
🤖 Agent: I moved the distance blend per pixel (it was per triangle through the flat hdr_code). Before/after screenshots and frame time are in my comment: the pack's GPU frame is 121.1 vs 122.0 ms on lavapipe, same within noise.
2026-11-08 02:18:50 +01:00
Horizon agent 14bfb672f8 Merge #11750 into #11799 (both add the last tests of client_session.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-protocol/src/adapter/client_session.rs
2026-11-08 02:14:29 +01:00
Horizon agentandClaude Opus 5.5 43832ec794 Merge #12145 into #12147 (both change the map_color line)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:58:49 +01:00
Ludovic Marie bc6016d19d Merge pull request #12157 from horizon-rust/agent/older-servers-8718
🤖 Agent: Follow-up to #8718. On main, lan_bot's older_clients_of_each_release_join_and_are_sent_chunks fails at 1.21.1 (the reverse step never adds 1.21.2's particle status to ClientInformation); CI misses it because the test returns early without prepared resources. With this it passes, as do all 440 horizon-protocol tests and, on the union, the particle harnesses block/emitter/entity/marker/native_client_particles, particle_providers, player_effect_amplifier and spell_ticks (8/8).
2026-11-08 01:55:30 +01:00
Ludovic Marie 40e3f368da Merge pull request #11720 from Teiwazik/seat/follow-plugin-seat-chains
🤖 Agent: I pushed 9bc110bae. The new second position_riders() call after the player moves rebuilt the whole mob index on every tick for every client; it now runs only when the riding graph has something on this player. I also added the missing passenger_vehicles field to main's studio Remote initializer (it did not compile on main) and drop passengers whose vehicle is removed. Both ignored tests pass with prepared resources, and so do the riding harnesses on a union with current main: boat_authority, boat_rider, boat_paddle_reset, mob_rider_navigation, mount_submerged, server_entities, vehicle_command_damage, dedicated_server (8/8).
2026-11-08 01:42:48 +01:00
Ludovic Marie b399f0ddc5 Merge pull request #11775 from Teiwazik/older-servers/sign-update-order
🤖 Agent: Checked against the 26.3 client jar: ServerboundSignUpdatePacket's codec is pos, fixedSizeList(4) of stringUtf8(384), then SignTextSlot as an idMapper VarInt (BACK=0, FRONT=1), so reading the side last and writing it as the old boolean after the position is right. It only runs in the older-server adapter, so 26.3 servers are untouched. I merged main (the #8718 conflict in translate.rs: kept #8718's arms plus this one); 440 horizon-protocol tests and clippy pass.
2026-11-08 01:18:50 +01:00
Horizon agentandClaude Opus 5.5 a3fd3597f4 Thorns: draw from random_source::entity(), which replaced dispenser::entity_random on main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:16:10 +01:00
Horizon agent 1d9f455dac Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 01:15:53 +01:00
Horizon agent f97dffe988 Merge remote-tracking branch 'origin/main' into pr-11775
# Conflicts:
#	crates/horizon-protocol/src/adapter/translate.rs
2026-11-08 00:15:30 +00:00
Horizon agentandClaude Opus 5.5 fee1085bec Merge main into fix-11722-old-pick-item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:02:24 +01:00
Horizon agentandClaude Opus 5.5 272518c1f2 Merge main into fix-old-attribute-names
Main already carries the 1.21.1 and 1.20.6 attribute tables (consolidated in
d79a36101); kept main's generated tables and this branch's mob effect renumbering.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:02:00 +01:00
Nikita Maximilian Grimm b95e34a990 Merge remote-tracking branch 'origin/main' into tnt-piston-ground
# Conflicts:
#	crates/horizon-client/src/game/piston_push.rs
2026-11-08 00:46:47 +01:00
GlockSmithandOpenAI Codex GPT-6 e86ce41888 Merge main and retain HDR emission on styled sign text
Resolve the overlap with #11863 in the styled span and glyph loops. Keep its emission tier on glyphs, outlines and decoration quads, with regression coverage.

Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-11-07 18:42:38 -05:00
Horizon agentandClaude Opus 5.5 458f0e4134 Native shader packs: blend the section fade by distance per pixel
The fade-in's 16-to-32-block distance blend was computed per vertex and
carried in the flat hdr_code, so it was one value per triangle (the
provoking vertex's) and near faces faded in steps. The flat bits now hold
only the section's own visibility (the same for the whole section), and
horizon_material_faded blends it by the interpolated spherical distance,
as terrain.wgsl's interpolated visibility does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:40:34 +00:00
Kacper 29b3374b5d Merge pull request #12098 from NikitaMGrimm/boat-drop-motion
A destroyed boat drops its item with vanilla's drop motion
2026-11-08 00:40:24 +01:00
Kacper 02042bf6b4 Merge pull request #12097 from NikitaMGrimm/boat-fire-timer
A boat burns: its fire timer counts down, fire hurts it and water puts it out, as in vanilla
2026-11-08 00:40:18 +01:00
Kacper 79e277451b Merge pull request #12095 from Web-mc1/fix/telemetry-scrub-word-boundaries
telemetry: match the user name exactly when lowercasing moves boundaries
2026-11-08 00:40:10 +01:00
Kacper e5c1ea0612 Merge pull request #12092 from Web-mc1/fix/java-properties-save-convert
java_properties: write a NUL and a non-BMP character as Java does
2026-11-08 00:40:03 +01:00
Kacper 2f39e246fe Merge pull request #12084 from KBroneZ/claude/windows-unused-mut
Fix Windows-only unused_mut on the TCP keepalive builders
2026-11-08 00:39:56 +01:00
Kacper 2cd9e12ecd Merge pull request #12078 from KBroneZ/claude/oracle-windows
Run tools/neoforge_oracle.py on Windows
2026-11-08 00:39:43 +01:00
Kacper 6d32a88a50 Merge pull request #12069 from senjinthedragon/horizon-uniforms-lights-tier
Shader packs: Horizon's dynamic lights as uniforms, horizonTier for terrain and entities
2026-11-08 00:39:37 +01:00
Kacper 124e88e731 Merge pull request #12068 from NikitaMGrimm/fix/diode-moving-piston-support
A repeater or comparator on a retracting piston stays, as in vanilla
2026-11-08 00:39:30 +01:00
Kacper 1a004614f8 Merge pull request #12056 from TheMrZZ/fix/h255-mob-piston-ground
Project ground after unoccupied server NoAI Mob piston movement
2026-11-08 00:39:23 +01:00
Kacper 1edf540479 Merge pull request #12054 from TheMrZZ/fix/h234-machine-recipe-placement
Route recipe-book placement into actual furnace menus
2026-11-08 00:39:16 +01:00
Kacper 9bccc1056f Merge pull request #12053 from TheMrZZ/fix/h245-sulfur-bucket
Pick up a sulfur cube with an empty bucket
2026-11-08 00:39:09 +01:00
Kacper d144af4a11 Merge pull request #12049 from ilguerro/perf/9996-load-loadlight
Load saved light directly from its layers instead of expanding and repacking
2026-11-08 00:39:03 +01:00
Kacper 616707f3d7 Merge pull request #12033 from Vaspyyy/feat/native-muffle-sounds
Native mod API: muffle the game's sounds for a while, as ears ringing after a blast (native_muffle_sounds)
2026-11-08 00:38:56 +01:00
Kacper 1c5744ac24 Merge pull request #12014 from Vaspyyy/fix/native-mod-image-slot
Native mods: a game built with horizon-mixins gets the mod image slot, native-mods.json or not
2026-11-08 00:38:49 +01:00
Kacper c124718cbc Merge pull request #12002 from MrCloudy2/fix/sprint-hit-slowdown
lan_e2e: sprint-hit-slowdown runs up instead of teleporting, and catches a missing slowdown
2026-11-08 00:38:43 +01:00
Kacper 0b8f18dd40 Merge pull request #11992 from TheMrZZ/fix/h275-test-defs-world-packs
Load a world's test instances from its data packs when it opens
2026-11-08 00:38:36 +01:00
Kacper e51c277db7 Merge pull request #11987 from NikitaMGrimm/hdr-hud-composite
HDR float frame: the HUD and menus lay over the world's light
2026-11-08 00:38:29 +01:00
Kacper fc61e4e9ff Merge pull request #11986 from NikitaMGrimm/hdr-entity-marks
HDR: mobs get HDR lighting and held light sources shine again
2026-11-08 00:38:23 +01:00
Kacper 2c300290d5 Merge pull request #11983 from TheMrZZ/fix/h207-effective-equippable
Honor supported pumpkin Equippable overrides on item use
2026-11-08 00:38:10 +01:00
Kacper 16260920c3 Merge pull request #11969 from Mik00000/perf/mob-ai-stress-bench
mobs: reuse pathfinding allocation buffers and add stress_mob_ai benchmark
2026-11-08 00:38:03 +01:00
Kacper d84428c781 Merge pull request #11968 from TheMrZZ/fix/h237-clone-block-ticks
Copy a clone's pending block ticks to its destination
2026-11-08 00:37:57 +01:00
Kacper e5f23f8b40 Merge pull request #11958 from TheMrZZ/fix/h095-allay-liked-player-ttl
Keep an Allay's liked_player memory time to live and expire it
2026-11-08 00:37:50 +01:00
Kacper 17e4124d04 Merge pull request #11956 from Stink-O/native/hold-world
Native mod API: hold the terrain's picture while the world changes (`native_world_hold`)
2026-11-08 00:37:43 +01:00
Horizon agentandClaude Opus 5.5 37eafb6c1b Older clients: a 1.21.1 client's information gains 1.21.2's particle status
The reverse step for 1.21.1 clients passed ClientInformation through, so a
host read past its end. The older-server adapter now writes 1.21.1's real
layout (no particle status), which made lan_bot's
older_clients_of_each_release_join_and_are_sent_chunks fail at 1.21.1;
real 1.21.1 clients were refused the same way. The step appends
ParticleStatus.ALL in configuration and play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:37:41 +00:00
RINNECODERandClaude Opus 5.5 c56e9312ee Port pack: a mod's own rarity or map colour stays the template's, not a refused mod
NeoForge lets mods add item rarities (Immersive Engineering's masterwork) and some blocks carry a
map colour id past 26.3's 61 (Mekanism's gas blocks: 63). The game refuses the whole content
root for either, so one such file left every block of the mod out. They now keep the template's
and the report names them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:28:23 -06:00
RINNECODERandClaude Opus 5.5 9f52bf9a83 Port pack: a block that can't occlude (noOcclusion) carries occludes=false
The oracle reports can_occlude per state, but the carry ignored it: a
full-cube block made with noOcclusion() (one its block entity draws) took
its template's occlusion and hid the faces behind it, leaving a hole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:26:03 -06:00
RINNECODERandClaude Opus 5.5 cf0b0d02c8 Shader packs: a setting in a comment may end at its line
Packs list their const settings in a block comment one per line with no
';' (`const vec4 colortex6ClearColor = vec4(0.0, 0.0, 0.0, 1.0)`). The
reader looked for the next ';' and took every following line as the value,
so each such setting was refused as 'not a vec4' (Zephyr Starlight logged
85 multi-line notes) and its clear colour was lost. In a comment a
declaration now ends at the first ';' or line end; live code still reads to
the ';'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:49 -06:00
Evan-lhmandClaude Opus 5.5 1b93764780 Launcher: a 0 px blur leaves the glass plates sharp (#12062)
With the blur at 0 the plates still read their backdrop bilinearly between
texel centres, and a texel is 3 to 6 logical pixels of pixel art, so the
scene behind the glass stayed slightly soft. With no blur, read the texel
under the point instead.

Fixes #12062

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:18:19 +01:00
Horizon agentandClaude Opus 5.5 52aad2205d Older servers: keep the particle markers inside the adapter's connections
The render crate read a trail duration of -1 as the pre-1.21.4 marker, and
the client read particle ids -2/-3 as the old entity-effect providers, for
any server: a 26.3 server sending those values got the legacy behaviour.
The client now decides from the connection's release (received_particle)
and passes an explicit legacy_trail_lifetime option; a current server's
-1 stays a lifetime of -1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:18:13 +00:00
Horizon agentandClaude Opus 5.5 9bc110bae6 Riding: skip the extra rider pass when nothing rides the player
The second position_riders() call after the player moves rebuilt the mob
index every tick for every client; run it only when the riding graph has
something on this player. Also forget passengers whose vehicle is removed,
and add the new field to main's second (studio) Remote initializer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:08:57 +00:00
Nikita Maximilian Grimm 83535ddcc1 Merge remote-tracking branch 'origin/main' into minecart-drop-motion 2026-11-07 23:58:46 +01:00
Nikita Maximilian Grimm 2c672eae2d Merge remote-tracking branch 'origin/main' into tnt-piston-ground 2026-11-07 23:58:26 +01:00
sergue212fbrightandOpenAI Codex GPT-6 796047f1e8 Render styled sign text and animate obfuscated glyphs
Follow vanilla 26.3 sign layout and baked glyph geometry, preserving styles through clipping and rebuilding animated sign meshes. Extend the block-entity oracle with styled components and glyph vertices.

Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-11-07 17:53:48 -05:00
Nikita Maximilian Grimm 2dbcb75cfa Merge remote-tracking branch 'origin/main' into execute-block-nbt 2026-11-07 23:41:15 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 34f528ffcb Drop a destroyed minecart's item and contents with vanilla's motions
VehicleEntity.destroy spawns the cart item through spawnAtLocation, with an
ItemEntity's own motion (0.2 up, random sideways). A container cart's contents
go through Containers.dropContents: a spot in the cart's block, pieces of 10 to
30 with the level random's scatter and no pickup delay, every slot drawing its
spot, and chestVehicleDestroyed drops the emptied container again. Fixes #12099.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:40:28 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 7f6f26a33a A shulker lid's push sets the pushed entity's on-ground flag the same way
ShulkerBoxBlockEntity.moveCollidedEntities pushes through Entity.move too,
so it ends in the same setOnGroundWithMovement as a piston's push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:30:46 +01:00
Nikita Maximilian Grimm 3d8924dcc8 Merge remote-tracking branch 'origin/main' into tnt-piston-ground 2026-11-07 23:15:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8ad82f9ac0 A piston push sets an item's or primed TNT's on-ground flag as vanilla does
Entity.move(MoverType.PISTON) ends in setOnGroundWithMovement on a server,
for every push: a sideways push clears the flag, and so does a free push
down. The ArmorStand-only repair in move_pushed becomes a trait method that
items, primed TNT and armor stands share.

Fixes #12029

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:15:41 +01:00
Web-mc1 77290ac3c1 telemetry: match the user name exactly when lowercasing moves boundaries
`replace_word` lowercased the text to find the user's name in any case, and
used that copy whenever it had the same byte length as the text. Equal
lengths don't imply equal boundaries: `İ` (U+0130) grows from 2 bytes to 3
and `K` (U+212A) shrinks from 3 to 1, so a text holding both keeps its
length while its offsets move. A match then sliced the original text
mid-character and panicked.

Use the lowercased copy only when the two share their character boundaries.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-08 11:12:29 +13:00
Nikita Maximilian Grimm daa43de927 Merge remote-tracking branch 'origin/main' into boat-drop-motion 2026-11-07 23:10:52 +01:00
Nikita Maximilian Grimm 3fb98b187f Merge remote-tracking branch 'origin/main' into boat-fire-timer 2026-11-07 23:10:51 +01:00
martipopsandClaude Opus 5.5 76db917ded Controller settings: a Nintendo Buttons row (By Position / By Letter)
The Device page gets a row beside Button Glyphs that toggles
PadSettings::nintendo_letters, so players no longer edit
horizon-controller.txt to make A confirm and jump on a Nintendo pad.
Its description says what A and B do in each mode. Always shown, like
the DualSense rows. docs/controllers.md points at the row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:09:01 -05:00
Web-mc1 37b79d1b63 java_properties: write a NUL and a non-BMP character as Java does
saveConvert pushed one UTF-16 unit at a time and marked a surrogate half with
a NUL sentinel, then ran fix_surrogates over the whole value, which split every
NUL in the result. A \u0000 that is part of a value (legal in a .properties
file) was therefore taken for a marker: "k=\u0000\uD83D\uDE00x" came back from
store with the NUL and both halves of the pair replaced, and since
server.properties is rewritten on every start the value could not survive a
round trip. Walk chars() instead: a Rust str's UTF-16 units always pair up, so
Java's per-char loop needs no marker, and a character past U+FFFF is still
escaped as the two units Java escapes it as.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-08 10:44:43 +13:00
KyttuXDandClaude Haiku 5.5 702b0895bd Draw vanilla terrain flat on Metal at any section count
On Metal, vanilla terrain drew flat only past 1600 listed sections. Cave
culling can drop the listed count below that on a normal view, and the
per-section path then drops cutout leaves on Intel Metal: the canopy above
trunks vanished. Flat draws keep them, so Metal now always draws flat.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-07 16:41:30 -05:00
martipopsandClaude Opus 5.5 4465238c9c Settings shell: build rows once, Down from search to the list, reveal clipped rows
Part of #11487.

- The shell keeps the rows it built and builds them again only when what
  they read changes: the screen's stamp of its settings, a press or slide,
  the search, the page, the size or the language. On Extras, a pad session
  of 727 frames built them twice instead of 751 times.
- On a screen with no page list, Down from the search field goes to the
  first row instead of Done, for the shell's keys and, through a declared
  focus link, for the pad and the keyboard's focus.
- A row the list hides part of scrolls fully into view when the pad or the
  keyboard focuses or presses it (AbstractSelectionList.setSelected), so A
  presses it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:35:56 -05:00
Nikita Maximilian Grimm 43943df1ef Merge remote-tracking branch 'origin/main' into boat-drop-motion 2026-11-07 22:29:00 +01:00
KBroneZandClaude Opus 5.5 8906102eb9 chore(launcher): add the found list's string before each table's last entry
Inserting "Found on this computer" before the last entry of each language
table, instead of after it, adds one line per file and leaves the last line
alone, so other pull requests that append strings don't conflict with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:23:19 +02:00
KBroneZandClaude Sonnet 5.5 0a51d6dc5c fix: shadow the TCP keepalive instead of mutating it, so Windows builds are lint-clean
`let mut keepalive` was only assigned under
`#[cfg(not(any(target_os = "windows", target_os = "openbsd")))]`, so on
Windows and OpenBSD clippy reported `unused_mut` and `-D warnings` failed.
Shadow the binding under the same cfg instead; behaviour is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:22:22 +02:00
KBroneZandClaude Opus 5.5 21ca2fdd1d refactor(launcher): drop the found list's caption; check slugs against import
The "Found on this computer" heading says enough on its own, and the caption
cost a string in every language table. The test now checks that each found
instance's slug is the one importing it gives, so the Open button keeps
pointing at the pack an import made.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:15:51 +02:00
Nikita Maximilian Grimm 89a515a8d7 Merge remote-tracking branch 'origin/main' into boat-fire-timer 2026-11-07 22:14:37 +01:00
KBroneZandClaude Sonnet 5.5 a0f9368edf feat: the launcher plays a profile that only has data packs
Imported.datapacks counts the packs in a profile's home/new-world-datapacks,
`can_play` and `apply_profile` accept a profile with only data packs, and
`outcome` and `modpack list` count them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:13:52 +02:00
KBroneZandClaude Sonnet 5.5 1f86e14b9a feat: port data-only mod jars as data packs for a modpack's new worlds
A mod jar with no classes, a pack.mcmeta and a data/ folder is a data pack.
For a 26.2 or 26.3 modpack, `port` now upgrades such jars to 26.3 (the
upgrade-datapack code; 26.3 ones are copied) into the profile's
home/new-world-datapacks/ instead of carrying them as content mods, which
dropped their data/minecraft overrides. The game copies that folder into
each new world it creates.

Before its jar is read, a mod whose Modrinth project also ships as a data
pack counts as data tier, so `fetch` gets the jar; the jar then decides.
The coverage report lists such a jar as partial.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:13:51 +02:00
SenjinandClaude Opus 5.5 5ec521bb7b Shader packs: Horizon's dynamic lights as uniforms, horizonTier for terrain and entities
horizonDynamicLightCount and horizonDynamicLight0..15 give a pack Horizon
Extras' dynamic lights, nearest first (camera-relative position, light
level). horizonTier gives terrain, entity and hand programs a face's HDR
tier: 0 none, 1 to 4 low to very bright, plus 4 on terrain when the whole
face shines. Opt-in: a pack that declares none of them sees no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:13:03 +01:00
Nikita Maximilian Grimm 80bbced3fc Merge remote-tracking branch 'origin/main' into fix/diode-moving-piston-support 2026-11-07 22:12:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c30b863128 Let water a boat drops into along its move put its fire out
Entity.collidedWithFluid sweeps the box along the whole move, so water reaches
the boat down to the lower of its start and end bottoms in every step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:12:17 +01:00
KBroneZandClaude Opus 5.5 690d434afa feat(launcher): list other launchers' modpacks on the Modpacks page
The Modpacks page now opens with "Found on this computer": the modpack
instances the CurseForge app, Prism Launcher, MultiMC, the Modrinth App,
ATLauncher and the official launcher keep in their usual folders, read with
`other_launchers::scan` on a thread. Each row shows the app, Minecraft
version and loader, and imports the instance with one click through the
same path as "Import an instance folder…" (a Prism instance from its own
folder, so its `mmc-pack.json` still names the version). A pack already
imported opens instead.

Only instances with a `mods` folder of their own are listed: the official
launcher's profiles that play in its own folder are the player's everyday
game, not a pack. The two new strings are in every language table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:10:54 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 f57811dd0d Drop a destroyed boat's item with an item entity's own motion
VehicleEntity.destroy spawns the boat item through spawnAtLocation, so it
leaves with ItemEntity's default motion (0.2 up, random sideways), not
straight up at 0.1. Chest contents keep their own scatter. Fixes #12026.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:05:45 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 73e054f827 Apply a boat's fire block effects in the order its move meets the blocks
Entity.checkInsideBlocks meets the blocks of the box a move starts in before
the ones it reaches, each once, and each step lights before it puts out: a
boat that leaves water for fire in one move ends up burning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:01:24 +01:00
Mik00000 e1434f2ee4 perf(game): reduce dimension swap overhead and cache raycast chunk lookups 2026-11-07 22:58:20 +02:00
Florian ERNSTandCodex 67a39ecaac Project ground after unoccupied server NoAI Mob piston movement
Co-Authored-By: Codex <[email protected]>
2026-11-07 21:56:55 +01:00
KBroneZandClaude Sonnet 5.5 199d9d74f8 fix: run tools/neoforge_oracle.py on Windows
Windows users cannot create symlinks without a privilege (WinError 1314), and NeoForge's
unix_args.txt gives "Illegal char <:>" there. Link the server's libraries folder with a
junction and the mod jars with a hard link, copying when that fails; use win_args.txt on
Windows. Linux and macOS keep the symlinks and unix_args.txt. Adds tests/test_neoforge_oracle.py.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:55:49 +02:00
TheMrZZandCodex 1005076a59 Route recipe-book placement through actual furnace menus
Reuse the existing machine menu cache, shared placement and authoritative
slot writeback for ordinary PlaceRecipe packets. Preserve inventory/table
placement and the separate Horizon recipe-transfer route.

Fixes #12009

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-07 21:49:12 +01:00
Nikita Maximilian Grimm 8189734106 Merge remote-tracking branch 'origin/main' into fix/diode-moving-piston-support 2026-11-07 21:46:30 +01:00
KBroneZandClaude Sonnet 5.5 1f29c88a49 feat: copy a game folder's new-world-datapacks into every new world
A game folder can hold a `new-world-datapacks` folder. Create New World now
seeds its temporary data pack folder from it and selects those packs, and a
world opened without the Create screen (`--world`, the control socket) gets
a copy when it has no level.dat yet. Each world keeps its own copy. Without
the folder nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:46:25 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 bf0f0dd811 Wear off a driven boat's damage before it burns, and let water it left put it out
AbstractBoat.tick decays the damage before Entity.baseTick burns the boat, also
for a boat its rider's client moves, and the block effects follow the whole
move, so water the boat started in still puts it out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:44:44 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 007458a627 A repeater or comparator on a retracting piston stays, as in vanilla
DiodeBlock.canSurviveOn asks isFaceSturdy(level, pos, UP, RIGID) of the
block below, in canSurvive and in updateShape with the neighbour's new
state. A retracting piston base is a moving_piston whose block entity
shape holds the extended base, so its top face is rigid. The diode checks
read the raw state's exported facts instead, saw no support, and broke the
repeater or comparator. Both paths now go through the contextual rigid
support, which takes the state a shape update hands over.

The redstone catalog harness gains fixed lever/piston/diode contraptions
for both piston and both diode kinds (#12019).

Fixes #12019

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:29:19 +01:00
Nikita Maximilian Grimm 9c30028167 Merge remote-tracking branch 'origin/main' into execute-block-nbt 2026-11-07 21:28:43 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0d49b282c0 Read a block predicate's NBT with the argument's own SNBT grammar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:28:40 +01:00
Florian ErnstandClaude Opus 5.5 099fb70e6a Pick up a sulfur cube with an empty bucket
SulfurCube.mobInteract ends with Bucketable.bucketMobPickup, and a sulfur
cube is picked up with an empty bucket (canBePickedUpWithBucket), not a
water bucket. Horizon's bucket pickup only knew the water bucket's mobs,
so a click with a bucket left the cube where it was.

After the cube's own baby, primed, shears and swallow branches, an empty
bucket now plays the sulfur fill sound, fills a sulfur_cube_bucket, fires
filled_bucket and player_interacted_with_entity, drops the leash, discards
the cube and gives the bucket through ItemUtils.createFilledResult, as the
other bucketable mobs do. The native bucket hook hears it.

The filled bucket carries SulfurCube.saveToBucketTag: the cube's age and
age_locked in bucket_entity_data, and its body block as
sulfur_cube_content when it has one. Other species keep their bucket data.

The command harness gets a mob_interact op: the connected player's real
Interact packet on a mob, on both sides.

Fixes #12044.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:26:20 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c7aef39dd5 Burn boats: live fire timer, fire damage and both block-effect passes
A boat now keeps Entity.remainingFireTicks: Entity.baseTick burns it down a
tick at a time and hurts it every 20 ticks, and the two applyEffectsFromBlocks
of AbstractBoat.tick light it on fire blocks, hurt it once per fire block,
let water, powder snow and rain put it out and clear the cooldown. Saves,
/data, portals and the root vehicle write the live Fire; loads and /data
merge read it. Fixes #12025.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:24:20 +01:00
TheMrZZandCodex gpt-6.1-sol d7c3fefcfa Preserve Nautilus cooldown memory presence and lifetime
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 21:23:19 +01:00
Florian ERNSTandCodex 8b48b0d74e test: checkpoint H255 piston ground regression before claim-cap decision
Qualified old-source failure only; no production repair or fixed-pass claim.

Co-Authored-By: Codex <[email protected]>
2026-11-07 21:19:53 +01:00
Nikita Maximilian Grimm 5664a950e8 Merge remote-tracking branch 'origin/main' into execute-block-nbt 2026-11-07 21:17:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 538422f3c7 Match the NBT part of a block predicate against the block entity
execute if|unless block, fill ... replace and clone ... filtered tested only
the block state; BlockPredicateArgument also compares the {...} part with the
block entity's full saved data (NbtUtils.compareNbt), and a block without a
block entity fails it.

Fixes #12030

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:17:09 +01:00
Florian ErnstandClaude Opus 5.5 b528247824 Merge origin/main into fix/h275-test-defs-world-packs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:59:27 +01:00
MSedgeMC e8da3a8e21 A drowned stops the trident attack when it loses the trident
`DrownedTridentAttackGoal` overrides `canUse` with the trident test and
inherits `RangedAttackGoal.canContinueToUse`, which calls `canUse()` again
(and falls back to a target it is still walking towards). Horizon's
`DrownedTridentAttack::can_continue` asked only the base ranged goal, so a
drowned whose trident was gone kept the trident attack running once its
path was finished.
2026-11-07 19:42:47 +00:00
Mateusz KorniakandClaude Opus 5.5 3004b64927 Fix speckled players with Improved Transparency on Metal
fs_oit_cull writes the nearest opaque see-through depth into the depth
buffer, and the later OIT stages test GreaterEqual against it, so an opaque
fragment is compared with its own depth. On Metal, position.z can differ from
the rasterizer's depth by an ulp, which dropped random pixels of players and
mannequins (horizontal dashes, text behind showing through). Write the depth
4 ulps farther: the fragment still passes, anything visibly behind is culled.

Checked on macOS (Metal, Apple M1) with a mannequin and a text display; not
yet on Linux (Vulkan).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:38:40 +01:00
VaspyyyandClaude Opus 5.5 abdf311e3b Native mod API: muffle the game's sounds for a while (native_muffle_sounds)
After a close blast a mod can now dull and quieten the world's sounds while
its own ringing plays clearly: native_muffle_sounds(amount, lowpass, ticks,
exempt) turns every sound down by `amount` and low-passes it by `lowpass`
(down to 300 Hz), both fading back over `ticks` client ticks and held while
the game is paused. The sound events in `exempt` play through untouched,
those already playing too.

The mixer mixes the muffled sounds into a bus of their own, runs two
one-pole low-pass stages and the duck over it, and adds it to the exempt
ones. The level glides over 20 ms so tick-sized steps don't click. While no
muffle is set or once it has faded, sounds mix straight into the output as
before. The sound engine marks a channel exempt as it starts, and marks the
playing ones again when the list changes.

Helper-only fragment muffle_sounds. Fixes #12006

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:34:54 +01:00
MSedgeMC f96e0fba0f A tamed cat stops being tempted
`CatTemptGoal` overrides `canUse` with `!cat.isTame()` but does not
override `canContinueToUse`; `TemptGoal.canContinueToUse` ends with
`return this.canUse()`, so taming a cat mid-tempt ends the goal. Horizon's
`CatTempt::can_continue` ran only the base `Tempt` continue, which never
asked whether the cat was tamed, so a cat tamed while following a fish
kept following it.
2026-11-07 19:32:24 +00:00
VaspyyyandClaude Opus 5.5 653a4a99fe Native mods: a game horizon-mixins built has the mod image slot
The HUD atlas reserved `horizon/modimage` (native_mod_image, API 355) only
when the launcher's native-mods.json listed mods, so a bundle applied with
horizon-mixins by hand or by a mod's own script built a game whose
native_mod_image always returned false.

horizon-mixins leaves .horizon-mixins-stage.json at the root of every
workspace it changes, naming each injection's and module's mod. The build
script turns a record that names mods into HORIZON_MIXIN_MODS, and
built_with_native_mods counts it and installed trait mods as well as
native-mods.json. A release or Nightly has none of them and keeps no slot.

Fixes #12005

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:26:53 +01:00
Mik00000 65c35da0dc mobs: reuse pathfinding allocation buffers across queries 2026-11-07 21:21:07 +02:00
MrCloudy2andClaude Opus 5.5 772088790c lan_e2e: sprint-hit-slowdown builds its sprint with a run-up
An absolute /tp zeroes the player's speed, as vanilla's does
(TeleportCommand's relatives carry no DELTA), so the check's teleport
right before the hit left the guest at walking speed and the check
failed on main. The guest now runs 19 ticks from 8 blocks out and
clicks in reach. The slowdown assertion is tightened: the first
post-hit sample must be under 0.75 of the sprint speed and then climb,
which fails with the client's 0.6 cut removed (the old 0.9 bound did not).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:14:01 +01:00
Florian ErnstandClaude Opus 5.5 ad475b0573 Load a world's test instances from its data packs when it opens
/test only knew the test instances of the game's own data: read_test_def
read options.resources, and the selector ids were the exported built-in
ones, so a test a world's data pack defined was "No matches for
selector". Vanilla 26.3 loads Registries.TEST_INSTANCE from the packs the
world enables when it loads: later packs override earlier ones and the
built-in data, and /reload or /datapack do not change it.

When a world opens (the end of load_world_state), the built-in pack's and
the initially enabled packs' test_instance files are read once, in pack
order, through the existing test instance decoder. They become the
world's test definitions (test_defs) and its minecraft:test_instance ids,
replacing the previous world's. /reload and /datapack leave them as they
are. A game with no world opened reads the game's own data as before.

Fixes #11976

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:59:12 +01:00
Florian ERNSTandCodex aa019cfaa4 Canonicalize two saved adventure predicate forms
Co-Authored-By: Codex <[email protected]>
2026-11-07 19:58:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 9e962f6350 HDR layered frame docs: say what a screenshot shows without promising the tone curve changes it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:55:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 78ea4b6d02 HDR layered frame: drop the coverage debug flag the display pass no longer reads
The coverage view is chosen once, when the shader is wrapped; nothing reads the field after.
Also lets a test fill the coverage with its own f64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:51:26 +01:00
Florian ErnstandClaude Opus 5.5 596fda5618 Overlap note: check heads in one git call, diff in parallel
The workflow's clone is blobless, so `git cat-file -e` on a head it
does not hold fetches it from the server, one round trip per check.
On busy days the job ran into its 10-minute timeout. Check every head
in one `cat-file --batch-check` with lazy fetching off, and run the
diffs on 8 threads so their blob fetches overlap. A note for #11723
now takes 13 s instead of 170 s; `show` over every open pull request
29 s instead of 785 s, with the same notes.

Closes #11984

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:42:34 +01:00
TheMrZZandCodex gpt-6.1-sol 46b257b1d5 fix: honor supported Equippable overrides on item use
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 19:38:39 +01:00
MSedgeMC 8b0c8e40a6 A trader stops trading when hurt or out of reach
`TradeWithPlayerGoal.canUse` refuses a trader that `wasHurtRecently` or
whose trading player is more than four blocks away, but Horizon's
`TradeWithPlayer::can_use` checked neither. A wandering trader that took a
hit, or whose customer walked off, stayed in the stand-still trade goal
where vanilla ends the trade.
2026-11-07 18:34:07 +00:00
MSedgeMC d980cd810d A happy ghast on a still timeout stops floating
`HappyGhast.HappyGhastFloatGoal` overrides `canUse` with
`!isOnStillTimeout() && super.canUse()`. `FloatGoal` does not override
`canContinueToUse`, so `Goal.canContinueToUse` asks that same overridden
`canUse` on every tick: a player standing on the ghast makes it stop
floating. Horizon's `HappyGhastFloat::can_continue` re-ran only
`Float.can_use`, so the goal kept hopping in water under a player who was
holding it still.
2026-11-07 18:31:37 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 196bea6f1a HDR marks: entities, the hand and translucent particles mark at the entity pipeline's depth bias
#7820 gave the entity pipeline a constant depth bias of 128 so entities win the tie with
coplanar terrain. HDR output marks what it draws by drawing the same quads again, alpha only,
GreaterEqual against the depth it left, but without the bias: every mark tested a hair behind
that depth and failed. Mobs lost their HDR lighting, light sources in the hand stopped shining,
and the empty hand only looked right by reading the ground's mark behind it.

The marks after entities, the hand (unmark and mark) and translucent particles now draw at the
same bias. Translucent items, additive draws and the OIT stage follow unbiased pipelines and
keep the unbiased mark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:27:20 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 cdabdddb6b HDR float frame: the HUD and menus lay over the world's light
On the float HDR frame the last pass guessed which pixels still showed the world
(`shows_world`) and clipped everything else at white, so the world's light dropped to
SDR under the chat's see-through background, a menu's backdrop and the coloured camera
overlays. Where the device records HUD coverage (#11896) the frame is now drawn in
layers, as the HDR note's step 3 specifies:

- After the world and the hand, `Hdr::resolve` turns the frame's marks into light once.
  The camera overlays, post effects and entity outlines draw on light after it.
- `keep_world` becomes a pass: the world clipped to white, alpha 0, into a HUD target in
  the frame's format, which the HUD and menus draw on with their unchanged blends.
- The last pass lays the HUD over the world's light by its coverage, in gamma as the HUD
  blends (H = P - (1 - c) B, then H ui^(1/2.2) + (1 - c) W). UI Brightness scales the
  HUD's own part.
- The menu blur blurs that composite, light included, and makes it the world the menu
  draws over.

Until the tone curve, a screenshot is the HUD target and the world icon the world, both
clipped at white; the -hdr.png is the full picture. The legacy path, the 16-bit frame and
HDR off are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:27:07 +01:00
Florian ErnstandClaude Opus 5.5 78efabcfa7 Copy a clone's pending block ticks to its destination
/clone placed the blocks but not the block ticks pending in its source
box, so a strict clone of fresh sand never fell, while vanilla 26.3's
CloneCommands.clone ends with LevelTicks.copyAreaFrom over the whole
source box.

cmd_clone now calls the existing LevelTicks::copy_area with the source
box and the destination offset, after the placements and block
entities and before the empty-count failure, as vanilla does. The core
copy (the box filter, trigger times, priorities, sub-tick reordering
and dedup) is unchanged. Block ticks only, in the same level.

Fixes #11963

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:16:16 +01:00
MSedgeMC 8d783c0ff0 A cube mob keeps still while it is a passenger
`AbstractCubeMob.CubeMobKeepOnJumpingGoal.canUse` is
`!this.cubeMob.isPassenger()`, but Horizon's `CubeKeepJumping::can_use`
returned true unconditionally. A slime or magma cube riding a boat, a
minecart or another mob therefore kept hopping and steering its vehicle
every tick, where vanilla leaves the vehicle alone.
2026-11-07 18:14:53 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 cbe9ddfa3f HDR marks: entities, the hand and translucent particles mark at the entity pipeline's depth bias
#7820 gave the entity pipeline a constant depth bias of 128 so entities win the tie with
coplanar terrain. HDR output marks what it draws by drawing the same quads again, alpha only,
GreaterEqual against the depth it left, but without the bias: every mark tested a hair behind
that depth and failed. Mobs lost their HDR lighting, light sources in the hand stopped shining,
and the empty hand only looked right by reading the ground's mark behind it.

The marks after entities, the hand (unmark and mark) and translucent particles now draw at the
same bias. Translucent items, additive draws and the OIT stage follow unbiased pipelines and
keep the unbiased mark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:14:43 +01:00
Stink-OandClaude Opus 5.5 749c5405f6 World hold: its import on a line of its own, apart from the shared mod_api list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:13:53 +00:00
Mik00000 bc8912c13f bench: add stress_mob_ai scene for active mob AI and pathfinding 2026-11-07 20:02:57 +02:00
Florian ErnstandClaude Opus 5.5 40ad3a91c9 Keep an Allay's liked_player memory time to live and expire it
An Allay loaded with a finite minecraft:liked_player memory kept it
forever and saved it without its ttl: the loader read only the memory's
presence and value, the save wrote the value, and nothing counted the
memory down.

- The memory's time to live is kept next to the existing liked-player
  state (None for a memory that never expires), read on load and
  written on save as MemorySlot.visit does.
- The Allay's AI step, which NoAI skips like customServerAiStep, ticks
  it as MemorySlot.tick does: it counts down, and is cleared on the
  tick after it reaches 0.
- Giving an item sets the memory without a time to live
  (Brain.setMemory), taking it back erases it, and the /data path that
  keeps the memory keeps its time to live too.

Fixes #11936

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:42:50 +01:00
MrCloudy2andClaude Opus 5.5 a61825404a A vehicle's teleports leave its rider's look alone
ClientPacketListener.handleMovePlayer applies a position packet only when
the player is no passenger, and still accepts the teleport. Horizon's
client applied it to a rider too, look included: a map whose datapack
teleports the vehicle every tick (`tp @s ~ ~ ~ 0 0` on a strider) snapped
the rider's view back 20 times a second. A rider now keeps its own
position and look and answers the teleport with them.

lan_e2e gains `rider-look`: a tick function teleports the strider the
guest rides, and the guest's view stays where they turned it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:18:26 +01:00
Stink-OandClaude Opus 5.5 aa2220fcf6 Native mod API: hold the terrain's picture while the world changes (native_world_hold)
A client mod asks once a frame (`hold.hold()`, or `hold.hold_within(x, z, radius)` for circles):
sections already on screen that are meshed again are uploaded as ever but wait beside the meshes
drawn, and letting go swaps them all in on one frame with nothing left to upload. Smaller circles
let go of what they leave. In single player, far terrain waits too for the columns the server
changes beyond the view inside the hold, and takes them in when it lets go of them. Bounded: 30 s,
1 GiB of waiting meshes, the arena running out; ended so, it stays off until no mod asks for a
frame. Clearing the sections (another world or dimension) and unloading columns drop what waits;
arena compaction moves waiting meshes too. Renderer::upload keeps its order on the normal path
(before_mesh / upload_mesh / after_mesh). Tests in gfx/hold.rs; docs in native-mods.md; fragment
world_hold.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:34:09 +00:00
yahya-nusseibehandClaude Opus 5.5 d94fdae0b9 Agent control: no shared /tmp socket path, and bench refuses bad numbers
Without XDG_RUNTIME_DIR (containers, some WSL and minimal setups) the
control socket was /tmp/horizon-control.sock, a fixed name in a folder
every user shares. Another user who bound it first made --control fail
to start, and horizon-ctl then talked to their socket.

When the temporary directory is shared (world-writable, as /tmp), the
default endpoint is now horizon-<uid>/horizon-control.sock in it. The
game makes that folder 0700, and both the game and horizon-ctl refuse
it unless it is a real folder, owned by this user, that no one else may
enter. A temporary directory of the user's own (macOS's $TMPDIR) is
used as before, so nothing moves there. The launcher's copy of the
default endpoint (game_tools.rs) follows the same rule.

Separately, `bench -1` or `bench nan` panicked the game in
Duration::from_secs_f64; bench now takes 0 to 86400 seconds and refuses
anything else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 17:20:50 +02:00
iseaprojectsandDeepSeek V4.1 Flash d1dd934be2 Spanish: finish the launcher and es_es strings
The launcher's es.json still held 91 English values: the mod-port status sentences, release notices, look-and-feel settings, the SSH hosting panel and several install/update results. Translate them, and fix Back from the body part (Espalda) to Atrás.

es_es.json was 20 keys short of en_us.json: every /studio replay message, Play in Background and the controller-focus note. Add them so Spanish screens no longer fall back to English.

Closes #11814.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 11:08:01 -04:00
MrCloudy2andClaude Opus 5.5 a95c0e022d lan_e2e shield: each player's own game shows the disabled shield's cooldown
The shield check now also asks each player's window what its hotbar
draws over the shield (a new `shield_cooldown_shown` in the control
state: ItemCooldowns' percent for minecraft:shield). Right after the
vindicator's axe disables it the cooldown shows, and after the 100 ticks
it is gone. On main both players' games showed none (#11805).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:24 +01:00
MrCloudy2andClaude Opus 5.5 2f3743d282 An axe disabling a shield sends the defender the shield's cooldown
BlocksAttacks.disable adds the item cooldown (ItemCooldowns.addCooldown
on the shield's group), which ServerItemCooldowns sends a player as
Cooldown(minecraft:shield, 100) and ends with 0. Horizon only set its own
shield counter, so a guest's client never heard of it: no cooldown on the
hotbar, and a held right click could show the shield raised while the
server had it down. The disable now also goes through the item cooldowns.

Fixes #11805

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:25:58 +01:00
Teiwazik 1eb3b7f6c3 Merge remote-tracking branch 'origin/main' into tmp-seat/follow-plugin-seat-chains 2026-11-07 15:12:38 +01:00
MrCloudy2andClaude Opus 5.5 36add718c0 Spears stab other players: the charge and the jab reach players on a server
KineticWeapon.damageEntities and PiercingWeapon.attack hit every entity
along the view, players included (PiercingWeapon.canHitEntity:
canHarmPlayer, not the same vehicle). Horizon's stab targets were the
mobs only. A guest's spear now also reaches the other players of its
level; each stab is worked out as the attacker in their turn and lands
through the PvP path once the hub is back, with the spear's damage type,
the 0.4 push and the weapon's Knockback, the dismount, thorns and the
kill score, then the attacker's wear, stats, exhaustion and hit feedback.

The kill score and thorns of pvp_land move into pvp_aftermath, shared by
both. lan_e2e gains a `spear` check: a jab and a sprinting charge hurt
the host, and with the pvp game rule off neither does.

Fixes #11790

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:42:20 +01:00
MSedgeMC 077164ff2f port_map: the camel RandomSitting row no longer records the pose gap 2026-11-07 13:39:44 +00:00
MSedgeMC c953fe79cb Test that a sitting camel cannot stand under a two-block ceiling
The test builds a world with a stone ceiling cell inside the standing box
but above the sitting box, then asks RandomSitting, the arm the goal
selector calls: 200 asks must all be refused. A second camel with open sky
fires within the same 200, so the refusal is the pose check and not the
state setup. Dropping the can_change_pose conjunct fails the first loop.
2026-11-07 13:39:44 +00:00
MSedgeMC 92282c7476 A sitting camel stands up only when its standing box fits
`CamelAi.RandomSitting.checkExtraStartConditions` ends with
`body.canCamelChangePose()`, which is
`wouldNotSuffocateAtTargetPose(isCamelSitting() ? STANDING : SITTING)`.
RandomSitting did not ask, so a camel sitting under a ceiling that blocks
its 2.375 tall standing box (its sitting pose is 0.945 tall) stood up into
it. The `can_change_pose` helper already existed for the elastic leash pull.
2026-11-07 13:39:43 +00:00
TeiwazikandClaude Sonnet 5.5 052c2eb962 Native shader packs: sections fade in toward the fog colour like vanilla terrain
The fade-in rides the high 16 bits of the flat hdr_code varying (no new
location); solid and translucent colours mix toward the fog colour after
the pack's material, alpha kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 14:35:56 +01:00
TeiwazikandClaude Sonnet 5.5 fdc056c8ac Older servers: send sign_update with the side before the lines
26.3 ends sign_update with the side as a VarInt; 26.2 and older read it as a
boolean right after the position. Forwarding the packet unchanged made the
server misread it and kick the player on Done.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 14:23:33 +01:00
e8a5564059 Cache decoded saved palette entries in the block registry
A saved area resolves the same dozen block states in every section of every column, but decode_column built a format!-ed String key and parsed each entry through BlockState::from_nbt into a per-column HashMap. The registry now memoizes palette entries by their NBT in an FxHashMap keyed by the tag itself, so a repeated entry costs one hash and one comparison and allocates nothing; the per-entry resolution rules move with it unchanged. Loading the 529-column saved area drops Persistent::load from 1,467.8M to 1,284.3M instructions.

Part of #9996.

A first draft by DeepSeek V4.1 Flash, via pi, reviewed and reworked.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:15:22 +01:00
70a0a29eec Check packed palette indices one word at a time
Loading a generated area spends 16% of Persistent::load (the worker's
chunk load) in PalettedContainer::from_packed, and nearly all of it is
check_indices: a scalar bounds check of every entry of every section
(~10.6 instructions per entry, 22M entries per 529-column area).

check_indices now rejects a whole word at once. Adding 2^bits - palette
to a word lifts every field by that much, so a field carries out of its
own bits exactly when its index is past the palette; the carry out of
each bit is computed for the whole word with (a & b) | ((a | b) & !sum).
A field can only appear bad where a genuinely bad field precedes it in
the same word (a carry adds one to it), which never turns an accepted
input into a rejected one, and the offending word is rescanned entry by
entry on the error path so the reported index is the same first
offending entry as before. The last word's padding fields are biased by
zero, so they cannot look bad, as the entry-by-entry scan ignored them.
The same set of inputs is accepted: the early return when the palette
holds exactly 2^bits entries was already there and stays.

Part of #9996.

A first draft by DeepSeek V4.1 Flash, via pi, reviewed and reworked.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:15:22 +01:00
5cffcf1370 Load saved light directly from its layers instead of expanding and repacking
The saved sky and block nibble layers are already the resident Light::Sky and Light::Block layout. Choose the resident variant from the layers and copy the stored layer when only one kind of light varies, so only sections where both vary still expand their light into a full array. Missing layers still inherit the bottom row above and dimensions without sky light still ignore saved sky layers.

Part of #9996.

A first draft by DeepSeek V4.1 Flash, via pi, reviewed and reworked.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:15:22 +01:00
TeiwazikandClaude Sonnet 5.5 ff28824d42 Riding: boat riders keep their own seat
position_riders re-seated every mob whose vehicle is not a mob through
passenger_riding_position, which puts every boat rider in seat 0 from
the boat's position. seat_boat_mobs had already placed them, so remote
players on a moving boat jumped between the two seats each pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 13:44:21 +01:00
TeiwazikandClaude Sonnet 5.5 45978aaf67 Seat test: import Vec3 and play packets
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 13:43:19 +01:00
24fa04d753 fix: drop mob XP after the guest who hurt it has left
Vanilla LivingEntity.dropExperience and Mob.dropCustomDeathLoot key on
lastHurtByPlayerMemoryTime, not whether that player is still in the
world. Horizon folded both into Death.player, so a guest who left
within the 100-tick memory made drops::die skip experience and
equipment.

Give die a player_killed flag from the timer. LAST_DAMAGE_PLAYER stays
None when killer_uuid cannot resolve them.

Fixes #11674

Co-Authored-By: OpenCode <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
2026-11-07 12:29:23 +00:00
Mayonesu0 1429b27098 Merge remote-tracking branch 'origin/main' into perf/launcher-write-data-file 2026-11-07 13:20:05 +01:00
DetimesandClaude Opus 5.5 c78882c7da Older clients: attributes and effects in the client's numbering
1.21.2 dropped the generic./player./zombie. attribute prefixes, so the id tables of 1.21.1 and 1.20.6 (matched by
name) had no attribute runs and every update_attributes value was left out for those clients. They are regenerated
with the renames. Mob effect ids were never renumbered: update_mob_effect and remove_mob_effect now go through the
client's table, and an effect added after its release is not sent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:16:51 +00:00
Detimes bb22bf6570 Merge remote-tracking branch 'upstream/main' into fix-11722-old-pick-item 2026-11-07 11:58:27 +00:00
DetimesandClaude Opus 5.5 c7f54b7461 Older clients: a 1.21.3 client's pick-item swaps the slot into its hand
1.21.3 and older clients send pick_item with the inventory slot that holds the picked block's item; 26.3 has no
such packet and the adapter dropped it. It now becomes a container_click swap of that slot with the held hotbar
slot (tracked from set_carried_item and set_held_slot), state id 0 so the server resends the inventory.

Fixes #11722

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:58:27 +00:00
Mayonesu0andClaude Opus 5.5 199e8193fd Launcher: write a prepared version's data file after installing it
A version's first start read its thousands of small files one by one, about
100 s under Windows Defender here, because only the game wrote
horizon-data.bin, and only after that slow start. The launcher now writes it
in the background once an install or import has its resources, reading on up
to 8 threads (43 s against 281 s on one, on a fresh copy); the start then
loads in 1.2 s. The game stays the fallback, on its one idle thread.

A reused version no longer copies the data file, which names its own root.
complete_assets removes the file when it adds something, as the file answers
missing for what it lacks. A write removes what a stopped writer left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:38:08 +01:00
TeiwazikandClaude Sonnet 5.5 55a54edebe Riding: follow plugin seat chains through invisible vehicles
Plugins seat players on text displays, interactions, armor stands or area
effect clouds that ride another player. Resolve the seat through the whole
chain of vehicles, keep the packet riding graph, move the local rider with a
moving seat, and use the Avatar attachment height for player vehicles.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 11:44:41 +01:00
Riccardo GuerrieroandClaude Opus 5.5 c9f2efa210 A player's Thorns hurts the mob as the player's thorns hit
Thorns on the player answered a mob's hit with generic damage that had no
attacker, so the mob was not knocked back, did not turn on the player and
dropped no experience when it died of it. It now hurts the mob by
`minecraft:thorns` damage the player caused (`DamageEntity` with the
wearer as owner), rolled from the mob's own random, and the held weapon's
Breach applies as on any hit by the player.

The whole equipment counts, in `EquipmentSlot` order: the main hand, the
off hand, then feet up to head (Thorns' slots are `any`), so a Thorns
piece held in a hand answers too. Each piece rolls its chance, hurts the
attacker and wears in that order, also when the attacker is already dead.
A piece that takes no wear, or a creative player's, is left alone.

A mob hit by thorns damage plays `enchant.thorns.hit` where it stands
when the hit lands whole, silent or not (`playSecondaryHurtSound`).

Part of #9954.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:34:28 +01:00
AkirabaneandClaude Opus 5.5 8e0b157254 dedicated: a stop waits for a proxy to move the players out first
With [transfer] on and players on, a stop (/stop, a stop signal, the
supervisor's stop or restart) now waits stop_wait_seconds (10 by default,
0 to 60, 0 stops at once as before) so a proxy told server/stopping can
move the players out first. The log says "Stopping in 10 s: a proxy may
move the players out first", which horizon-server takes as the start of
the stop. Every login is refused as server_shutdown, and the server ticks
on and answers the proxy's requests.

The wait ends as soon as no player is placed, at its time when no
hand-over is frozen, and at the latest a freeze's longest time later, so a
hand-over started in time finishes; the whole wait is cut at 90 s, within
the supervisor's 120 s for a stop, so stop_wait_seconds and
max_freeze_seconds may add up to 90 at most when the stop waits. No freeze
starts that this limit would cut: a player its source saved after the
target placed it would be on both servers. A freeze lasts ticks, so its
length is reckoned at the tick length of the moment (the rate's, or longer
on a server slower than its rate). A second stop ends the wait. Then the
stop goes on as vanilla's.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:51:02 +01:00
yahya-nusseibehandClaude Opus 5.5 d689881e68 Cap the packets a connection queues for the hub between ticks
A connection's network thread hands every play packet to the hub over
an unbounded channel. The hub's inbox caps (INBOX_LIMIT packets,
INBOX_BYTES bytes) apply only as it drains that channel at the start of
a tick, so while one tick runs long (an autosave, chunk generation) a
client can queue tiny packets without bound, each a boxed Serverbound
many times its size on the wire.

Each connection now counts what it handed the hub and the hub hasn't
taken yet (Undrained, shared between the network thread and the
guest's PlayerSender). Past twice the inbox caps the network thread
disconnects the client with the same message as the packet-rate kick
(disconnect.exceeded_packet_rate) and logs a warning; the hub
uncounts each packet as it takes it. A client within the inbox caps
never comes near.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 08:06:12 +02:00
279 changed files with 12922 additions and 1105 deletions

No files matched your search

+18
View File
@@ -46,6 +46,21 @@ thread_local! {
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// Counts the languages set (`set`): another count is another language, so text looked up
/// before it may read otherwise.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
thread_local! {
/// Counts the scopes this thread entered and left (`scoped`), which no other thread sees.
static SCOPE_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Which language text looks up in now, on this thread, as a count that changes whenever that
/// can: both counts only grow, so their sum changes when either does.
pub fn generation() -> u64 {
GENERATION.load(Ordering::Relaxed) + SCOPE_GENERATION.get()
}
fn scope_changed() {
SCOPE_GENERATION.set(SCOPE_GENERATION.get() + 1);
}
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -524,6 +539,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
GENERATION.fetch_add(1, Ordering::Relaxed);
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
@@ -543,6 +559,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
scope_changed();
}
}
let outer = SCOPE.with(|s| {
@@ -552,6 +569,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
})
});
let _restore = Restore(outer);
scope_changed();
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
+255 -1
View File
@@ -47,6 +47,53 @@ struct Channel {
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
unmuffled: bool,
}
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
const MUFFLED_CUTOFF: f32 = 300.0;
/// The cutoff it sweeps down from as its strength rises.
const OPEN_CUTOFF: f32 = 20_000.0;
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
const MUFFLE_GLIDE: f32 = 0.02;
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
/// (never set, or faded out) the sounds mix straight into the output, as without it.
#[derive(Default)]
struct Muffle {
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
target: [f32; 2],
/// Where the output is now, gliding to `target`.
current: [f32; 2],
/// The low-pass stages' state for the left and right output channels.
stages: [[f32; 2]; 2],
/// The muffled sounds of one buffer, reused.
bus: Vec<f32>,
}
impl Muffle {
fn active(&self) -> bool {
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
}
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
let rate = rate as f32;
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
for (current, target) in self.current.iter_mut().zip(self.target) {
*current += (target - *current) * glide;
}
let [amount, lowpass] = self.current;
let gain = 1.0 - amount;
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
stage[0] += (s - stage[0]) * a;
stage[1] += (stage[0] - stage[1]) * a;
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
*out += (s + (stage[1] - s) * lowpass) * gain;
}
}
}
}
#[derive(Default)]
struct Mixer {
@@ -65,6 +112,8 @@ struct Mixer {
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
/// A mod's muffle of the sounds it does not exempt.
muffle: Muffle,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -100,6 +149,21 @@ pub struct Heard {
pub range: f32,
pub looping: bool,
}
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
#[derive(Clone, Copy, Debug, PartialEq)]
struct MuffleFade {
amount: f32,
lowpass: f32,
ticks: u32,
left: u32,
}
impl MuffleFade {
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
fn level(&self) -> [f32; 2] {
let left = self.left as f32 / self.ticks as f32;
[self.amount * left, self.lowpass * left]
}
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
@@ -247,6 +311,8 @@ pub struct Audio {
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
muffle: Option<MuffleFade>,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
@@ -439,6 +505,7 @@ impl Backend for MixerBackend {
owner: self.owner,
world: self.world,
started: None,
unmuffled: false,
},
);
Some(id)
@@ -466,6 +533,9 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -702,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
let muffling = mixer.muffle.active();
let mut bus = std::mem::take(&mut mixer.muffle.bus);
if muffling {
bus.clear();
bus.resize(data.len(), 0.0);
}
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -711,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
for frame in data.chunks_mut(channels) {
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
&mut *data
};
for frame in out.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= frames {
if c.looping && frames > 1 {
@@ -746,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
}
mixer.voices = voices;
if muffling {
mixer.muffle.apply(&bus, data, channels, rate);
} else {
// Off: the next muffle starts from silence, not from the last one's filter.
mixer.muffle.current = [0.0; 2];
mixer.muffle.stages = [[0.0; 2]; 2];
}
mixer.muffle.bus = bus;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -1077,6 +1167,7 @@ impl Audio {
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
@@ -1238,6 +1329,7 @@ impl Audio {
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
@@ -1370,6 +1462,50 @@ impl Audio {
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
// A paused game holds a mod's muffle where it is, as it holds the sounds.
if !paused && let Some(fade) = &mut self.muffle {
fade.left = fade.left.saturating_sub(1);
let level = fade.level();
if fade.left == 0 {
self.muffle = None;
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
}
self.set_muffle_level(level);
}
}
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
/// player hears it. False for an amount or strength outside 0 to 1.
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
return false;
}
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
let exempt = if on {
exempt
.iter()
.map(|e| crate::sound_events::normalize(e))
.collect()
} else {
Vec::new()
};
self.engine.set_unmuffled(exempt, &mut self.backend);
self.muffle = on.then_some(MuffleFade {
amount,
lowpass,
ticks,
left: ticks,
});
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
true
}
fn set_muffle_level(&mut self, level: [f32; 2]) {
if let Ok(mut m) = self.backend.mixer.lock() {
m.muffle.target = level;
}
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
@@ -1835,6 +1971,7 @@ mod tests {
owner: 0,
world: 0,
started: None,
unmuffled: false,
}
}
@@ -2255,6 +2392,123 @@ mod tests {
assert_eq!(data, [0.5; 2]);
}
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
fn sine(hz: f32) -> Vec<f32> {
(0..48_000)
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
.collect()
}
fn rms(data: &[f32]) -> f32 {
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
}
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
let mut out = Vec::new();
let mut buffer = [0.0; 960];
for _ in 0..(seconds * 100.0).round() as usize {
mix(m, &mut buffer, 2, 48_000);
out.extend_from_slice(&buffer);
}
out
}
#[test]
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
let mut m = Mixer::default();
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
c.unmuffled = unmuffled;
m.channels.insert(1, c);
m.muffle.target = muffle;
listen(&mut m, 0.2);
listen(&mut m, 0.2)
};
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
// An exempt sound plays through as if there were no muffle, sample for sample.
assert_eq!(
heard(6000.0, [0.5, 1.0], true),
heard(6000.0, [0.0; 2], false)
);
}
#[test]
fn a_faded_muffle_leaves_the_sound_as_it_was() {
let mixer = || {
let mut m = Mixer::default();
m.channels
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
m
};
let (mut muffled, mut plain) = (mixer(), mixer());
muffled.muffle.target = [0.7, 1.0];
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
muffled.muffle.target = [0.0; 2];
listen(&mut muffled, 0.5);
listen(&mut plain, 0.5);
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
}
#[test]
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
audio.engine.registry.add_file(
"minecraft",
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
&|_, _| true,
);
audio.engine.registry.add_file(
"hztest",
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
&|_, _| true,
);
// The engine makes its channels; with no output, none loads a file.
audio.engine.loaded = true;
let marks = |audio: &Audio| {
let m = audio.backend.mixer.lock().unwrap();
let mut marks: Vec<_> = m
.channels
.iter()
.map(|(&id, c)| (id, c.unmuffled))
.collect();
marks.sort();
marks
};
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
// Two sounds already playing, and one exempt sound started under the muffle.
audio.play("ambient.cave", None, 1.0, 1.0);
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
assert!(close(level(&audio), [0.8, 1.0]));
let ticks = TickContext::default();
audio.tick(false, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
// A paused game holds it.
audio.tick(true, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
for _ in 0..3 {
audio.tick(false, &ticks);
}
assert_eq!(level(&audio), [0.0; 2]);
// Over: its exemptions go with it.
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
// Out of range changes nothing.
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
assert_eq!(level(&audio), [0.0; 2]);
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
@@ -12,12 +12,13 @@ use crate::{
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
font::{Font, Glyph},
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
@@ -620,30 +621,26 @@ fn frac(x: f32) -> f32 {
x - x.floor()
}
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
/// `StringSplitter.findLineBreak`: first line, with styled advances.
fn first_line(line: &Text, max_width: f32, advance: &dyn Fn(char, &Style) -> f32) -> Text {
let mut width = 0.0;
let mut visible = false;
let mut last_space = None;
for (i, c) in line.char_indices() {
for (i, (c, style)) in line.chars().enumerate() {
if c == '\n' {
return line[..i].to_owned();
return line.slice(0, i);
}
if c == ' ' {
last_space = Some(i);
}
width += advance(c);
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
_ if i > 0 => i,
// The first char alone is too wide: take that one char.
_ => c.len_utf8(),
};
return line[..end].to_owned();
let step = advance(c, style);
width += step;
if visible && width > max_width.max(1.0) {
return line.slice(0, last_space.unwrap_or(i));
}
visible |= step != 0.0;
}
line.to_owned()
line.clone()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
@@ -661,7 +658,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour. The font colours each
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
/// else the sign's. A line is one call per run of one colour.
/// else the sign's. Keep style boundaries for glyphs and decoration effects.
fn sign_text_calls(
scene: &Scene,
data: &SignData,
@@ -684,17 +681,32 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed), 0)
};
let advance = |c: char| (scene.glyph)(c).advance;
let advance = |c: char, style: &Style| {
let g = scene
.font
.map_or_else(|| (scene.glyph)(c), |f| f.glyph_styled(c, style));
g.advance + if style.is_bold() { g.offset } else { 0.0 }
};
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
for i in 0..text.lines.len() {
// The line that fits, in the order it is drawn (getVisualOrder).
let line = text.line_text(i);
let line = line.resolved();
let flat: String = line.chars().map(|(c, _)| c).collect();
let fits = first_line(&flat, data.max_line_width(), &advance);
let shown = line.slice(0, fits.chars().count());
let shown = if let Some(font) = scene.font {
font.split_formatted(&line, data.max_line_width())
.into_iter()
.next()
.unwrap_or_default()
.resolved()
.into_owned()
} else {
first_line(&line.resolved(), data.max_line_width(), &advance)
};
let shown = visual(&shown);
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
let width = shown
.chars()
.map(|(c, s)| advance(c, s))
.sum::<f32>()
.ceil();
let x = -((width as i32) / 2) as f32;
let y = i as f32 * line_height - midpoint;
let first = calls.len();
@@ -704,20 +716,22 @@ fn sign_text_calls(
.style
.color
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
match calls[first..].last_mut() {
Some(last) if last.color == run => last.text.push_str(&span.text),
_ => calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
color: run,
outline,
light,
tier,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
style: span.style.clone(),
color: run,
outline,
light,
tier,
});
pen += span
.text
.chars()
.map(|c| advance(c, &span.style))
.sum::<f32>();
}
if calls.len() == first {
// A line with no text is still a call, of no glyphs.
@@ -726,6 +740,7 @@ fn sign_text_calls(
x,
y,
text: String::new(),
style: Style::default(),
color,
outline,
light,
@@ -736,6 +751,17 @@ fn sign_text_calls(
calls
}
fn sign_text_animated(data: &SignData) -> bool {
[&data.front, &data.back].into_iter().any(|side| {
(0..side.lines.len()).any(|i| {
side.line_text(i)
.resolved()
.chars()
.any(|(c, s)| c != ' ' && s.obfuscated == Some(true))
})
})
}
/// How far the text of an outlined call sits in front of its outline, in
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
const TEXT_OVER_OUTLINE: f32 = 0.05;
@@ -749,72 +775,131 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
text_vertices(scene.glyph, call, out);
let glyph = |c| {
let Some(font) = scene.font else {
return (scene.glyph)(c);
};
let g = font.glyph_styled(c, &call.style);
if call.style.obfuscated == Some(true) && c != ' ' {
font.random_glyph(&call.style, g.advance)
} else {
g
}
};
text_vertices(&glyph, scene.font.map(Font::white), call, out);
}
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
/// `BakedSheetGlyph.renderChar` / `Font.PreparedTextBuilder.accept`: the
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
/// y down as the font's, with its outline when it has one. A native mod's
/// block entity draws its text this way (`native_draw_text`).
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
pub fn text_vertices(
glyph: &dyn Fn(char) -> Glyph,
white: Option<Rect>,
call: &TextCall,
out: &mut Vec<Vertex>,
) {
let bold = call.style.is_bold();
let mut quad = |corners: [(f32, f32); 4], cell: Rect, z: f32, argb: u32| {
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let color = 0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((x, y), uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: call.matrix.transform_point3(Vec3::new(x, y, z)).to_array(),
uv,
layer: u32::from(call.tier) << 16,
color,
light: call.light,
glint_uv: [0.0; 2],
});
}
};
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32, outline: bool| {
let mut pen = call.x;
for (i, ch) in call.text.chars().enumerate() {
let g = glyph(ch);
let x = pen + dx * g.offset;
let y = call.y + dy * g.offset;
let advance = g.advance + if bold { g.offset } else { 0.0 };
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: call
.matrix
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: u32::from(call.tier) << 16,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
let thick = if bold { 0.1 } else { 0.0 };
let shear = |v| {
if call.style.is_italic() {
1.0 - 0.25 * v
} else {
0.0
}
};
for copy in 0..=usize::from(bold) {
let x0 = x + copy as f32 * g.offset + g.left - thick;
let x1 = x + copy as f32 * g.offset + g.right + thick;
let top = y + g.top - thick;
let bottom = y + g.bottom + thick;
quad(
[
(x0 + shear(g.bottom), bottom),
(x1 + shear(g.bottom), bottom),
(x1 + shear(g.top), top),
(x0 + shear(g.top), top),
],
cell,
z + copy as f32 * 0.001,
argb,
);
}
}
x += g.advance;
// `Font.prepare8xTextOutline` discards effects: only the front
// text gets underline/strikethrough, including over spaces.
if !outline && let Some(white) = white {
let x0 = if i == 0 { x - 1.0 } else { x };
for (enabled, top) in [
(call.style.strikethrough, 3.5),
(call.style.underlined, 8.0),
] {
if enabled == Some(true) {
quad(
[
(x0, y + top + 1.0),
(x + advance, y + top + 1.0),
(x + advance, y + top),
(x0, y + top),
],
white,
z + 0.01,
argb,
);
}
}
}
pen += advance;
}
};
if call.outline != 0 {
for (dx, dy) in [
(-1.0, -1.0),
(0.0, -1.0),
(1.0, -1.0),
(-1.0, 0.0),
(1.0, 0.0),
(-1.0, 1.0),
(0.0, 1.0),
(1.0, 1.0),
] {
emit(dx, dy, 0.0, call.outline);
for dx in -1..=1 {
for dy in -1..=1 {
if dx != 0 || dy != 0 {
emit(dx as f32, dy as f32, 0.0, call.outline, true);
}
}
}
}
let z = if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
};
emit(0.0, 0.0, z, call.color);
emit(
0.0,
0.0,
if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
},
call.color,
false,
);
}
/// Block-entity fields the level ticks, at one position (vanilla's own field
@@ -847,6 +932,7 @@ pub struct TextCall {
pub x: f32,
pub y: f32,
pub text: String,
pub style: Style,
/// ARGB.
pub color: u32,
/// ARGB outline colour, 0 for none.
@@ -894,6 +980,8 @@ pub struct Scene<'a> {
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Font providers, effects and random glyphs for styled sign text.
pub font: Option<&'a Font>,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -1055,6 +1143,7 @@ struct SignTextEntry {
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
animated: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct StaticKey {
@@ -2361,9 +2450,11 @@ impl BlockEntityRenderer {
let entry = match signs.entry(pos) {
std::collections::hash_map::Entry::Occupied(mut occupied) => {
let entry = occupied.get_mut();
let changed = entry.data != *data;
if (entry.state, entry.packed, entry.near, entry.glyphs)
!= (state, packed, near, glyphs)
|| entry.data != *data
|| changed
|| entry.animated
{
entry.vertices.clear();
self.render_one(
@@ -2378,7 +2469,10 @@ impl BlockEntityRenderer {
&mut entry.vertices,
);
entry.state = state;
entry.data.clone_from(data);
if changed {
entry.animated = sign_text_animated(data);
entry.data.clone_from(data);
}
entry.packed = packed;
entry.near = near;
entry.glyphs = glyphs;
@@ -2399,6 +2493,7 @@ impl BlockEntityRenderer {
&mut vertices,
);
vacant.insert(SignTextEntry {
animated: sign_text_animated(data),
state,
data: data.clone(),
packed,
@@ -3638,6 +3733,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
@@ -4032,6 +4128,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut call = TextCall {
@@ -4039,6 +4136,7 @@ mod tests {
x: 0.0,
y: 0.0,
text: "Hi".into(),
style: Style::default(),
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
@@ -4106,6 +4204,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let call = TextCall {
@@ -4113,6 +4212,7 @@ mod tests {
x: 10.0,
y: 2.0,
text: "ab".into(),
style: Style::default(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
@@ -4136,6 +4236,83 @@ mod tests {
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn styled_sign_quads_follow_vanilla_glyph_and_effect_bounds() {
let white = Rect {
u0: 0.0,
v0: 0.0,
u1: 0.1,
v1: 0.1,
w: 1,
h: 1,
};
let glyph = |c| Glyph {
rect: (c != ' ').then_some(white),
left: 0.0,
right: 4.0,
top: 0.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
x: 0.0,
y: 0.0,
text: "A ".into(),
color: 0xff123456,
outline: 0,
light: 240,
tier: crate::mesher::LOW,
style: Style {
bold: Some(true),
italic: Some(true),
underlined: Some(true),
strikethrough: Some(true),
..Default::default()
},
};
let mut out = Vec::new();
text_vertices(&glyph, Some(white), &call, &mut out);
// BakedSheetGlyph: bold expands by 0.1, italic shears by 1 - y/4,
// the second bold glyph is 0.5 along and 0.001 in front (Unihex).
assert_eq!(out.len(), 24);
for (got, expected) in out[0].pos.iter().zip([-1.1, 8.1, 0.0]) {
assert!((got - expected).abs() < 1e-6);
}
assert!((out[4].pos[0] - out[0].pos[0] - 0.5).abs() < 1e-6);
assert_eq!(out[4].pos[2], 0.001);
assert_eq!(out[8].pos, [-1.0, 4.5, 0.01]);
assert_eq!(out[12].pos, [-1.0, 9.0, 0.01]);
assert_eq!(out[21].pos, [11.0, 9.0, 0.01]); // underline crosses the space
assert!(out.iter().all(|v| v.color == 0xff563412 && v.light == 240));
call.outline = 0xff010203;
out.clear();
text_vertices(&glyph, Some(white), &call, &mut out);
// Eight outlines contain glyphs only; effects appear once, in front.
assert_eq!(out.len(), 8 * 8 + 24);
assert!((out[0].pos[0] - -1.6).abs() < 1e-6);
assert!(out[64..].iter().all(|v| v.pos[2] >= TEXT_OVER_OUTLINE));
// The glow tier reaches bold copies, outlines and decoration quads.
assert!(out.iter().all(|v| v.layer >> 16 == 1));
call.tier = 0;
out.clear();
text_vertices(&glyph, Some(white), &call, &mut out);
assert!(out.iter().all(|v| v.layer >> 16 == 0));
}
#[test]
fn styled_sign_clipping_counts_bold_width_and_preserves_resets() {
let line = Text::legacy("§lAB§r東Z");
let advance = |_: char, s: &Style| 5.0 + if s.is_bold() { 0.5 } else { 0.0 };
let shown = first_line(&line, 16.0, &advance);
assert_eq!(shown.plain(), "AB東");
assert_eq!(
shown.chars().map(|(_, s)| s.is_bold()).collect::<Vec<_>>(),
[true, true, false]
);
}
/// The front text calls of a sign whose every glyph advances 6.
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
let world = World::new(BlockStateId(0), 1);
@@ -4164,6 +4341,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
@@ -4303,7 +4481,9 @@ mod tests {
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph_reads = std::cell::Cell::new(0);
let glyph = |c: char| {
glyph_reads.set(glyph_reads.get() + 1);
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
@@ -4346,6 +4526,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
@@ -4389,6 +4570,23 @@ mod tests {
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// With the same game time and data, plain text reuses its vertices,
// but obfuscated text must ask the font for fresh glyphs each frame.
let mut vertices = Vec::new();
let reads = glyph_reads.get();
renderer.render(&scene, [(pos, 1)].iter(), &mut vertices);
assert_eq!(glyph_reads.get(), reads);
let animated = SignData {
front: text("§kSecret", false),
..Default::default()
};
let animated_sign = |_| Some(&animated);
scene.sign = &animated_sign;
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
let reads = glyph_reads.get();
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
assert!(glyph_reads.get() > reads);
scene.sign = &first_sign;
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
@@ -4396,11 +4594,23 @@ mod tests {
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
let advance = |c: char, _: &Style| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(
first_line(&Text::literal("ab cd ef"), 13.0, &advance).plain(),
"ab"
);
assert_eq!(
first_line(&Text::literal("abcdef"), 13.0, &advance).plain(),
"ab"
);
assert_eq!(
first_line(&Text::literal("\u{6771}"), 3.0, &advance).plain(),
"\u{6771}"
);
assert_eq!(
first_line(&Text::literal("ab\ncd"), 100.0, &advance).plain(),
"ab"
);
}
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
/// submits the unit cube's two Y faces. Both are wound outward, so of each
@@ -4504,6 +4714,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut verts = Vec::new();
@@ -4590,6 +4801,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
@@ -447,6 +448,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -502,6 +504,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
@@ -689,6 +692,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -743,6 +747,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -845,6 +850,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -900,6 +906,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -962,6 +969,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1060,6 +1068,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pos = [0, 64, 0];
@@ -1149,6 +1158,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1225,6 +1235,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1304,6 +1315,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
@@ -1371,6 +1383,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let pos = [0, 64, 0];
@@ -1419,6 +1432,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -1525,6 +1539,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let options = CacheOptions {
@@ -83,6 +83,13 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
},
Opt::PadNintendoButtons => {
if pad.nintendo_letters {
"A, on the right, confirms and jumps and B goes back, as on a Switch."
} else {
"B, at the bottom, confirms and jumps and A goes back, as on other controllers."
}
}
Opt::PadKeyboard => match pad.keyboard {
gamepad::TextKeyboard::Auto => {
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
@@ -261,6 +268,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
Opt::PadGlyphs,
"Follow the controller, or always show one family's buttons.",
),
option(
Opt::PadNintendoButtons,
"Whether a Nintendo controller's A and B work by where they sit or by their letters.",
),
info(Info::Using, "The buttons menus show right now."),
],
),
@@ -1332,6 +1343,7 @@ mod tests {
Opt::PadLookDeadzone,
Opt::PadTriggerThreshold,
Opt::PadGlyphs,
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadPointerSpeed,
Opt::PadScrollSpeed,
@@ -1429,6 +1441,53 @@ mod tests {
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
}
/// The Device page's Nintendo Buttons row sits with the glyphs, steps between its two
/// values rather than ticking a box, and what it sets is what the controller file keeps.
#[test]
fn the_nintendo_buttons_row_flips_letters_and_the_file_keeps_it() {
let (page, group) = rows()
.into_iter()
.flat_map(|(page, groups)| groups.into_iter().map(move |g| (page, g)))
.find(|(_, (_, rows))| {
rows.iter()
.any(|r| r.item == Item::Option(Opt::PadNintendoButtons))
})
.unwrap();
assert_eq!((page, group.0), (Page::Device, "Glyphs"));
let mut s = Settings::default();
let item = Item::Option(Opt::PadNintendoButtons);
assert!(item.steps(&s));
s.controller.nintendo_letters = false;
let mut ui = ControllerUi {
focus: Some(Target::Item(item)),
..ControllerUi::default()
};
// Vanilla's caption and value template; Horizon's own words come bundled.
struct Vanilla;
impl lang::ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json")
.then(|| br#"{"options.generic_value": "%s: %s"}"#.to_vec())
}
}
let value = |ui: &ControllerUi, s: &Settings| {
lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
ui.parts(item, s)
})
.0
};
assert_eq!(value(&ui, &s).0, "Nintendo Buttons");
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Position"));
let actions = key(&mut ui, &mut s, K::Enter);
assert!(actions.contains(&Action::Changed), "{actions:?}");
assert!(s.controller.nintendo_letters);
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Letter"));
assert!(PadSettings::parse(&s.controller.serialize()).nintendo_letters);
key(&mut ui, &mut s, K::Enter);
assert!(!s.controller.nintendo_letters);
assert!(!PadSettings::parse(&s.controller.serialize()).nintendo_letters);
}
/// What the screen draws at the bottom of the page changes with the choice, in every one of
/// these settings.
#[test]
@@ -1438,6 +1497,7 @@ mod tests {
for (opt, choices) in [
(Opt::PadGameplayGuide, 3),
(Opt::PadGlyphs, 4),
(Opt::PadNintendoButtons, 2),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
] {
@@ -1470,6 +1530,7 @@ mod tests {
for opt in [
Opt::PadGameplayGuide,
Opt::PadGlyphs,
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadLightbar,
] {
@@ -278,6 +278,43 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
}
infos
}
/// The folder of a game folder whose packs every new world there starts with: a modpack's profile
/// keeps the packs of its data-only jars here (`horizon-port-pack`). Each world gets its own copy,
/// as Create World's packs do, so a world keeps working when the folder changes. Vanilla has no
/// such folder, and without one nothing here happens.
pub const NEW_WORLD_PACKS: &str = "new-world-datapacks";
/// Create World's temporary pack folder holding `game_dir`'s [`NEW_WORLD_PACKS`], and their ids,
/// to show them selected in the Data Packs screen. `None` when there are none.
pub fn seed_create_world(game_dir: &Path) -> Option<(PathBuf, Vec<String>)> {
let from = game_dir.join(NEW_WORLD_PACKS);
let ids: Vec<String> = discover_folder(&from).into_iter().map(|p| p.id).collect();
if ids.is_empty() {
return None;
}
let seeded = temp_folder()
.map_err(|e| e.to_string())
.and_then(|dir| copy(&from, &dir).map(|()| dir));
match seeded {
Ok(dir) => Some((dir, ids)),
Err(e) => {
log::warn!("Horizon: data packs for new worlds: {e}");
None
}
}
}
/// A world about to be created without Create World (`--world`, the control socket) gets
/// `game_dir`'s [`NEW_WORLD_PACKS`]. The game turns on every pack it finds in a world.
pub fn install_new_world_packs(game_dir: &Path, world: &Path) -> Result<(), String> {
let from = game_dir.join(NEW_WORLD_PACKS);
let exists = |name: &str| world.join(name).exists();
if !from.is_dir() || exists("level.dat") || exists("level.dat_old") {
return Ok(());
}
copy(&from, &world.join("datapacks"))
}
/// A new empty folder for Create World's packs
/// (`Files.createTempDirectory("mcworld-")`).
pub fn temp_folder() -> std::io::Result<PathBuf> {
@@ -1101,6 +1138,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_game_folder_s_new_world_packs_go_into_new_worlds_only() {
let root = std::env::temp_dir().join(format!("hz-new-world-packs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (game, world, old) = (root.join("game"), root.join("new"), root.join("old"));
assert!(seed_create_world(&game).is_none());
assert_eq!(install_new_world_packs(&game, &world), Ok(()));
assert!(!world.join("datapacks").exists());
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("data/v/function/a.mcfunction"), "say a").unwrap();
}
let (temp, ids) = seed_create_world(&game).unwrap();
assert_eq!(ids, ["file/islands", "file/void"]);
assert!(temp.join("void/data/v/function/a.mcfunction").is_file());
let _ = std::fs::remove_dir_all(&temp);
install_new_world_packs(&game, &world).unwrap();
let ids: Vec<String> = discover(&world).into_iter().map(|p| p.id).collect();
assert_eq!(ids, ["file/islands", "file/void"]);
// A world that exists already keeps the packs it has.
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("level.dat"), "").unwrap();
install_new_world_packs(&game, &old).unwrap();
assert!(discover(&old).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+38 -1
View File
@@ -5,7 +5,7 @@ use crate::{
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
@@ -85,6 +85,10 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and whether cosmetics are offered.
fn stamp(&self, s: &Settings) -> u64 {
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
name: MODS,
@@ -228,4 +232,37 @@ mod tests {
assert!(s.extras.sort);
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
}
/// A page with no page list has Done under the search field: Down goes to the first row
/// instead, from the keys and, as the frame declares it, from a controller's focus.
#[test]
fn down_from_the_search_field_lands_on_the_first_row() {
use crate::{hud::HudBatch, settings_shell::Columns, ui};
use winit::keyboard::KeyCode as K;
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(1);
let size = (batch.width, batch.height);
let cols = Columns::of(size.0, size.1).with_note_lines(NOTE_LINES);
// The Mods group's note can't take the focus: the first item can.
let first = ExtraRow::Item(Item::OwnNameTag);
let at = shell.point_at(&s, size, first);
let [x, y, w, h] = crate::widgets::linked_step(cols.search(), true).unwrap();
assert!(ui::hovered(at, x, y, w, h));
assert_eq!(crate::widgets::linked_step(cols.search(), false), None);
let [x, y, w, h] = cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
size,
false,
false,
false,
);
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
}
+323 -39
View File
@@ -173,6 +173,8 @@ mod hdr_icc;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::Calibration as HdrCalibration;
mod hold;
pub use hold::WorldHold;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -445,6 +447,52 @@ struct GpuStaticEntitySection {
vertices: u32,
}
pub type SectionKey = (i32, i32, i32);
/// What a section's mesh brings with it besides its quads (`Renderer::upload`): placed with
/// the mesh, now or when a hold lets go of it.
struct MeshFacts<'a> {
visibility: u64,
block_entities: &'a [([i32; 3], u32)],
lights: &'a [([i32; 3], u32)],
bounds: Option<[glam::Vec3; 2]>,
/// It has translucent quads.
translucent: bool,
/// It has no quads at all: that is its mesh.
empty: bool,
}
impl<'a> MeshFacts<'a> {
fn of(mesh: &'a SectionMesh) -> Self {
Self {
visibility: mesh.visibility,
block_entities: &mesh.block_entities,
lights: &mesh.lights,
bounds: mesh.bounds,
translucent: !mesh.translucent.is_empty(),
empty: mesh.solid.is_empty() && mesh.translucent.is_empty(),
}
}
}
/// A section's new mesh waiting behind a hold (gfx/hold.rs): uploaded, with what goes with it.
struct HeldMesh {
gpu: Option<SectionGpu>,
visibility: u64,
block_entities: Vec<([i32; 3], u32)>,
lights: Vec<([i32; 3], u32)>,
bounds: Option<[glam::Vec3; 2]>,
translucent: bool,
empty: bool,
}
impl HeldMesh {
fn facts(&self) -> MeshFacts<'_> {
MeshFacts {
visibility: self.visibility,
block_entities: &self.block_entities,
lights: &self.lights,
bounds: self.bounds,
translucent: self.translucent,
empty: self.empty,
}
}
}
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
/// it, at half the world's resolution (the material stage's `reflection`).
struct MirrorTarget {
@@ -1193,6 +1241,8 @@ pub struct Renderer {
static_entity_catalog_revision: u64,
static_entity_frame_stats: StaticCacheFrameStats,
hdr_mark_pipeline: wgpu::RenderPipeline,
/// `hdr_mark_pipeline` at `entity_pipeline`'s depth bias, for what that drew.
hdr_entity_mark_pipeline: wgpu::RenderPipeline,
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
@@ -1330,6 +1380,9 @@ pub struct Renderer {
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Removed sections fading out over far terrain (`leave_section`).
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
/// A mod's hold on the terrain's picture (`hold_world`, gfx/hold.rs) and the new meshes
/// waiting behind it.
hold: hold::Hold<HeldMesh>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: FxHashMap<(i32, i32), SectionMask>,
@@ -2454,6 +2507,14 @@ impl Renderer {
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
@@ -4007,17 +4068,19 @@ impl Renderer {
// paths here, so their fp32 depths of the same plane differ by a few dozen
// ulps and the tie came out either way, as a jagged strip of the trapdoor
// through the hull's floor. The same constant as the shadows' covers that
// spread; a model's feet sit 0.001 above the ground, far beyond it.
// spread; a model's feet sit 0.001 above the ground, far beyond it. HDR output's
// marks on what it draws take it too (`hdr_entity_mark_pipeline`).
let entity_bias = wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
};
let entity_pipeline = entity_pipeline(
"Entities",
None,
true,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
},
entity_bias,
alpha_blend,
false,
);
@@ -4089,12 +4152,26 @@ impl Renderer {
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
// The same at `entity_pipeline`'s depth bias, for what it drew (entities, the hand,
// translucent particles): without it the marks test a hair behind the depth it left
// and never land.
let hdr_entity_mark_pipeline = entity_pipeline_with(
"HDR entity mark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
entity_bias,
wgpu::BlendState::REPLACE,
false,
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
let hdr_hand_unmark_pipeline = entity_pipeline_with(
"HDR hand unmark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
entity_bias,
wgpu::BlendState::REPLACE,
false,
"fs_hdr_hand_unmark",
@@ -4547,7 +4624,11 @@ impl Renderer {
attributes: &TERRAIN_ATTRIBUTES,
})];
let info = adapter.get_info();
// Metal's per-section path drops cutout leaves (the canopy above trunks vanishes),
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
// so Metal draws vanilla terrain flat at any count.
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| info.backend == wgpu::Backend::Metal
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
0
@@ -4799,6 +4880,7 @@ impl Renderer {
entity_pipeline,
static_entity_pipeline,
hdr_mark_pipeline,
hdr_entity_mark_pipeline,
hdr_hand_unmark_pipeline,
hdr_mark_lightning_pipeline,
weather,
@@ -4902,6 +4984,7 @@ impl Renderer {
lod_cutout: Default::default(),
lod_retiring: Default::default(),
lod_leaving: Vec::new(),
hold: Default::default(),
lod_pending: None,
detail_columns: Default::default(),
detail_revision: 0,
@@ -5006,7 +5089,7 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
if self.hdr.is_some() {
// Drawn into the world: the light sources it holds are marked as the world's are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
@@ -5497,12 +5580,28 @@ impl Renderer {
if let Some(copies) = &mut self.capture_copies {
copies.section(key, mesh);
}
if self.hold.holds(key) && self.shown(key) && self.upload_held(key, mesh) {
return;
}
if let Some(gpu) = self.hold.take(key).and_then(|held| held.gpu) {
self.release_mesh(gpu);
}
let facts = MeshFacts::of(mesh);
let fading = self.before_mesh(key, &facts);
let section = self.upload_mesh(mesh);
self.after_mesh(key, &facts, section, fading);
}
/// What goes with section `key`'s new mesh, before its quads go in: its old mesh is
/// released. The fade it keeps.
fn before_mesh(&mut self, key: SectionKey, mesh: &MeshFacts) -> Option<Instant> {
self.occlusion.set(key, mesh.visibility);
let changed = if mesh.block_entities.is_empty() {
self.block_entities.remove(&key).is_some()
} else {
let old = self.block_entities.insert(key, mesh.block_entities.clone());
old.as_deref() != Some(&mesh.block_entities[..])
let old = self
.block_entities
.insert(key, mesh.block_entities.to_vec());
old.as_deref() != Some(mesh.block_entities)
};
if changed {
self.block_entity_revision = next_block_entity_revision();
@@ -5521,8 +5620,8 @@ impl Renderer {
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
} else {
let old = self.light_blocks.insert(key, mesh.lights.clone());
old.as_deref() != Some(&mesh.lights[..])
let old = self.light_blocks.insert(key, mesh.lights.to_vec());
old.as_deref() != Some(mesh.lights)
};
if lights_changed {
self.light_revision += 1;
@@ -5530,7 +5629,7 @@ impl Renderer {
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
if !mesh.translucent.is_empty() && !self.sections.contains(key) {
if mesh.translucent && !self.sections.contains(key) {
// New translucent terrain, not an edit's remesh: large pages wait for it.
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
}
@@ -5549,13 +5648,19 @@ impl Renderer {
self.newest_upload = Some(now);
}
self.release_section(key);
let section = self
.upload_mesh(mesh)
.map(|section| SectionGpu { fading, ..section });
self.set_detail(
key,
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
);
fading
}
/// Section `key`'s new mesh drawn from now on, its quads uploaded (`section`: none when it
/// has none, or the arena had no room).
fn after_mesh(
&mut self,
key: SectionKey,
mesh: &MeshFacts,
section: Option<SectionGpu>,
fading: Option<Instant>,
) {
let section = section.map(|section| SectionGpu { fading, ..section });
self.set_detail(key, section.is_some() || mesh.empty);
if let Some(mut section) = section {
let sections = self.layout.sections();
if let Some(gpu) = &mut self.gpu_culling
@@ -5579,6 +5684,94 @@ impl Renderer {
.insert_marked(key, section, mesh.bounds, marked);
}
}
/// Whether section `key` has its mesh on screen (`set_detail`): an empty one counts.
fn shown(&self, key: SectionKey) -> bool {
self.detail_columns
.get(&(key.0, key.2))
.is_some_and(|mask| mask.contains(key.1 as usize))
}
/// The bytes a section's quads take in the arenas.
fn mesh_bytes(gpu: Option<&SectionGpu>) -> u64 {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
gpu.map_or(0, |s| {
(s.solid_slots + u64::from(s.translucent) * 4) * stride
})
}
/// A mod's hold on the terrain's picture (gfx/hold.rs, `WorldHold`), asked once a frame
/// before the frame's uploads: while held, a section on screen that is meshed again waits,
/// uploaded, behind the mesh drawn. What no longer waits takes its place on this frame.
pub fn hold_world(&mut self, ask: &WorldHold) {
let waiting = match self.hold.ask(ask, Instant::now()) {
hold::Release::None => return,
hold::Release::All => self.hold.take_all(),
hold::Release::Outside => self.hold.take_outside(),
};
self.place_held(waiting);
}
/// Whether a mod's hold covers chunk column `column` (`hold_world`): what changes there waits.
pub fn world_holds_column(&self, column: (i32, i32)) -> bool {
self.hold.holds((column.0, 0, column.1))
}
/// Section `key`'s new mesh waits behind the hold, uploaded. False when the arena has no
/// room for it: what waited takes its place (giving the old meshes back), the hold is
/// spent, and this one goes in as ever.
fn upload_held(&mut self, key: SectionKey, mesh: &SectionMesh) -> bool {
let facts = MeshFacts::of(mesh);
let gpu = self.upload_mesh(mesh);
if gpu.is_none() && !facts.empty {
log::warn!("Horizon: no memory left for a held world's new sections: let go");
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
return false;
}
let bytes = Self::mesh_bytes(gpu.as_ref());
let held = HeldMesh {
gpu,
visibility: facts.visibility,
block_entities: facts.block_entities.to_vec(),
lights: facts.lights.to_vec(),
bounds: facts.bounds,
translucent: facts.translucent,
empty: facts.empty,
};
let (old, over) = self.hold.wait(key, held, bytes);
if let Some(gpu) = old.and_then(|old| old.gpu) {
self.release_mesh(gpu);
}
if over {
log::warn!(
"Horizon: a held world's new sections came to {} MiB: let go",
self.hold.bytes() >> 20
);
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
}
true
}
/// Sections that waited behind the hold take their place, on this frame.
fn place_held(&mut self, waiting: Vec<(SectionKey, HeldMesh)>) {
if waiting.is_empty() {
return;
}
let (count, started) = (waiting.len(), Instant::now());
let bytes: u64 = waiting
.iter()
.map(|(_, held)| Self::mesh_bytes(held.gpu.as_ref()))
.sum();
for (key, mut held) in waiting {
let gpu = held.gpu.take();
let facts = held.facts();
let fading = self.before_mesh(key, &facts);
self.after_mesh(key, &facts, gpu, fading);
}
log::debug!(
"Horizon: {count} held sections ({} MiB) took their place in {:.1} ms",
bytes >> 20,
started.elapsed().as_secs_f64() * 1e3
);
}
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
let solid = (mesh.solid.len() / 4) as u32;
let translucent = (mesh.translucent.len() / 4) as u32;
@@ -5871,7 +6064,13 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
// (meshes waiting behind a hold move with the rest, or they would keep the page)
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
let flat = self
.flat
.as_mut()
@@ -5914,7 +6113,12 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
match begin_shrink(arena, &self.device, meshes, *part) {
Some(true) => break,
Some(false) => wait = SHRINK_RETRY, // the other arena may still go
@@ -5938,7 +6142,13 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
// (meshes waiting behind a hold move with the rest, or they would keep the page)
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
// As many quads a frame either way: a quad's indices are a quarter of its bytes.
let cap = match part {
Part::Indices => COMPACT_BUDGET / 4,
@@ -6111,6 +6321,8 @@ impl Renderer {
self.newest_lod_fade = None;
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
self.hold.clear();
self.lods.clear();
self.lod_cutout.clear();
self.lod_retiring.clear();
@@ -6181,6 +6393,13 @@ impl Renderer {
if want == self.packed_quads {
return;
}
let held_packed = |held: &HeldMesh| held.gpu.as_ref().is_some_and(|s| s.packed);
if !want && self.hold.sections().any(held_packed) {
// (held meshes in the layout going away take their place, and go with the rest)
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
}
self.packed_quads = want;
packed::pack_quads(want);
if !want && self.sections.values().any(|s| s.packed) {
@@ -6288,6 +6507,11 @@ impl Renderer {
if let Some(copies) = &mut self.capture_copies {
copies.remove_columns(&columns);
}
if !self.hold.is_empty() && !columns.is_empty() {
let gone = columns.iter().copied().collect();
let held = self.hold.take_columns(&gone);
self.release_meshes(held.into_iter().filter_map(|held| held.gpu).collect());
}
// Most of the view at once (a teleport at render distance 32 drops some 3700
// columns): a pass over each store instead of a lookup per section key, which
// took 10 ms of one frame. Far terrain fades the sections out instead.
@@ -10338,7 +10562,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
@@ -10800,7 +11024,7 @@ impl Renderer {
);
if self.hdr.is_some() {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(
0..translucent_particle_quads * 6,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
@@ -11150,10 +11374,18 @@ impl Renderer {
);
}
laps.lap("r_passes");
// What an SDR picture of the frame shows: on HDR output's layered frame its HUD target,
// the HUD over the world clipped to white.
let sdr_texture = self
.hdr
.as_ref()
.and_then(|h| h.hud())
.cloned()
.unwrap_or_else(|| target_texture.clone());
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
let shown_texture = match &fg_frames {
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
_ => &target_texture,
_ => &sdr_texture,
};
let readback = readback.or_else(|| {
self.screenshot
@@ -11191,7 +11423,7 @@ impl Renderer {
(Some(_), None) => {
if let Some(image) = &self.offscreen {
self.readback
.draw_onto(&self.device, &mut encoder, &target_texture, image);
.draw_onto(&self.device, &mut encoder, &sdr_texture, image);
}
}
(None, _) => {}
@@ -11382,8 +11614,15 @@ impl Renderer {
} else {
None
};
// HDR output's layered frame keeps the world after the hand and the camera overlays,
// as light (`Hdr::layered`); else it is kept here, before them.
let layered = self.hdr.as_ref().is_some_and(|h| h.layered());
if let Some(hdr) = &self.hdr {
hdr.keep_world(encoder, timing.as_mut());
if layered {
hdr.begin(&self.queue, !frame.title);
} else {
hdr.keep_world(encoder, timing.as_mut());
}
}
if overlay.hand_quads > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -11422,7 +11661,14 @@ impl Renderer {
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(encoder, timing.as_mut());
if let Some(view) = hdr.hand_light_view() {
// On the layered frame the hand's marks go to the frame, before they become
// light with the world's.
let marks = if layered {
Some(view_target.clone())
} else {
hdr.hand_light_view()
};
if let Some(view) = marks {
// The light sources in the hand may shine, the rest of it stays at the
// world's brightness: nothing the world kept here shows through it.
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -11454,11 +11700,19 @@ impl Renderer {
let quads = 0..overlay.hand_quads * 6;
pass.set_pipeline(&self.hdr_hand_unmark_pipeline);
pass.draw_indexed(quads.clone(), overlay.first_hand_vertex, 0..1);
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(quads, overlay.first_hand_vertex, 0..1);
}
}
}
// The layered frame's marks become light: what draws from here draws on light, and
// nothing is lifted after.
if layered
&& !frame.title
&& let Some(hdr) = &self.hdr
{
hdr.resolve(encoder, timing.as_mut());
}
// A native mod's turn after the hand, on the real frame only.
if overlay.real && !frame.title {
native_render::native_hand_drawn(&mut native_render::HandDrawn {
@@ -11591,6 +11845,16 @@ impl Renderer {
}
}
}
if layered && let Some(hdr) = &self.hdr {
hdr.keep_world(encoder, timing.as_mut());
}
// What the HUD and menus draw on: the layered frame's HUD target, else the frame.
let hud = self
.hdr
.as_ref()
.and_then(|h| h.hud())
.map(|t| t.create_view(&Default::default()));
let hud_target = hud.as_ref().unwrap_or(view_target);
let coverage = self.hdr.as_ref().and_then(|h| h.coverage_view());
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
@@ -11599,7 +11863,7 @@ impl Renderer {
// the screen effects.
if overlay.crosshair > 0 {
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: view_target,
view: hud_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -11640,7 +11904,7 @@ impl Renderer {
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
self.hud.draw_range(
encoder,
view_target,
hud_target,
coverage.as_ref(),
&self.depth,
frame.hud,
@@ -11652,18 +11916,38 @@ impl Renderer {
let format = self.frame_format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
let blur = self
.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software));
let size = [self.config.width, self.config.height];
match self.hdr.as_ref().filter(|_| layered) {
// The blur keeps light above white: it blurs the picture so far, the HUD over
// the world's light, which becomes the world the menu draws over.
Some(hdr) => {
let (_, source) = blur.source(device, size);
hdr.composite(encoder, &source, timing.as_mut());
blur.blur(
device,
&self.queue,
encoder,
view_target,
size,
radius,
timing.as_mut(),
);
hdr.keep_world(encoder, timing.as_mut());
}
None => blur.apply(
device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
size,
radius,
timing.as_mut(),
);
),
}
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
}
@@ -11672,7 +11956,7 @@ impl Renderer {
}
self.hud.draw_range(
encoder,
view_target,
hud_target,
coverage.as_ref(),
&self.depth,
frame.hud,
+463 -80
View File
@@ -72,9 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
/// hand marked it, for `resolve`.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
@@ -156,8 +176,7 @@ pub struct Hdr {
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
coverage: bool,
coverage_debug: bool,
layered: Option<Layered>,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
@@ -199,21 +218,23 @@ impl Hdr {
debug: Option<&str>,
) -> Self {
let coverage_debug = coverage && debug == Some("coverage");
// The layered frame's last pass, else today's.
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
let mut source = include_str!("../hdr.wgsl").to_owned();
if coverage_debug {
// The debug view wraps the last pass as it is, so the picture under the tint is
// exactly the one without it.
let entry =
"@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>";
assert!(
source.contains(entry),
"hdr.wgsl's fs_hdr changed its signature"
let entry = format!(
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
);
source = source.replace(entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
assert!(
source.contains(&entry),
"hdr.wgsl's {entry_point} changed its signature"
);
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
source.push_str(&entry);
source.push_str(
r#"
@group(0) @binding(4) var coverage: texture_2d<f32>;
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
r#" {
let picture = picture_hdr(pos);
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
// Magenta at paper white, in the output's own encoding.
@@ -228,46 +249,45 @@ impl Hdr {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let mut entries = vec![
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
let layout = |label, textures: &[u32], params: bool| {
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
];
if coverage_debug {
entries.push(texture(4));
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline_for = |format| {
};
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
if params {
entries.push(wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
}
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &entries,
})
};
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(layout)],
..Default::default()
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
@@ -280,7 +300,7 @@ impl Hdr {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
@@ -292,6 +312,30 @@ impl Hdr {
cache: None,
})
};
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
let display = layout("Horizon HDR bindings", layout_bindings, true);
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
Layered {
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
params: params(),
}
});
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -334,29 +378,20 @@ impl Hdr {
alpha: scale_alpha,
}),
);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
logo: logo_mask(device, queue),
layout,
pipeline: pipeline_for(output.format()),
capture_pipeline: pipeline_for(Output::Pq.format()),
layout: display,
clear_alpha,
hand_mark,
params: params(),
capture_pipeline: pipeline_for(Output::Pq.format()),
capture_params: params(),
output,
format,
targets: None,
debug: debug == Some("1"),
coverage,
coverage_debug,
layered,
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
@@ -398,25 +433,160 @@ impl Hdr {
self.format,
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let coverage = self.coverage.then(|| {
let layered = self.layered.is_some();
let coverage = layered.then(|| {
texture(
"HUD coverage",
super::hud_coverage::FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
let group = self.group(device, &frame, &world, coverage.as_ref(), &self.params);
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
// off; the last pass clips it at white.
let hud = layered.then(|| {
texture(
"HUD",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
let group = self.group(
device,
&frame,
&world,
coverage.as_ref(),
hud.as_ref(),
&self.params,
);
let passes = self
.layered
.as_ref()
.zip(coverage.as_ref().zip(hud.as_ref()))
.map(|(l, (coverage, hud))| {
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let (frame, world, coverage, hud) =
(view(&frame), view(&world), view(coverage), view(hud));
let entry = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
let params = wgpu::BindGroupEntry {
binding: 2,
resource: l.params.as_entire_binding(),
};
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR layered bindings"),
layout,
entries,
})
};
[
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
group(&l.keep.0, &[entry(0, &frame)]),
group(
&l.composite.0,
&[
entry(0, &frame),
params,
entry(4, &coverage),
entry(5, &hud),
],
),
]
});
self.targets = Some(Targets {
size,
frame,
world,
coverage,
hud,
group,
passes,
});
}
self.targets.as_ref().unwrap().frame.clone()
}
/// The float frame with HUD coverage (`Layered`).
pub fn layered(&self) -> bool {
self.layered.is_some()
}
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
/// once `keep_world` made it, and which an ordinary screenshot shows.
pub fn hud(&self) -> Option<&wgpu::Texture> {
self.targets.as_ref()?.hud.as_ref()
}
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
if let Some(l) = &self.layered {
queue.write_buffer(
&l.params,
0,
bytemuck::cast_slice(&self.params(world, false)),
);
}
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
};
let Some([group, ..]) = &t.passes else {
return;
};
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(
encoder,
&l.resolve.1,
group,
&view,
"HDR marks to light",
timing,
);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
pub fn composite(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if let (
Some(l),
Some(Targets {
passes: Some([_, _, group]),
..
}),
) = (&self.layered, &self.targets)
{
Self::draw(
encoder,
&l.composite.1,
group,
target,
"HDR composite",
timing,
);
}
}
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
Some(
self.targets
@@ -440,6 +610,9 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
///
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -448,6 +621,11 @@ impl Hdr {
let Some(t) = &self.targets else {
return;
};
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
let view = hud.create_view(&Default::default());
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
return;
}
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
@@ -475,13 +653,14 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
/// so it needs no mark there.
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
return;
};
let view = t.frame.create_view(&Default::default());
@@ -512,9 +691,10 @@ impl Hdr {
}
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
/// reads for it too), and what tints the world rather than covering it.
/// reads for it too), and what tints the world rather than covering it. None on the
/// layered frame, where both draw on the frame before `keep_world`.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
Some(t.world.create_view(&Default::default()))
}
@@ -540,7 +720,15 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
let view = target.create_view(&Default::default());
Self::draw(
encoder,
&self.pipeline,
&t.group,
&view,
"HDR output",
timing,
);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -568,6 +756,7 @@ impl Hdr {
&t.frame,
&t.world,
t.coverage.as_ref(),
t.hud.as_ref(),
&self.capture_params,
);
queue.write_buffer(
@@ -575,7 +764,15 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
let view = texture.create_view(&Default::default());
Self::draw(
encoder,
&self.capture_pipeline,
&group,
&view,
"HDR output",
None,
);
Some(texture)
}
@@ -585,26 +782,27 @@ impl Hdr {
frame: &wgpu::Texture,
world: &wgpu::Texture,
coverage: Option<&wgpu::Texture>,
hud: Option<&wgpu::Texture>,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
let frame = frame.create_view(&Default::default());
let world = world.create_view(&Default::default());
let coverage = coverage.map(|t| t.create_view(&Default::default()));
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let (frame, world) = (view(frame), view(world));
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
let texture = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
let mut entries = vec![
texture(0, &frame),
texture(1, &world),
wgpu::BindGroupEntry {
binding: 2,
resource: params.as_entire_binding(),
},
texture(3, &self.logo),
];
if self.coverage_debug {
entries.push(texture(4, coverage.as_ref().unwrap()));
match &layered {
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
None => entries.push(texture(1, &world)),
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
@@ -641,16 +839,17 @@ impl Hdr {
params
}
/// One full-screen pass of `pipeline` into `view`.
fn draw(
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
view: &wgpu::TextureView,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -659,8 +858,8 @@ impl Hdr {
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
});
@@ -723,7 +922,7 @@ mod tests {
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn coverage_target_resizes_resets_and_is_sampled_only_by_the_debug_view() {
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
@@ -808,6 +1007,190 @@ mod tests {
}
}
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Clears all of `texture` to `color`.
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
let view = texture.create_view(&Default::default());
let mut attachment = super::super::hud_coverage::attachment(&view);
let [r, g, b, a] = color;
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(attachment)],
..Default::default()
});
}
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
/// for HDR10.
fn texel(
device: &wgpu::Device,
queue: &wgpu::Queue,
mut encoder: wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> [f32; 4] {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
if texture.format() == Output::Pq.format() {
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
}
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
#[test]
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
let Some((device, queue)) = gpu() else {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, world);
if layered {
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
hdr.keep_world(&mut encoder, None);
}
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
[0.8, 0.7, 0.6, 0.88],
[0.9, 0.8, 0.4, 0.05],
[0.9, 0.8, 0.4, 0.2],
[0.9, 0.8, 0.4, 0.4],
[0.9, 0.8, 0.4, 0.6],
] {
let (today, layered) = (shown(false, world), shown(true, world));
// Half floats round the light apart by a step at most.
assert!(
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
"{world:?}: {today:?} {layered:?}"
);
if world[3] < 1.0 {
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
}
}
}
#[test]
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: F::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// The world's light, the HUD's target over it (on the world clipped to white), its
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
for (world, hud, coverage, ui, want) in [
// Nothing over it: the world, light above white included.
(3.0, 1.0, 0.0, 1.0, 3.0),
// Half-transparent black: half of the world shows, as the HUD blends.
(2.0, 0.5, 0.5, 1.0, 1.0),
// Covered: the HUD as drawn, at UI Brightness.
(2.0, 0.3, 1.0, 1.0, 0.3),
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
// Added over the world (the text highlight), which it doesn't hide.
(0.6, 0.8, 0.0, 1.0, 0.8),
// At or below white, at 100%: the HUD's picture as drawn.
(0.7, 0.45, 0.5, 1.0, 0.45),
] {
hdr.ui = ui;
let frame = hdr.frame(&device, [1, 1]);
let t = hdr.targets.as_ref().unwrap();
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [world, world, world, 1.0]);
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
hdr.begin(&queue, true);
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
let got = texel(&device, &queue, encoder, &target);
assert!(
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
"{world} {hud} {coverage} {ui}: {got:?}"
);
}
}
#[test]
fn the_menu_blur_keeps_the_light_it_blurs() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let size = [8, 8];
let frame = hdr.frame(&device, size);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
let (_, source) = blur.source(&device, size);
hdr.begin(&queue, true);
hdr.composite(&mut encoder, &source, None);
let view = frame.create_view(&Default::default());
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
hdr.keep_world(&mut encoder, None);
let hud = hdr.hud().unwrap().clone();
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
let [r, ..] = texel(&device, &queue, encoder, &frame);
assert!((r - 4.0).abs() < 0.01, "{r}");
let encoder = device.create_command_encoder(&Default::default());
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
}
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
@@ -0,0 +1,335 @@
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
//! screen yet (terrain streaming in) are not held.
//!
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
use super::SectionKey;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::time::{Duration, Instant};
/// The longest a hold lasts.
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
/// The most bytes of new meshes that wait.
pub(super) const MAX_BYTES: u64 = 1 << 30;
/// The most circles a hold is kept to (`WorldHold::hold_within`).
pub const MAX_AREAS: usize = 64;
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
/// holds all of it, and circles join.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldHold {
all: bool,
within: Vec<[f64; 3]>,
}
impl WorldHold {
/// Hold the whole world's terrain as it is drawn, this frame.
pub fn hold(&mut self) {
self.all = true;
}
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
/// circle that isn't finite or has a negative radius is left out, as are those past
/// `MAX_AREAS`.
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
if good && self.within.len() < MAX_AREAS {
self.within.push([x, z, radius]);
}
}
/// Whether anything is held.
pub fn is_held(&self) -> bool {
self.all || !self.within.is_empty()
}
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
pub fn covers(&self, key: SectionKey) -> bool {
if self.all {
return true;
}
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
self.within
.iter()
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
}
}
/// What a frame's ask means for the sections waiting.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Release {
/// They go on waiting.
None,
/// Those the hold no longer covers take their place.
Outside,
/// All of them take their place.
All,
}
/// A section waiting behind the hold and the bytes its new mesh takes.
struct Waiting<S> {
section: S,
bytes: u64,
}
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
pub(super) struct Hold<S> {
ask: WorldHold,
since: Option<Instant>,
spent: bool,
waiting: FxHashMap<SectionKey, Waiting<S>>,
bytes: u64,
}
impl<S> Default for Hold<S> {
fn default() -> Self {
Self {
ask: WorldHold::default(),
since: None,
spent: false,
waiting: FxHashMap::default(),
bytes: 0,
}
}
}
impl<S> Hold<S> {
/// Whether a new mesh of section `key` waits.
pub fn holds(&self, key: SectionKey) -> bool {
self.since.is_some() && !self.spent && self.ask.covers(key)
}
pub fn is_empty(&self) -> bool {
self.waiting.is_empty()
}
pub fn bytes(&self) -> u64 {
self.bytes
}
/// A frame's ask, at `now`.
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
if !ask.is_held() {
let was = self.since.is_some();
(self.since, self.spent) = (None, false);
self.ask = WorldHold::default();
return if was && !self.waiting.is_empty() {
Release::All
} else {
Release::None
};
}
if self.spent {
return Release::None;
}
let since = *self.since.get_or_insert(now);
if now.saturating_duration_since(since) >= MAX_HOLD {
log::warn!(
"Horizon: a mod held the world for {} s: let go",
MAX_HOLD.as_secs()
);
self.spend();
return Release::All;
}
if *ask == self.ask {
return Release::None;
}
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
self.ask.clone_from(ask);
if self.waiting.is_empty() {
Release::None
} else {
Release::Outside
}
}
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
/// What waited is the caller's to place (`take_all`) or drop.
pub fn spend(&mut self) {
self.spent = self.since.is_some();
}
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
/// The bool is whether what waits is now too much (`MAX_BYTES`).
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
let old = self.take(key);
self.bytes += bytes;
self.waiting.insert(key, Waiting { section, bytes });
(old, self.bytes > MAX_BYTES)
}
/// Section `key`'s waiting mesh, no longer waiting.
pub fn take(&mut self, key: SectionKey) -> Option<S> {
if self.waiting.is_empty() {
return None;
}
let w = self.waiting.remove(&key)?;
self.bytes -= w.bytes;
Some(w.section)
}
/// Everything waiting, no longer waiting.
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
self.bytes = 0;
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
}
/// What waits that the hold no longer covers.
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
let ask = &self.ask;
let all = self.since.is_none() || self.spent;
let out: Vec<_> = self
.waiting
.extract_if(|&k, _| all || !ask.covers(k))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(k, w)| (k, w.section)).collect()
}
/// What waits in these columns, which are going.
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
if self.waiting.is_empty() {
return Vec::new();
}
let out: Vec<_> = self
.waiting
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(_, w)| w.section).collect()
}
/// The sections are all gone: what waits goes with them and the hold is spent.
pub fn clear(&mut self) {
self.waiting.clear();
self.bytes = 0;
self.spend();
}
pub fn sections(&self) -> impl Iterator<Item = &S> {
self.waiting.values().map(|w| &w.section)
}
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
self.waiting.values_mut().map(|w| &mut w.section)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all() -> WorldHold {
let mut h = WorldHold::default();
h.hold();
h
}
fn within(circles: &[[f64; 3]]) -> WorldHold {
let mut h = WorldHold::default();
for &[x, z, r] in circles {
h.hold_within(x, z, r);
}
h
}
#[test]
fn a_hold_covers_sections_by_their_middle() {
assert!(all().covers((900, 0, -900)));
assert!(!WorldHold::default().covers((0, 0, 0)));
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
}
#[test]
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
let h = within(&[
[f64::NAN, 0.0, 5.0],
[0.0, f64::INFINITY, 5.0],
[0.0, 0.0, -1.0],
[0.0, 0.0, f64::INFINITY],
]);
assert!(!h.is_held());
let mut many = WorldHold::default();
for i in 0..MAX_AREAS + 10 {
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
}
assert_eq!(many.within.len(), MAX_AREAS);
// (several mods: all of it from one holds all of it)
many.hold();
assert!(many.covers((-5000, 0, 5000)));
}
#[test]
fn a_hold_starts_and_lets_go_when_no_one_asks() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t), Release::None);
assert!(g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
// (nothing waited: nothing to place)
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
g.ask(&all(), t);
g.wait((0, 0, 0), 7, 100);
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
assert!(!g.holds((0, 0, 0)));
}
#[test]
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
assert!(!g.holds((0, 0, 0)));
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
g.ask(&all(), t + MAX_HOLD * 3);
assert!(g.holds((0, 0, 0)));
}
#[test]
fn smaller_circles_let_go_what_they_leave() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
g.wait((0, 0, 0), 1, 10);
g.wait((5, 0, 0), 2, 20);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
assert!(!g.holds((5, 0, 0)));
// (to all of it again: nothing more is let go, and all of it waits again)
assert_eq!(g.ask(&all(), t), Release::Outside);
assert!(g.take_outside().is_empty());
assert!(g.holds((5, 0, 0)));
}
#[test]
fn what_waits_is_counted_and_capped() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
// (a newer mesh of the same section replaces it, and the old one comes back to free)
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
assert_eq!(g.bytes(), MAX_BYTES - 20);
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
assert_eq!(g.take((1, 0, 0)), Some(3));
assert_eq!(g.take((1, 0, 0)), None);
assert_eq!(g.bytes(), MAX_BYTES - 19);
}
#[test]
fn columns_going_and_the_sections_cleared_drop_what_waits() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
g.wait((0, 0, 0), 1, 10);
g.wait((0, 5, 0), 2, 10);
g.wait((3, 0, 0), 3, 10);
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
let mut dropped = g.take_columns(&gone);
dropped.sort_unstable();
assert_eq!(dropped, vec![1, 2]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
g.clear();
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
// (cleared while held: spent until let go; with no hold, nothing to end)
assert_eq!(g.ask(&all(), t), Release::None);
assert!(!g.holds((3, 0, 0)));
let mut none = Hold::<u32>::default();
none.clear();
none.ask(&all(), t);
assert!(none.holds((3, 0, 0)));
}
}
@@ -49,9 +49,23 @@ struct HorizonMaterialOut {
@location(14) @interpolate(flat) normal: vec3<f32>,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
light: vec2<u32>, origin: vec4<f32>) -> HorizonMaterialOut {
let pos = horizon_material_displace(position + origin.xyz, uv, layer, color, light);
var out = horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
// The section's fade-in (`RenderSection.getVisibility`), as how far it is from whole, in the
// high 16 bits of `hdr_code`: no varying of its own, and 0 (whole) where nothing fades. It is
// the same for the whole section, so the flat varying loses nothing.
let instance = horizon_terrain_instance_visibility(origin.w);
out.hdr_code |= u32(round((1.0 - instance) * 65535.0)) << 16u;
return out;
}
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
@@ -94,7 +108,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
color, light, origin);
}
@vertex fn horizon_material_vs_flat(
@builtin(vertex_index) vertex: u32,
@@ -103,7 +117,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
) -> HorizonMaterialOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
color, light, origin);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@@ -181,9 +195,10 @@ struct HorizonMaterialSolid {
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let code = in.hdr_code & 15u;
if code > 0u {
let whole = code > 4u;
let tier = select(code, code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
@@ -202,7 +217,7 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
return horizon_material_solid(in);
}
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let color = horizon_material_faded(in, horizon_material_color(in, false));
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
@@ -216,7 +231,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
let color = horizon_material_faded(in, horizon_material_color(in, true));
if color.a < 0.01 { discard; }
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
@@ -582,6 +597,25 @@ mod tests {
report.stages.remove(0)
}
/// The fade-in packed above the HDR code: a section a quarter visible (`section_instance`
/// with step 1: mantissa `1 - visibility` = 0.75) keeps its visibility through the code, and
/// a fragment 24 blocks away, half-way into the 16-block blend, is 1 - (1 - 0.25) / 2 = 0.625
/// visible; the code's low nibble survives.
#[test]
fn the_fade_in_rides_the_hdr_code() {
let w = f32::from_bits(1.0f32.to_bits() | (0.75 * 8_388_608.0) as u32);
// `horizon_material_vertex`.
let instance = 1.0 - (w.to_bits() & 0x7fffff) as f32 / 8_388_608.0;
let code = 7u32 | (((1.0 - instance) * 65535.0).round() as u32) << 16;
// `horizon_material_faded` and `horizon_material_mark`.
let unpacked = 1.0 - (code >> 16) as f32 / 65535.0;
assert!((unpacked - 0.25).abs() < 1.0 / 65535.0);
let blend = ((24.0f32 - 16.0) / 16.0).clamp(0.0, 1.0);
let visibility = 1.0 + (unpacked - 1.0) * blend;
assert!((visibility - 0.625).abs() < 1.0 / 65535.0);
assert_eq!(code & 15, 7);
}
#[test]
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
let terrain = vanilla_terrain();
@@ -728,6 +728,13 @@ impl PackAtlas {
),
("mc_Entity", entity),
("at_midBlock", mid_block),
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
// face shines (`mesher::WHOLE`).
(
"horizonTier",
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
),
(
"mc_midTexCoord",
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
flat,
geometry,
origin,
0x80ffffff,
);
let fade = if requested { expected } else { 1.0 };
let expected = [
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
assert_eq!(count, 98);
}
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
/// read by a pack's terrain program, flat or instanced.
#[test]
fn terrain_tier_reaches_pack_pixels() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
return;
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.vsh"),
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
)
.unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.fsh"),
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
// No light, low, very bright, and bright all over (4 more).
let cases = [
(0, 0.0),
(LOW, 1.0),
(VERY_BRIGHT, 4.0),
(BRIGHT | WHOLE, 7.0),
];
for flat in [false, true] {
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
..Default::default()
},
);
let program = post
.terrain
.as_ref()
.unwrap_or_else(|| panic!("{:?}", post.notes));
for (tier, expected) in cases {
let origin = section_instance([0.0; 3], 0, 1.0);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
false,
origin,
emitting(0xffff_ffff, tier),
);
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
}
}
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -190,6 +281,7 @@ fn sample(
flat: bool,
geometry: bool,
origin: [f32; 4],
color: u32,
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -209,7 +301,7 @@ fn sample(
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -1628,3 +1632,44 @@ mod velocity_translate_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod tier_translate_tests {
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
#[test]
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
std::fs::write(
shaders.join("gbuffers_entities.vsh"),
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let m = horizon_shaders::Macros::standard();
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
for (who, inputs) in [
("entities", super::inputs()),
("hand", super::super::pack_hand::inputs()),
] {
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
assert!(attribute.is_some(), "{who}");
horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
"hasSkylight",
"hasCeiling",
"cloudTime",
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
// many, and each one's camera-relative position and light level (zeros past the count).
"horizonDynamicLightCount",
"horizonDynamicLight0",
"horizonDynamicLight1",
"horizonDynamicLight2",
"horizonDynamicLight3",
"horizonDynamicLight4",
"horizonDynamicLight5",
"horizonDynamicLight6",
"horizonDynamicLight7",
"horizonDynamicLight8",
"horizonDynamicLight9",
"horizonDynamicLight10",
"horizonDynamicLight11",
"horizonDynamicLight12",
"horizonDynamicLight13",
"horizonDynamicLight14",
"horizonDynamicLight15",
"isElytraFlying",
"cloudHeight",
];
@@ -929,6 +948,12 @@ impl FrameUniforms {
"gbufferProjectionInverse" => m4(projection_inverse),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
n if dynamic_light_index(n).is_some() => Value::f(
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
.copied()
.unwrap_or([0.0; 4]),
),
"heldItemId" => Value::i(&[inputs.held_items[0]]),
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
@@ -1785,6 +1810,29 @@ pub(super) mod initial_history_tests {
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
}
#[test]
fn the_dynamic_lights_reach_the_pack_nearest_first() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
f.dynamic_lights = &lights;
let mut u = FrameUniforms::default();
u.update(&f, &PackInputs::default(), [800, 450]);
let count = index("horizonDynamicLightCount").unwrap();
assert_eq!(int(&u, count), 2);
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
// Past the count, and the last of the sixteen: zeros.
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
assert_eq!(index("horizonDynamicLight16"), None);
// Off (no lights): none.
f.dynamic_lights = &[];
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(int(&u, count), 0);
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -2534,6 +2582,12 @@ pub(super) mod initial_history_tests {
}
}
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
fn dynamic_light_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
(k < crate::dynamic_light::MAX).then_some(k)
}
#[cfg(test)]
#[path = "pack_uniforms/ftransform_test.rs"]
mod ftransform_test;
+107 -37
View File
@@ -33,6 +33,11 @@ struct Params {
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// much of the world it hides there (gfx/hud_coverage.rs).
@group(0) @binding(4) var coverage: texture_2d<f32>;
@group(0) @binding(5) var hud: texture_2d<f32>;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
@@ -149,49 +154,29 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(0.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
let size = vec2<f32>(textureDimensions(frame));
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
let nits = vec3(calibration_nits(pos.xy, size));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
struct Lit {
light: vec3<f32>,
lifted: bool,
};
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
if shows && a >= 0.75 && a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
@@ -200,10 +185,13 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
@@ -220,9 +208,27 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
return Lit(min(light, vec3(params.peak)), lifted);
}
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
fn calibrating(pos: vec4<f32>) -> bool {
let size = vec2<f32>(textureDimensions(frame));
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
}
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
// lifted.
fn display(light: vec3<f32>) -> vec4<f32> {
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
@@ -233,3 +239,67 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
return vec4(scrgb, 1.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
}
let at = vec2<i32>(pos.xy);
let f = frame_texel(textureLoad(frame, at, 0));
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
let w = frame_texel(textureLoad(world, at, 0));
// The world's marks lift only where nothing was drawn over them since: grey text over
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
var light = lit(f.rgb, w.a, shows).light;
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
}
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
// the HUD found it before.
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
}
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
}
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
}
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
}
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
}
+29 -5
View File
@@ -202,20 +202,44 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
let copy = self.source(device, size).0;
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[copy].0.as_image_copy(),
copy.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
self.blur(device, queue, encoder, target, size, radius, timing);
}
/// The blur's copy of a frame of `size`, for a picture drawn into it rather than copied
/// (HDR output's composite, `Hdr::composite`), then `blur`.
pub fn source(
&mut self,
device: &wgpu::Device,
size: [u32; 2],
) -> (wgpu::Texture, wgpu::TextureView) {
self.ensure(device, size);
self.targets[if self.half { 2 } else { 0 }].clone()
}
/// Blur what `source` holds into `target`, as `apply` blurs its copy of the frame.
#[allow(clippy::too_many_arguments)]
pub fn blur(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
@@ -42,7 +42,12 @@ struct OitFragment {
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
let opaque = oit_linear_depth(b.a);
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
// The later stages test GreaterEqual against this depth, so the opaque fragment itself must still
// pass: Metal's `position.z` can differ from the rasterizer's depth by an ulp, which dropped
// random pixels of an opaque body. A few ulps farther (a positive float's bits minus one is
// the next smaller value) keeps it, and culls anything behind it by a visible distance.
let culled_depth = select(0.0, bitcast<f32>(bitcast<u32>(b.a) - 4u), b.a > 0.0);
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), culled_depth);
}
// The sum over the world, renormalized to the coverage the transform gives.
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
+22 -4
View File
@@ -317,6 +317,7 @@ impl Opt {
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::ModelPart(i) => [
@@ -864,6 +865,11 @@ impl Opt {
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadNintendoButtons => tr(if s.controller.nintendo_letters {
"horizon.options.nintendo_buttons.letter"
} else {
"horizon.options.nintendo_buttons.position"
}),
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
@@ -1151,6 +1157,9 @@ impl Opt {
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
Opt::PadNintendoButtons => {
s.controller.nintendo_letters = !s.controller.nintendo_letters
}
Opt::PadLightbar => {
let all = gamepad::Lightbar::ALL;
let i = all
@@ -2238,6 +2247,11 @@ impl OptionsUi {
_ => None,
}
}
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
@@ -3708,6 +3722,8 @@ impl OptionsUi {
let in_world = self.in_world;
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
// The keys lay the screen out at the size it was drawn at.
self.size = (batch.width, batch.height);
return;
}
if self.page() == Page::SplitSetup {
@@ -4923,10 +4939,12 @@ impl OptionsUi {
}
return events;
}
let gui = (
self.size.0.max(MIN_KEY_GUI.0),
self.size.1.max(MIN_KEY_GUI.1),
);
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
self.size
} else {
MIN_KEY_GUI
};
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
+18 -8
View File
@@ -60,6 +60,10 @@ pub struct ParticleOptions {
pub mod_particle: Option<String>,
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
pub legacy_entity_effect: Option<bool>,
/// A pre-1.21.4 server's trail, which had no duration: TrailParticle.Provider chose
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
/// server only, never read from the wire.
pub legacy_trail_lifetime: bool,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -75,6 +79,7 @@ impl ParticleOptions {
vibration: None,
mod_particle: None,
legacy_entity_effect: None,
legacy_trail_lifetime: false,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
@@ -3485,9 +3490,7 @@ impl ParticleEngine {
scaled(o.color[2], jb),
);
q.size = 0.26;
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
q.lifetime = if duration == -1 {
q.lifetime = if o.legacy_trail_lifetime {
self.level_random.next_int_bound(40).unwrap_or(0) + 10
} else {
duration
@@ -5764,12 +5767,12 @@ mod tests {
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(1, 42, 2);
let legacy = ParticleOptions {
legacy_trail_lifetime: true,
..ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1)
};
for _ in 0..5 {
engine.create(
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
[0.0; 3],
[0.0; 3],
);
engine.create(&legacy, [0.0; 3], [0.0; 3]);
}
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
assert_eq!(
@@ -5786,6 +5789,13 @@ mod tests {
[0.0; 3],
);
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
// A current server's -1 is a lifetime like any other, not the old marker.
engine.create(
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
[0.0; 3],
[0.0; 3],
);
assert_eq!(engine.pending.last().unwrap().lifetime, -1);
}
#[test]
fn sizes_follow_vanilla_curves() {
@@ -845,6 +845,8 @@ pub enum Opt {
PadLookDeadzone,
PadTriggerThreshold,
PadGlyphs,
/// A Nintendo pad's face buttons by place or by letter (`PadSettings::nintendo_letters`).
PadNintendoButtons,
PadKeyboard,
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
@@ -14,6 +14,7 @@ use crate::{
text::Text,
ui,
};
use std::rc::Rc;
/// The screen's parts in GUI pixels. Down the left: the mark, the search
/// field, the page list, and Undo and Done at the foot. On the right: the
@@ -865,6 +866,11 @@ pub(crate) trait Screen {
NOTE_LINES
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<Self::Page, Self::Row>>;
/// A fingerprint ([`stamp_of`]) of what `sheets` reads that can change while the screen is
/// open, besides through `press`, `slide` and `wheel_step` (the shell builds again after
/// those): the rows are built again only when it changes, or the search, the page, the size or
/// the language does.
fn stamp(&self, s: &Settings) -> u64;
/// A row was pressed (`back`: Shift held); the shell has played the click.
fn press(&mut self, s: &mut Settings, row: Self::Row, back: bool) -> Vec<Output>;
/// A slider row moved to `position`.
@@ -881,6 +887,23 @@ pub(crate) trait Screen {
}
}
/// A fingerprint of `parts` for [`Screen::stamp`]: their `Debug` text, hashed as it is written,
/// without keeping it.
pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
use std::{fmt::Write, hash::Hasher};
struct Hashing(std::collections::hash_map::DefaultHasher);
impl Write for Hashing {
fn write_str(&mut self, text: &str) -> std::fmt::Result {
self.0.write(text.as_bytes());
Ok(())
}
}
let mut hashing = Hashing(Default::default());
// Writing into a hasher can't fail.
let _ = write!(hashing, "{parts:?}");
hashing.0.finish()
}
/// A [`Shell`] of any screen, as `OptionsUi` drives it.
pub(crate) trait Hosted {
/// The screen opens, afresh.
@@ -907,6 +930,10 @@ pub(crate) trait Hosted {
) -> Vec<Output>;
/// Lets go of a slider (true: it plays its click) or the scrollbar.
fn release(&mut self) -> bool;
/// A controller's or the keyboard's focus is on the row at `rect`: if the list hides part of
/// it, it scrolls all into view (`AbstractSelectionList.setSelected`). How far the row moved
/// down, when it moved.
fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32>;
/// The mouse wheel at `at`: it scrolls, unless the row under it takes the step (`wheel_step`).
fn wheel(
&mut self,
@@ -961,6 +988,19 @@ struct View<P, R> {
content: f32,
}
/// What a [`View`] was built for: when any of it changes, the rows are built again.
#[derive(PartialEq)]
struct Built<P, R> {
stamp: u64,
/// Which language the rows' text is in (`lang::generation`).
language: u64,
size: (f32, f32),
query: String,
page: Option<P>,
/// While searching, the rows the focus and a drag keep in the list.
kept: (Option<R>, Option<R>),
}
/// A screen on the shell, with all that isn't the screen's: where the list is, what has the focus,
/// what is held and what is searched.
pub(crate) struct Shell<S: Screen> {
@@ -980,6 +1020,8 @@ pub(crate) struct Shell<S: Screen> {
/// While searching, the page last picked in the page list: current until the list scrolls.
picked: Option<S::Page>,
waiting: Option<&'static str>,
/// The rows as last built, and what for.
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
}
impl<S: Screen + Default> Default for Shell<S> {
fn default() -> Self {
@@ -998,6 +1040,7 @@ impl<S: Screen + Default> Default for Shell<S> {
page: None,
picked: None,
waiting: None,
built: None,
}
}
}
@@ -1008,10 +1051,43 @@ impl<S: Screen> Shell<S> {
fn shows_focus(&self, target: Target<S::Page, S::Row>) -> bool {
self.keyed && self.focus == Some(target)
}
/// The screen's rows now: the open page's or, while searching, every page's whose name or
/// The screen's rows now, built again only when what they were built for changed
/// ([`Built`]). The scroll is kept within them.
fn view(&mut self, s: &Settings, size: (f32, f32)) -> Rc<View<S::Page, S::Row>> {
let query = self.query();
let searching = !query.is_empty();
let focused = match self.focus {
Some(Target::Row(row)) if searching => Some(row),
_ => None,
};
let key = Built {
stamp: self.screen.stamp(s),
language: lang::generation(),
size,
query,
page: self.page,
kept: (focused, self.dragging.filter(|_| searching)),
};
let view = match &self.built {
Some((built, view)) if *built == key => view.clone(),
_ => {
let view = Rc::new(self.build(s, size));
self.built = Some((key, view.clone()));
view
}
};
self.scroll
.clamp(view.cols.top, view.cols.bottom, view.content);
view
}
/// A row may read otherwise now: the screen changed something.
fn rebuild(&mut self) {
self.built = None;
}
/// The screen's rows: the open page's or, while searching, every page's whose name or
/// description has the search in it, under their pages' names. The row being changed stays
/// whatever it reads now. The scroll is kept within it.
fn view(&mut self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
/// whatever it reads now.
fn build(&self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
let cols = Columns::of(w, h).with_note_lines(self.screen.note_lines());
let query = self.query();
let sheets = self.screen.sheets(s);
@@ -1048,7 +1124,6 @@ impl<S: Screen> Shell<S> {
}
}
let (lines, content) = lines.finish();
self.scroll.clamp(cols.top, cols.bottom, content);
View {
cols,
pages,
@@ -1136,12 +1211,14 @@ impl<S: Screen> Shell<S> {
};
self.dragging = Some(row.id);
self.screen.slide(s, row.id, position);
self.rebuild();
vec![DRAGGED]
}
_ => {
self.can_change_value = false;
let mut outputs = vec![CLICK];
outputs.extend(self.screen.press(s, row.id, back));
self.rebuild();
outputs
}
}
@@ -1171,6 +1248,22 @@ impl<S: Screen> Shell<S> {
out.push((Target::Done, cols.button(0)));
out
}
/// Where Down from the search field goes on a screen with no page list, whose foot is under the
/// field instead: the first row to use that is all in view.
fn first_row(
&self,
view: &View<S::Page, S::Row>,
) -> Option<(Target<S::Page, S::Row>, [f32; 4])> {
if !self.tabs(view).is_empty() {
return None;
}
let cols = &view.cols;
let mut placed = self.placed(view);
let first = placed.find(|&(row, y)| {
row.live && row.value != Value::Note && y >= cols.top && y + style::ROW <= cols.bottom
});
first.map(|(row, y)| (Target::Row(row.id), cols.row(y)))
}
/// Focus `target`, scrolling its row into the list's view.
fn focus_on(
&mut self,
@@ -1325,6 +1418,9 @@ impl<S: Screen + Default> Hosted for Shell<S> {
},
);
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
if let Some((_, row)) = self.first_row(&view) {
crate::widgets::focus_link(cols.search(), true, row);
}
self.draw_dock(batch, atlas, &view, described, current);
let done = cols.button(0);
let label = Text::literal(lang::get("Done"));
@@ -1358,6 +1454,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
if let Some(row) = self.dragging {
self.screen.slide(s, row, cols.slider_at(x));
self.rebuild();
return vec![DRAGGED];
}
return Vec::new();
@@ -1409,6 +1506,22 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.scroll.release();
self.dragging.take().is_some()
}
fn reveal(&mut self, s: &Settings, size: (f32, f32), [x, y, w, h]: [f32; 4]) -> Option<f32> {
let view = self.view(s, size);
let cols = &view.cols;
let middle = (x + w / 2.0, y + h / 2.0);
let (_, row_y) = self.placed(&view).find(|&(_, ry)| {
let [rx, ry, rw, rh] = cols.row(ry);
ui::hovered(middle, rx, ry, rw, rh)
})?;
let before = self.scroll.amount;
scroll_into_view(&mut self.scroll, cols.row(row_y), cols, view.content);
let moved = before - self.scroll.amount;
(moved != 0.0).then(|| {
self.picked = None;
moved
})
}
fn wheel(
&mut self,
s: &mut Settings,
@@ -1421,6 +1534,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
{
self.rebuild();
return outputs;
}
}
@@ -1471,6 +1585,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.focus_on(focusables[next], &view);
return Vec::new();
}
if key == K::ArrowDown
&& self.focus == Some(Target::Search)
&& let Some(first) = self.first_row(&view)
{
self.keyed = true;
self.focus_on(first, &view);
return Vec::new();
}
let focused = view
.rows
.iter()
@@ -1486,6 +1608,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
let width = view.cols.track().1 + 8.0;
let next = slider_step(position, steps, width, key == K::ArrowRight);
self.screen.slide(s, row.id, next);
self.rebuild();
return vec![DRAGGED];
}
if matches!(
@@ -1576,6 +1699,13 @@ impl<S: Screen> Shell<S> {
pub(crate) fn scroll_amount(&self) -> f32 {
self.scroll.amount
}
/// The row the shell's own focus is on.
pub(crate) fn focused_row(&self) -> Option<S::Row> {
match self.focus {
Some(Target::Row(row)) => Some(row),
_ => None,
}
}
/// The captions of the rows the list has now, as the widget tree reads them.
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
let view = self.view(s, (854.0, 480.0));
@@ -1601,6 +1731,8 @@ mod tests {
slider: f32,
choice: usize,
pressed: Vec<(u32, bool)>,
/// How many times its rows were built.
built: std::cell::Cell<usize>,
}
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
const SLIDER: u32 = 0;
@@ -1614,7 +1746,12 @@ mod tests {
fn title(&self) -> &'static str {
"Probe"
}
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
/// Its rows read the field of view, besides its own state.
fn stamp(&self, s: &Settings) -> u64 {
stamp_of(&s.fov)
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
self.built.set(self.built.get() + 1);
let (value, chosen) = CHOICES[self.choice];
let mut rows = vec![
Row::new(
@@ -1623,7 +1760,7 @@ mod tests {
Value::Slider {
position: self.slider,
steps: Some(10),
text: "x".to_owned(),
text: s.fov.to_string(),
},
),
Row {
@@ -1677,6 +1814,90 @@ mod tests {
shell.point_at(&Settings::default(), size, row)
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
/// away from any row, at the size it was drawn at.
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
let mut batch = HudBatch::new([1708, 960]);
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
let size = (batch.width, batch.height);
shell.scroll(s, size, -1.0);
shell.key(s, K::ArrowDown, None, false, size);
shell.wheel(s, size, (0.0, 0.0), 1.0);
size
}
#[test]
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
let atlas = atlas();
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let built = |shell: &Shell<Probe>| shell.screen.built.get();
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
let view = shell.view(s, size);
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
labels.any(|label| label.ends_with(end))
};
// Each of the four built them before: now the first frame builds them once, the next none.
let size = frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
// A press changes the screen's own state: its row reads the new value.
let at = middle_of(&mut shell, CHOICE, size);
shell.click(&mut s, at, size, false, false, false);
assert!(reads(&mut shell, &s, size, "Blue"));
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
s.fov = 90.0;
frame(&mut shell, &mut s, &atlas);
assert!(reads(&mut shell, &s, size, "90"));
let before = built(&shell);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), before);
// Searching, and another size, build them again.
shell.type_text("filler");
shell.view(&s, size);
assert_eq!(built(&shell), before + 1);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 2);
// So does another language, and coming back from it.
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
assert_eq!(built(&shell), before + 3);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 4);
}
#[test]
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let cols = Columns::of(SHORT.0, SHORT.1);
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
let cut = FIRST_FILLER + 20;
let view = shell.view(&s, SHORT);
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
let rect = cols.row(y - shell.scroll.amount);
assert!(
rect[1] + rect[3] / 2.0 >= cols.bottom,
"the row's middle is hidden"
);
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
// All in view, it stays put.
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
assert_eq!(
shell.click(&mut s, middle, SHORT, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
@@ -861,6 +861,9 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
}
/// A `SoundEventListener.onPlaySound` call.
#[derive(Clone, Debug, PartialEq)]
@@ -937,6 +940,9 @@ pub struct SoundEngine {
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
/// The sound events a mod's muffle leaves clear (`Audio::muffle`), normalized: their channels
/// are marked as they start, and those playing when the list changes are marked again.
unmuffled: Vec<String>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
h.channel
@@ -966,6 +972,21 @@ impl SoundEngine {
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
unmuffled: Vec::new(),
}
}
/// The sound events that play clearly through a mod's muffle from now on, normalized
/// (`minecraft:ui.button.click`); the channels playing now follow the new list too.
pub fn set_unmuffled(&mut self, events: Vec<String>, backend: &mut impl Backend) {
self.unmuffled = events;
for (id, handle) in &self.instance_to_channel {
let (Some(instance), Some(channel)) = (
self.instances.get(id),
self.handles.get(handle).and_then(handle_channel),
) else {
continue;
};
backend.set_unmuffled(channel, self.unmuffled.contains(&instance.identifier));
}
}
/// Isolate a fixture's dispatch from process-wide native installation.
@@ -1315,6 +1336,7 @@ impl SoundEngine {
}
let position = instance.position;
let looping = instance.looping && instance.delay <= 0;
let unmuffled = self.unmuffled.contains(&instance.identifier);
let streaming = sound.stream;
let pool = usize::from(streaming);
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
@@ -1367,6 +1389,7 @@ impl SoundEngine {
backend.set_looping(channel, looping && !streaming);
backend.set_position(channel, position);
backend.set_relative(channel, relative);
backend.set_unmuffled(channel, unmuffled);
backend.start(channel, &sound, looping);
if tickable {
self.ticking.push(id);
@@ -105,6 +105,8 @@ thread_local! {
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
/// The lists drawn with a scrollbar since `end_frame`, and the last frame's.
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -117,6 +119,7 @@ pub struct Saved(
bool,
(u64, u64),
(Vec<ScrollArea>, Vec<ScrollArea>),
(Vec<FocusLink>, Vec<FocusLink>),
);
/// This thread's buttons and targets, leaving none.
@@ -128,6 +131,7 @@ pub fn take() -> Saved {
ACTION_GLYPHS.take(),
ACTION_SCREENS.take(),
SCROLL_AREAS.take(),
FOCUS_LINKS.take(),
)
}
@@ -136,6 +140,7 @@ pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
SCROLL_AREAS.set(saved.5);
FOCUS_LINKS.set(saved.6);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
@@ -282,6 +287,7 @@ pub fn cover_frame() {
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
}
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
@@ -315,6 +321,38 @@ pub fn over_scroll_area(at: (f32, f32)) -> (f32, f32) {
})
}
/// A step the arrows and the D-pad take that the rectangles don't show: `down` (else up) from
/// a focus whose middle is in `from`, to the target at `to`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FocusLink {
pub from: [f32; 4],
pub down: bool,
pub to: [f32; 4],
}
/// Down (or up) from what is in `from` goes to `to`: a screen whose layout puts the next control
/// off to one side, where vanilla's step would pass it by (the settings shell's search field over
/// its list, on a screen with no page list).
pub fn focus_link(from: [f32; 4], down: bool, to: [f32; 4]) {
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.push(FocusLink { from, down, to }));
}
/// Where the last drawn frame said a step `down` (else up) from `focus` goes, if it said.
pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
let [x, y, w, h] = focus;
let middle = (x + w / 2.0, y + h / 2.0);
FOCUS_LINKS.with_borrow(|(_, last)| {
last.iter()
.find(|l| {
let [lx, ly, lw, lh] = l.from;
l.down == down
&& (lx..lx + lw).contains(&middle.0)
&& (ly..ly + lh).contains(&middle.1)
})
.map(|l| l.to)
})
}
/// The focus targets of the last drawn frame.
pub fn last_targets() -> Vec<Target> {
TARGETS.with_borrow(|(_, last, _)| last.clone())
@@ -392,6 +430,9 @@ pub fn end_frame(frames: u64) {
SCROLL_AREAS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -76,6 +76,9 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Nintendo Buttons",
"horizon.options.nintendo_buttons.position": "By Position",
"horizon.options.nintendo_buttons.letter": "By Letter",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -964,6 +967,8 @@
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, on the right, confirms and jumps and B goes back, as on a Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, at the bottom, confirms and jumps and A goes back, as on other controllers.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
@@ -982,6 +987,7 @@
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.",
"The glyph family the game picked from the controller's maker.": "The glyph family the game picked from the controller's maker.",
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Whether a Nintendo controller's A and B work by where they sit or by their letters.",
"The buttons menus show right now.": "The buttons menus show right now.",
"Rumble when something happens to you.": "Rumble when something happens to you.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
@@ -5,6 +5,7 @@
"horizon.options.controller": "Mando",
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
"horizon.options.vibration": "Vibración",
"horizon.options.play_in_background": "Jugar en segundo plano",
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
"horizon.options.lightbar": "Barra de luz",
"horizon.options.look_sensitivity": "Sensibilidad de vista",
@@ -34,6 +35,23 @@
"horizon.studio.record.error": "Error de grabación de Studio: %s",
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
"horizon.studio.replay.usage": "Uso: /studio replay <open RUTA|pause|resume|speed MULTIPLICADOR|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Uso: /studio replay open <RUTA>",
"horizon.studio.replay.opened": "Replay de Studio abierto: %s (%s s, %s paquetes)",
"horizon.studio.replay.open_error": "No se pudo abrir el replay: %s",
"horizon.studio.replay.paused": "Replay de Studio en pausa",
"horizon.studio.replay.resumed": "Replay de Studio reanudado",
"horizon.studio.replay.inactive": "No hay ningún replay de Studio activo",
"horizon.studio.replay.speed.usage": "Uso: /studio replay speed <MULTIPLICADOR>",
"horizon.studio.replay.speed.set": "Velocidad del replay de Studio ajustada a %s×",
"horizon.studio.replay.speed.invalid": "Velocidad de replay no válida",
"horizon.studio.replay.seek.usage": "Uso: /studio replay seek <MILISEGUNDOS>",
"horizon.studio.replay.seeked": "Replay de Studio situado en %s ms (%s s)",
"horizon.studio.replay.seek.error": "No se pudo mover el replay: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Rebobinar solo está disponible para un replay independiente.",
"horizon.studio.replay.seek.invalid": "Posición de replay no válida",
"horizon.studio.replay.status": "Replay de Studio: %s / %s ms (%s%%), velocidad %s×, en pausa %s, finalizado %s",
"horizon.studio.replay.closed": "Replay de Studio cerrado",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.shader_options": "Opciones de shaders",
@@ -88,6 +106,7 @@
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
"horizon.studio.open_folder": "Abrir grabaciones",
"horizon.studio.open_replay": "Reproducir replay",
"horizon.studio.refresh": "Actualizar",
"horizon.studio.unknown_server": "Desconocido",
"horizon.pages.open.music": "Música...",
@@ -882,6 +901,7 @@
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El mando sigue funcionando aunque esta ventana no tenga el foco, y perder el foco no pone el juego en pausa.",
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
"Player profile": "Perfil de jugador",
+12
View File
@@ -7,6 +7,18 @@ fn main() {
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
.expect("generating native mod API");
// A workspace horizon-mixins changed holds native mods, whichever tool ran it (the launcher,
// or a mod's own script with no `native-mods.json` beside its game): `HORIZON_MIXIN_MODS`
// names them for `game::mods::built_with_native_mods`. Watched only once it exists: a
// bundle applied in place also edits `game/`, which `generate` watches.
let stage = root.join(horizon_mod_api_gen::MIXINS_STAGE);
if stage.is_file() {
println!("cargo:rerun-if-changed={}", stage.display());
}
let staged = horizon_mod_api_gen::staged_mods(&root);
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ico = manifest_dir
.join("../..")
@@ -10,6 +10,7 @@ use horizon_client::{
block_entity_anim::EnchantingBook,
block_entity_render::{
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
text_vertices,
},
chunk::{Chunk, Light},
hud::{HudAtlas, Rect},
@@ -86,12 +87,19 @@ fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
fn sign_side(v: &Value) -> SignText {
let mut side = SignText::default();
for (line, text) in side
.lines
.iter_mut()
.zip(v["lines"].as_array().into_iter().flatten())
for (i, value) in v["lines"]
.as_array()
.into_iter()
.flatten()
.take(4)
.enumerate()
{
*line = text.as_str().unwrap_or_default().to_owned();
let line = horizon_client::scoreboard::comp_text(value);
side.lines[i] = line.plain();
side.saved[i] = Some(horizon_client::signs::SavedLine {
text: line.plain(),
component: horizon_client::lan::data::json_to_nbt(value),
});
}
let color = v["color"].as_str().unwrap_or("black");
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
@@ -373,6 +381,7 @@ pub fn main() -> Result<()> {
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
font: Some(&atlas.font),
dynamic_visible: None,
};
let mut verts = Vec::new();
@@ -426,8 +435,26 @@ pub fn main() -> Result<()> {
.iter()
.filter(|t| !t.text.is_empty())
.map(|t| {
let mut x = t.x;
let glyphs: Vec<_> = t.text.chars().map(|c| {
let g = json!({"matrix": t.matrix.to_cols_array(), "x": x, "y": t.y,
"text": c.to_string(), "color": t.color,
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF)),
"style": [t.style.is_bold(), t.style.is_italic(), t.style.underlined == Some(true),
t.style.strikethrough == Some(true), t.style.obfuscated == Some(true)]});
x += atlas.font.advance(c, &t.style);
g
}).collect();
let mut vertices = Vec::new();
let mut local = t.clone();
local.matrix = Mat4::IDENTITY;
text_vertices(&|c| atlas.font.glyph_styled(c, &t.style), Some(atlas.font.white()), &local, &mut vertices);
let vertices: Vec<_> = vertices.iter().map(|v| {
let c = v.color;
json!([v.pos[0], v.pos[1], v.pos[2], (c & 0xff00ff00) | (c & 255) << 16 | (c >> 16 & 255)])
}).collect();
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
"color": t.color, "outline": t.outline,
"color": t.color, "outline": t.outline, "glyphs": glyphs, "textVertices": vertices,
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
})
.collect();
@@ -283,6 +283,7 @@ pub fn main() -> horizon_client::Result<()> {
loot: &loot,
damage: Some(source),
player: player.filter(|_| pkill),
player_killed: pkill,
luck: get("luck", "0").parse()?,
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
mob_drops: true,
+105 -1
View File
@@ -95,6 +95,7 @@ fn read_endpoint(path: &Path) -> io::Result<(u16, String)> {
/// Connect to a running game's control endpoint (and authenticate over TCP).
pub fn connect(path: &Path) -> io::Result<Stream> {
private_dir(path, false)?;
#[cfg(unix)]
if !uses_tcp(path) {
return UnixStream::connect(path).map(Stream::Unix);
@@ -158,10 +159,82 @@ pub fn default_path() -> PathBuf {
if !cfg!(unix) {
return std::env::temp_dir().join("horizon-control.json");
}
let dir = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.or_else(fallback_dir)
.unwrap_or_else(std::env::temp_dir);
dir.join("horizon-control.sock")
}
/// Without `$XDG_RUNTIME_DIR` (containers, some WSL and minimal setups), when the temporary
/// directory is shared by every user (`/tmp`), a folder of this user's own in it,
/// `horizon-<uid>`: a fixed name there let another user bind the socket first, so that
/// `--control` failed and `horizon-ctl` talked to theirs. [`private_dir`] checks it before the
/// socket is made or reached. A temporary directory of the user's own (macOS's `$TMPDIR`) is
/// used as it is. The launcher finds the endpoint the same way (`game_tools.rs`).
fn fallback_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let temp = std::env::temp_dir();
let shared = std::fs::metadata(&temp).is_ok_and(|m| m.permissions().mode() & 0o002 != 0);
let uid = own_uid().filter(|_| shared)?;
Some(temp.join(format!("horizon-{uid}")))
}
#[cfg(not(unix))]
None
}
/// This process's user: the owner of `/proc/self` where there is one, else of the home folder.
#[cfg(unix)]
fn own_uid() -> Option<u32> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata("/proc/self")
.ok()
.or_else(|| std::fs::metadata(std::env::var_os("HOME")?).ok())
.map(|m| m.uid())
}
/// For an endpoint in [`fallback_dir`]: [`check_private`] on that folder. Elsewhere (the user's
/// `$HORIZON_CONTROL`, the runtime directory) nothing to check.
fn private_dir(path: &Path, make: bool) -> io::Result<()> {
match fallback_dir() {
Some(dir) if path.parent() == Some(dir.as_path()) => check_private(&dir, make),
_ => Ok(()),
}
}
/// `dir` made (mode 0700) when `make`, and refused unless it is a real folder, this user's, that
/// no one else may enter.
#[cfg(unix)]
fn check_private(dir: &Path, make: bool) -> io::Result<()> {
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
if make {
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => return Err(e),
_ => {}
}
}
let meta = std::fs::symlink_metadata(dir)?;
if !meta.is_dir()
|| meta.permissions().mode() & 0o077 != 0
|| own_uid().is_some_and(|uid| uid != meta.uid())
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} isn't a folder only this user can open; set $HORIZON_CONTROL or $XDG_RUNTIME_DIR",
dir.display()
),
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_private(_: &Path, _: bool) -> io::Result<()> {
Ok(())
}
/// Whether `path` can be an endpoint at all, without touching it: a Unix socket's path has a
/// length limit (`sockaddr_un`); a TCP endpoint is an ordinary file and has none.
pub fn check_path(path: &Path) -> io::Result<()> {
@@ -184,6 +257,7 @@ pub fn check_path(path: &Path) -> io::Result<()> {
impl Server {
pub fn start(path: &Path) -> io::Result<Self> {
check_path(path)?;
private_dir(path, true)?;
// An endpoint left by a crashed game refuses connections; replace it.
if path.exists() && connect(path).is_err() {
std::fs::remove_file(path)?;
@@ -392,6 +466,36 @@ fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[test]
fn the_fallback_socket_folder_must_be_private() {
use std::os::unix::fs::PermissionsExt;
let base = std::env::temp_dir().join(format!("horizon-control-dir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// Made 0700 when missing, and then fine to reach.
let made = base.join("made");
super::check_private(&made, true).unwrap();
let mode = std::fs::metadata(&made).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
super::check_private(&made, false).unwrap();
// One another user could enter or write: refused, made or not.
let open = base.join("open");
std::fs::create_dir(&open).unwrap();
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(super::check_private(&open, true).is_err());
assert!(super::check_private(&open, false).is_err());
// A link to a private folder, or a file: refused.
std::os::unix::fs::symlink(&made, base.join("link")).unwrap();
assert!(super::check_private(&base.join("link"), false).is_err());
std::fs::write(base.join("file"), b"").unwrap();
assert!(super::check_private(&base.join("file"), true).is_err());
// Missing and not to be made: an error, not a folder made by whoever connects.
assert!(super::check_private(&base.join("missing"), false).is_err());
assert!(!base.join("missing").exists());
let _ = std::fs::remove_dir_all(&base);
}
use super::*;
/// The check agrees with the system at every length around its limit.
+41 -7
View File
@@ -1118,13 +1118,15 @@ pub fn mob_to_nbt(
);
let mut memories: Vec<(&str, Tag)> = Vec::new();
if m.extra.tame {
memories.push((
"minecraft:liked_player",
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
));
// `MemorySlot.visit`: the time to live only when it can expire.
let mut liked = vec![(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)];
if let Some(ttl) = m.extra.allay.as_ref().and_then(|st| st.liked_player_ttl) {
liked.push(("ttl", Tag::Long(ttl)));
}
memories.push(("minecraft:liked_player", Tag::compound(liked)));
}
// GlobalPos.CODEC, and the cooldown while it runs.
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
@@ -2218,6 +2220,20 @@ pub fn mob_from_nbt(
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
// `Brain.readAdditionalSaveData`: a memory saved with a time to live
// (`Codec.LONG`, read from any integer tag). `Long.MAX_VALUE` is
// `MemorySlot.NEVER_EXPIRE`: that memory never expires.
if let Some(st) = m.extra.allay.as_mut() {
st.liked_player_ttl = liked
.and_then(|l| match l.get("ttl")? {
Tag::Byte(v) => Some(i64::from(*v)),
Tag::Short(v) => Some(i64::from(*v)),
Tag::Int(v) => Some(i64::from(*v)),
Tag::Long(v) => Some(*v),
_ => None,
})
.filter(|&ttl| ttl != i64::MAX);
}
let memory = |key: &str| {
tag.get("Brain")
.and_then(|b| b.get("memories"))
@@ -5951,6 +5967,15 @@ pub fn bucket_data(m: &Mob) -> Tag {
if let Some(ttl) = crate::mobs::animal_brain::hunting_cooldown(m) {
put(&mut data, "HuntingCooldown", Tag::Long(ttl));
}
// SulfurCube.saveToBucketTag: after the default data, its age.
if m.species() == crate::mobs::Species::SulfurCube {
put(&mut data, "age", Tag::Int(m.extra.age));
put(
&mut data,
"age_locked",
Tag::Byte(i8::from(m.extra.age_locked)),
);
}
data
}
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
@@ -5962,6 +5987,15 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
..Default::default()
};
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
// SulfurCube.saveToBucketTag: the body block, when it has one.
if m.species() == crate::mobs::Species::SulfurCube
&& let Some(body) = &m.extra.body
{
data.set_other(
"minecraft:sulfur_cube_content",
Some(Tag::compound([("id", string(body))])),
);
}
if m.species() == crate::mobs::Species::Axolotl {
let variant = AXOLOTL_VARIANTS
.get(usize::from(m.extra.variant))
+61 -1
View File
@@ -91,6 +91,8 @@ mod chat_filter;
mod chiseled_bookshelf;
mod client_ambient;
mod clipboard;
#[cfg(test)]
mod clone_block_ticks_tests;
mod code_of_conduct;
mod combat;
mod command_blocks;
@@ -407,6 +409,7 @@ mod sniffers;
mod social;
mod spawner;
mod spear;
mod spear_players;
mod spectator;
mod spill;
mod splash_water;
@@ -457,6 +460,8 @@ mod world_entry;
mod world_loading;
mod world_primitives;
mod world_session;
#[cfg(test)]
mod world_test_def_tests;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
@@ -589,6 +594,9 @@ const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
const AUTO_UPLOAD_TIME_ARRIVING: Duration = Duration::from_millis(6);
/// A frame's finished meshes up to this many upload at once, unqueued.
const AUTO_UPLOADS_AT_ONCE: usize = 8;
/// The most changed columns far terrain waits on behind a mod's hold (`Game::lod_held`): past
/// it, they go in as ever.
const HELD_FAR_COLUMNS: usize = 1 << 13;
#[derive(Clone)]
struct Options {
@@ -2421,6 +2429,9 @@ struct Game<'a> {
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Columns the single-player server changed in bulk inside a mod's hold on the terrain
/// (`native_world_hold`): far terrain takes them in when the hold lets go of them.
lod_held: horizon_client::fast_hash::FxHashMap<(i32, i32), Arc<horizon_client::chunk::Chunk>>,
/// The server level far terrain keeps the chunks of.
lod_level: Option<String>,
/// The server's BlueMap far terrain draws from, when the player named one
@@ -3117,6 +3128,9 @@ struct Game<'a> {
structure_templates: HashMap<String, Option<structure_blocks::TemplateEntry>>,
/// The game tests read so far, by id (the test instance registry).
test_defs: std::cell::RefCell<HashMap<String, Option<test_instances::TestDef>>>,
/// `test_defs` holds the world's whole test instance registry
/// (`snapshot_test_defs`), so a miss is final.
test_defs_snapshot: bool,
/// `GameTestBlockHighlightRenderer.markers`: block, text, removal time.
test_highlights: HashMap<(i32, i32, i32), (String, std::time::Instant)>,
/// `GameTestTicker.SINGLETON`: the game tests running on this server.
@@ -3216,6 +3230,12 @@ struct Game<'a> {
/// `LivingEntity.recentKineticEnemies`: when each player (a guest, or the
/// window's own) last stabbed each entity this use.
stabbed: HashMap<(Option<u32>, u32), i64>,
/// The other players the acting player's spear can reach this turn,
/// taken from the hub before it (`spear_players`).
stab_players: Vec<spear_players::StabPlayer>,
/// Spear stabs on players worked out in a guest's turn, landed once the
/// hub is back (`land_player_stabs`).
player_stabs: Vec<spear_players::StabBatch>,
/// The movement a guest's client reported this tick
/// (`ServerPlayer.getKnownMovement`), for their turn.
known_movement: Vec3,
@@ -9912,6 +9932,7 @@ impl Game<'_> {
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
font: Some(&self.gui.atlas.font),
dynamic_visible: Some(&dynamic_visible),
};
let visible = camera_list.into_iter().chain(shadow_only.iter().copied());
@@ -14485,6 +14506,7 @@ impl Game<'_> {
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
self.lod_held.clear();
self.lod_blue_map = None;
self.far_beacons.clear();
self.lod_covered_at = None;
@@ -14635,8 +14657,18 @@ impl Game<'_> {
if self.remote.is_some() {
let changed = horizon_client::lan::handoff::take_changed();
if let Some(lod) = &mut self.lod {
let renderer = self.renderer.as_ref();
for chunk in changed {
if !self.world.chunks.contains_key(&(chunk.x, chunk.z)) {
let column = (chunk.x, chunk.z);
if self.world.chunks.contains_key(&column) {
continue;
}
// Inside a mod's hold, far terrain shows it with the sections (`native_world_hold`).
if renderer.is_some_and(|r| r.world_holds_column(column))
&& self.lod_held.len() < HELD_FAR_COLUMNS
{
self.lod_held.insert(column, chunk);
} else {
lod.observe(chunk, self.res.facts());
}
}
@@ -14752,7 +14784,29 @@ impl Game<'_> {
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z])
.and_then(|camera| self.lod_aim(camera));
// A mod's hold on the terrain's picture, before this frame's uploads (`native_world_hold`).
let world_hold = self
.renderer
.is_some()
.then(|| self.native_world_hold_now());
if let Some(renderer) = self.renderer.as_mut() {
if let Some(hold) = &world_hold {
renderer.hold_world(hold);
}
// Far terrain takes in the columns the hold let go of, as the sections show.
if !self.lod_held.is_empty() {
let let_go: Vec<_> = self
.lod_held
.extract_if(|&column, _| !renderer.world_holds_column(column))
.collect();
if let Some(lod) = &mut self.lod {
for (column, chunk) in let_go {
if !self.world.chunks.contains_key(&column) {
lod.observe(chunk, self.res.facts());
}
}
}
}
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
// The meshes, once copied, are freed by a worker (`Streaming::free`).
@@ -16487,6 +16541,7 @@ impl<'a> Game<'a> {
lod_source_attempted: false,
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_held: Default::default(),
lod_level: None,
lod_blue_map: None,
lod_covered_at: None,
@@ -16886,6 +16941,7 @@ impl<'a> Game<'a> {
block_editor: None,
structure_templates: HashMap::new(),
test_defs: std::cell::RefCell::new(HashMap::new()),
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
@@ -16934,6 +16990,8 @@ impl<'a> Game<'a> {
lan_critical_hits: Vec::new(),
lan_kinetic_hits: Vec::new(),
stabbed: HashMap::new(),
stab_players: Vec::new(),
player_stabs: Vec::new(),
known_movement: Vec3::ZERO,
kinetic_feedback: HashMap::new(),
lan_global_events: Vec::new(),
@@ -18166,6 +18224,8 @@ fn write_level_for_role(
}
}
#[cfg(test)]
mod sulfur_bucket_tests;
#[cfg(test)]
mod vibration_tests;
+3
View File
@@ -505,6 +505,9 @@ impl Game<'_> {
"food": p.health.food.food,
"saturation": p.health.food.saturation,
"shield_cooldown": self.shield_cooldown,
// ItemCooldowns.getCooldownPercent of the shield: what this window's hotbar draws
// over it (on a server's client, only what the server's Cooldown packets told it).
"shield_cooldown_shown": self.cooldowns.percent("minecraft:shield", 0.0),
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
+2 -4
View File
@@ -377,9 +377,8 @@ impl Game<'_> {
// `Allay.mobInteract` hands over one item, its components and all.
given.count = 1;
m.extra.set_main_hand_stack(Some(given));
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
allay::like_player(m, acting);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_given",
@@ -402,8 +401,7 @@ impl Game<'_> {
.unwrap_or_else(|| ItemStack::new("minecraft:air", 1));
m.extra.set_main_hand(None);
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
m.extra.tame = false;
m.extra.owner = None;
allay::forget_liked_player(m);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_taken",
+180 -2
View File
@@ -1478,14 +1478,55 @@ impl Game<'_> {
let Some(info) = self.items().get(&stack.item) else {
return false;
};
let (index, slot) = match info.equip_slot.as_deref() {
let components = self
.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack));
let Some(equippable) = components.get("minecraft:equippable") else {
return false;
};
let actual = stack
.data()
.and_then(|data| data.other("minecraft:equippable"));
// Only adopt the default-allowed shape here. Restricted or otherwise
// shaped overrides keep their existing admission until supported.
let supported = actual.is_none_or(|tag| {
matches!(tag, horizon_world::nbt::Tag::Compound(map)
if map.keys().all(|key| matches!(key.to_string_lossy().as_str(), "slot" | "swappable")))
&& matches!(tag.get("slot"), Some(horizon_world::nbt::Tag::String(_)))
&& tag.get("swappable").is_none_or(|flag| {
horizon_client::item_data::component_bool(flag).is_some()
})
});
let slot = if supported {
equippable.get("slot").and_then(serde_json::Value::as_str)
} else {
info.equip_slot.as_deref()
};
let (index, slot) = match slot {
Some("head") => (0, "head"),
Some("chest") => (1, "chest"),
Some("legs") => (2, "legs"),
Some("feet") => (3, "feet"),
_ => return false,
};
if !info.swappable {
// The component codec replaces the whole value: an override which
// omits swappable uses true, rather than its item's prototype flag.
let swappable = if !supported {
info.swappable
} else if let Some(actual) = actual {
actual
.get("swappable")
.and_then(horizon_client::item_data::component_bool)
.unwrap_or(true)
} else {
equippable
.get("swappable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true)
};
if !swappable {
return false;
}
let current = self.armor[index].clone();
@@ -1912,3 +1953,140 @@ mod layer_color_tests {
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
}
}
#[cfg(test)]
mod pumpkin_equippable_use_tests {
use super::*;
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{Serverbound, UseItem},
};
use std::sync::{atomic::AtomicI32, mpsc};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h207_connected_use_item_adopts_actual_pumpkin_equippable_and_preserves_controls() {
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, "pumpkin-use-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: 207,
name: "PumpkinGuest".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 = 0;
guest.permission = 2;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let clock = game.world.game_time;
let eq = "minecraft:equippable={slot:\"head\"}";
let marker = "minecraft:custom_data={h207:207}";
let helmet = "minecraft:diamond_helmet[minecraft:custom_data={old:1}]";
let mut failures = Vec::new();
for (i, (label, mode, component, count, old, changed)) in [
("empty", "survival", Some(eq), 3, None, true),
("swap", "survival", Some(eq), 1, Some(helmet), true),
("creative", "creative", Some(eq), 3, None, true),
("prototype", "survival", None, 3, None, false),
("false", "survival", Some("minecraft:equippable={slot:\"head\",swappable:0b}"), 3, None, false),
("binding", "survival", Some(eq), 3, Some("minecraft:diamond_helmet[minecraft:enchantments={\"minecraft:binding_curse\":1},minecraft:custom_data={old:1}]"), false),
("same", "survival", Some(eq), 3, Some("minecraft:carved_pumpkin[minecraft:equippable={slot:\"head\"},minecraft:custom_data={h207:207}]"), false),
].into_iter().enumerate() {
let held = format!("minecraft:carved_pumpkin[{}{}{}]", component.unwrap_or(""), if component.is_some() { "," } else { "" }, marker);
let before = game.as_guest(&mut host, 2, |g| {
assert_eq!(g.ui.registries.permission, 2);
assert!(g.run_command("clear @s") || g.inventory.iter().all(Option::is_none));
g.run_command("item replace entity @s armor.head with air");
g.run_command(&format!("gamemode {mode}"));
assert!(g.run_command(&format!("item replace entity @s weapon.mainhand with {held} {count}")));
if let Some(old) = old { assert!(g.run_command(&format!("item replace entity @s armor.head with {old}"))); }
assert_eq!(g.player.as_ref().unwrap().position, Vec3::new(8.5, 200.0, 8.5));
let hand = g.held().unwrap().clone();
assert_eq!(hand.item.as_ref(), "minecraft:carved_pumpkin");
assert_eq!(hand.count, count);
assert!(hand.data().unwrap().other("minecraft:custom_data").is_some());
let actual = hand.data().unwrap().other("minecraft:equippable");
assert_eq!(actual.is_some(), component.is_some());
if let Some(actual) = actual {
assert_eq!(actual.get("slot"), Some(&horizon_world::nbt::Tag::String("head".into())));
assert_eq!(actual.get("swappable").and_then(horizon_client::item_data::component_bool), if label == "false" { Some(false) } else { None });
}
if label == "binding" { assert_eq!(g.armor[0].as_ref().unwrap().enchantment_level("minecraft:binding_curse"), 1); }
if label == "same" { assert!(g.armor[0].as_ref().unwrap().same_item_same_components(&hand)); }
(g.inventory.clone(), g.armor.clone(), hand)
}).unwrap();
let sequence = 207 + i as i32;
game.lan_guest_packet(&mut host, 2, Serverbound::UseItem(UseItem { hand: 0, yaw: 0.0, pitch: 0.0, sequence }));
assert!(host.hub.guest(2).unwrap().ack_up_to.get() >= sequence);
game.as_guest(&mut host, 2, |g| {
let mut expected_inventory = before.0.clone();
let mut expected_armor = before.1.clone();
if changed {
let mut worn = before.2.clone();
worn.count = 1;
expected_armor[0] = Some(worn);
if label == "swap" { expected_inventory[0] = before.1[0].clone(); }
else if label == "empty" { expected_inventory[0].as_mut().unwrap().count = 2; }
}
if g.inventory != expected_inventory || g.armor != expected_armor {
failures.push((label, g.inventory.clone(), g.armor.clone()));
}
assert_eq!(g.world.game_time, clock);
}).unwrap();
}
assert!(
failures.is_empty(),
"actual connected UseItem Equippable adoption failures: {failures:?}"
);
}
}
+175 -8
View File
@@ -26,6 +26,7 @@ use horizon_core::{
random::RandomSource,
};
use horizon_world::collision_context::CollisionContext;
use horizon_world::nbt::Tag;
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -80,6 +81,9 @@ pub(super) struct Boat {
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
/// `Entity.remainingFireTicks` (`Fire` in its save): burning while
/// positive, a fire block's cooldown while negative.
pub fire_ticks: i32,
/// `Entity.noGravity` (`NoGravity` in its save).
pub no_gravity: bool,
pub paddles: [f32; 2],
@@ -148,9 +152,19 @@ fn snowflake_velocity(known: Vec3, random: &mut impl RandomSource) -> Option<[f6
let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
Some([f64::from(xa), f64::from(0.05_f32), f64::from(za)])
}
/// `Entity.load`'s `Fire` (a short; 0 when absent).
pub(super) fn saved_fire(tag: &Tag) -> i32 {
i32::from(horizon_client::save::number(tag.get("Fire")).unwrap_or(0) as i16)
}
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
/// `Entity.baseTick` can burn the boat.
fn wear_off_hurt(&mut self) {
self.hurt_time = (self.hurt_time - 1).max(0);
self.damage = (self.damage - 1.0).max(0.0);
}
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
/// underwater ticks; any other status resets the counter.
fn tick_submersion(&mut self) {
@@ -191,6 +205,7 @@ impl Boat {
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
fire_ticks: 0,
no_gravity: false,
paddles: [0.0; 2],
paddling: [false; 2],
@@ -224,6 +239,23 @@ impl Boat {
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
)
}
/// `Entity.saveWithoutId` with `AbstractChestBoat`'s items: the loaded
/// compound `raw` under the live fields.
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
&self.entity_type(),
self.position,
self.velocity,
[self.yaw, self.pitch],
uuid,
raw,
self.chest.then_some(&self.items[..]),
);
if let Tag::Compound(map) = &mut tag {
map.insert("Fire".into(), Tag::Short(self.fire_ticks as i16));
}
tag
}
/// The start of `Entity.move`: a boat made stuck since its last move goes its
/// velocity times the multiplier, and its velocity and the multiplier are spent.
fn take_movement(&mut self) -> Vec3 {
@@ -648,8 +680,10 @@ impl Game<'_> {
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.tick_submersion();
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// `tick_boats_now` wears it off for the boats it ticks, before they burn.
if controlled {
b.wear_off_hurt();
}
// A remote client gets the controller from the passenger packet, even when
// this window occupies the second seat. Local worlds derive it here.
if self.remote.is_none() {
@@ -854,7 +888,17 @@ impl Game<'_> {
|| driven.contains(&id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
// Its rider's client moves it, but the server still wears
// off its damage and then burns it.
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
if driven.contains(&id) {
self.boats[i].wear_off_hurt();
}
if self.boat_fire_tick(i) {
break 'boat;
}
}
// The lead still ticks too.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
@@ -879,9 +923,13 @@ impl Game<'_> {
else {
break 'boat;
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
self.boats[i].wear_off_hurt();
// Entity.baseTick burns it before it moves; the fire may break it.
if !self.boat_fire_tick(i) {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
}
self.native_tick_post_one(&round, id, &kind);
}
i = match self.boats.get(i) {
@@ -916,6 +964,9 @@ impl Game<'_> {
return;
}
if self.remote.is_none() {
if self.boat_fire_passes(i) {
return;
}
self.boat_powder_snow_snowflakes(i);
}
// `AbstractBoat.tick` counts submersion on the server even when
@@ -1125,6 +1176,121 @@ impl Game<'_> {
}
cells
}
/// `BaseFireBlock.fireDamage` of a fire block at `p`.
fn fire_damage_at(&self, p: [i32; 3]) -> Option<f32> {
match &*self
.res
.facts()
.get(self.world.block(p[0], p[1], p[2]))
.name
{
"minecraft:fire" => Some(1.0),
"minecraft:soul_fire" => Some(2.0),
_ => None,
}
}
/// `Entity.baseTick` on the server: a burning boat takes 1 fire damage
/// every 20 ticks (none in lava) and burns a tick down. True when the fire
/// broke it.
fn boat_fire_tick(&mut self, i: usize) -> bool {
if self.remote.is_some() || self.boats[i].fire_ticks <= 0 {
return false;
}
let burn = self.boats[i].fire_ticks % 20 == 0 && !self.in_lava(self.boats[i].bounds());
self.boats[i].fire_ticks -= 1;
burn && self.burn_boat(i, 1.0)
}
/// The fire of the two `applyEffectsFromBlocks` in `AbstractBoat.tick`.
/// `Entity.checkInsideBlocks` meets the blocks of the box the boat started
/// its move in first, then the ones it reached, each block once; in each
/// step fire blocks light it (`BaseFireBlock.fireIgnite`, a cooldown tick
/// at a time) and each burns it, then a burning boat melts the powder snow
/// (with `mob_griefing`) and water and powder snow put it out. Rain puts
/// it out too, and a boat neither burning nor lit has its cooldown
/// cleared. True when the fire broke it.
fn boat_fire_passes(&mut self, i: usize) -> bool {
let b = &self.boats[i];
let mut steps = vec![Boat::bounds_at(b.previous)];
if b.previous != b.position {
steps.push(b.bounds());
}
// `Entity.collidedWithFluid` sweeps the box along the whole move:
// water reaches it down to the lower of its two bottoms.
let low = b.previous.y.min(b.position.y);
let (at, top) = (b.position, b.bounds().max_position().y);
let rain = self.in_rain(at, top);
for _ in 0..2 {
let before = self.boats[i].fire_ticks;
let mut seen = Vec::new();
for &bounds in &steps {
let cells = powder_snow_cells(bounds, |p| !seen.contains(&p));
seen.extend_from_slice(&cells);
let fires: Vec<f32> = cells
.iter()
.filter_map(|&p| self.fire_damage_at(p))
.collect();
if !fires.is_empty() {
let fire = &mut self.boats[i].fire_ticks;
if *fire < 0 {
*fire += 1;
}
if *fire >= 0 {
// igniteForSeconds(8)
*fire = (*fire).max(160);
}
for &damage in &fires {
if self.burn_boat(i, damage) {
return true;
}
}
}
let water = cells.iter().any(|&p| self.water_reaches(p, low));
let snow: Vec<[i32; 3]> = cells
.iter()
.copied()
.filter(|&p| self.is_powder_snow(p))
.collect();
if self.boats[i].fire_ticks > 0 && self.ui.rules.bool("mob_griefing") {
for &cell in &snow {
self.powder_snow_melt(BlockPos::new(cell[0], cell[1], cell[2]));
}
}
if water || !snow.is_empty() {
// clearFire
let fire = &mut self.boats[i].fire_ticks;
*fire = (*fire).min(0);
}
}
let fire = &mut self.boats[i].fire_ticks;
if rain {
*fire = (*fire).min(0);
}
let now = *fire;
if now <= 0 && now <= before {
*fire = 0;
}
if before > 0 && now <= 0 {
self.extinguish_sound([at.x, at.y, at.z]);
}
}
false
}
/// Whether the water at cell `p` reaches a box whose bottom is `low`
/// (`Entity.collidedWithFluid`).
fn water_reaches(&self, p: [i32; 3], low: f64) -> bool {
self.fluid_height_of(p[0], p[1], p[2], Some(Fluid::Water))
.is_some_and(|(height, _)| low + 1.0e-5 < f64::from(p[1]) + f64::from(height))
}
/// `VehicleEntity.hurtServer` for fire (`inFire`, `onFire`), which an
/// `Invulnerable` boat ignores. True when the boat broke.
fn burn_boat(&mut self, i: usize, damage: f32) -> bool {
let invulnerable = self
.persist
.raw
.get(&self.boats[i].id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
!invulnerable && self.hurt_boat(i, damage, false)
}
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles`: a boat that moved
/// horizontally in powder snow throws snowflakes the players around see. `AbstractBoat.tick`
/// calls `applyEffectsFromBlocks` twice.
@@ -1521,7 +1687,8 @@ impl Game<'_> {
}
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
// ENTITY_DIE), then the boat item on the death path only, and only
// with `entity_drops` on.
// with `entity_drops` on, by `spawnAtLocation` (an ItemEntity's own
// random motion).
if !creative {
self.entity_killed_event(at, false);
}
@@ -1535,7 +1702,7 @@ impl Game<'_> {
extra: None,
},
at,
[0.0, 0.1, 0.0],
super::dispenser::new_item_motion(),
10,
);
}
+2
View File
@@ -1883,6 +1883,8 @@ impl Game<'_> {
}
self.ui.chat.clear();
self.build_registries();
// The world opens with its test instances (`Registries.TEST_INSTANCE`).
self.snapshot_test_defs();
self.sync_rules();
}
/// Save the clock beside level.dat (Level.write saves the game rules: a
@@ -0,0 +1,156 @@
//! `CloneCommands.clone` ends with `LevelTicks.copyAreaFrom`: every block
//! tick pending in the source box is scheduled at the same offset in the
//! destination, with its trigger time, whatever the clone placed there
//! (tools/test_clone_block_ticks_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::{BlockPos, ChunkPos};
use std::sync::Arc;
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);
// A loaded chunk whose block tick queue is registered.
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("fill 0 99 0 15 99 15 stone"));
game
}
/// The block ticks queued at `pos`: (block, trigger tick).
fn ticks_at(game: &Game<'_>, pos: BlockPos) -> Vec<(u32, i64)> {
game.block_ticks
.blocks
.chunk(ChunkPos::new(0, 0))
.unwrap()
.queued()
.iter()
.filter(|t| t.pos == pos)
.map(|t| (t.kind.0, t.trigger_tick))
.collect()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_strict_clone_copies_the_pending_block_ticks_of_its_source() {
let mut game = game();
let source = BlockPos::new(1, 101, 1);
assert!(game.run_command("setblock 1 101 1 sand"));
let pending = ticks_at(&game, source);
assert_eq!(pending.len(), 1, "the placed sand's fall is queued");
// Strict: the clone places the sand without its own tick.
assert!(game.run_command("clone 1 101 1 1 101 1 3 101 1 strict replace"));
assert_eq!(
ticks_at(&game, BlockPos::new(3, 101, 1)),
pending,
"copied as it was"
);
assert_eq!(ticks_at(&game, source), pending, "the source keeps its own");
// Filtered: the sand's tick comes with it, the dirt below has none.
assert!(game.run_command("setblock 5 100 1 dirt"));
assert!(game.run_command("setblock 5 101 1 sand"));
let pending = ticks_at(&game, BlockPos::new(5, 101, 1));
assert!(game.run_command("clone 5 100 1 5 101 1 7 100 1 strict filtered minecraft:sand"));
assert_eq!(ticks_at(&game, BlockPos::new(7, 101, 1)), pending);
assert_eq!(ticks_at(&game, BlockPos::new(7, 100, 1)), vec![]);
// Move: the copy is made after the source was cleared.
assert!(game.run_command("setblock 9 101 1 sand"));
let pending = ticks_at(&game, BlockPos::new(9, 101, 1));
assert!(game.run_command("clone 9 101 1 9 101 1 11 101 1 strict replace move"));
assert_eq!(ticks_at(&game, BlockPos::new(11, 101, 1)), pending);
}
/// Added controls (after the baseline). A destination that already holds a
/// tick for the same block keeps that one (`LevelChunkTicks.schedule` keeps
/// one tick per position and type). A copied tick keeps its priority and
/// comes after the ticks queued before it. The copy is made before the
/// command fails on an empty count, as `copyAreaFrom` is called before
/// `ERROR_FAILED`.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn copied_block_ticks_keep_order_priority_and_dedup() {
let mut game = game();
let queued = |game: &Game<'_>, pos: BlockPos| {
game.block_ticks
.blocks
.chunk(ChunkPos::new(0, 0))
.unwrap()
.queued()
.iter()
.filter(|t| t.pos == pos)
.copied()
.collect::<Vec<_>>()
};
// The destination's own tick stays the only one.
assert!(game.run_command("setblock 7 101 5 sand"));
let own = queued(&game, BlockPos::new(7, 101, 5));
assert_eq!(own.len(), 1);
assert!(game.run_command("setblock 5 101 5 sand"));
assert!(game.run_command("clone 5 101 5 5 101 5 7 101 5 strict replace"));
assert_eq!(
queued(&game, BlockPos::new(7, 101, 5)),
own,
"kept, not doubled"
);
// Priority kept; ordered after what was queued before.
assert!(game.run_command("setblock 1 101 9 sand"));
let source = queued(&game, BlockPos::new(1, 101, 9))[0];
assert!(game.run_command("clone 1 101 9 1 101 9 3 101 9 strict replace"));
let copied = queued(&game, BlockPos::new(3, 101, 9));
assert_eq!(copied.len(), 1);
assert_eq!(
(copied[0].kind, copied[0].trigger_tick, copied[0].priority),
(source.kind, source.trigger_tick, source.priority)
);
assert!(copied[0].sub_tick_order > source.sub_tick_order);
// A clone that places nothing (a barrier) fails, its tick copied first.
assert!(game.run_command("setblock 9 101 9 sand"));
let pending = queued(&game, BlockPos::new(9, 101, 9));
assert!(game.run_command("setblock 9 101 9 barrier strict"));
assert_eq!(
queued(&game, BlockPos::new(9, 101, 9)),
pending,
"the tick stays"
);
assert!(!game.run_command("clone 9 101 9 9 101 9 11 101 9 strict replace"));
let moved: Vec<_> = queued(&game, BlockPos::new(11, 101, 9))
.iter()
.map(|t| (t.kind, t.trigger_tick))
.collect();
assert_eq!(moved, vec![(pending[0].kind, pending[0].trigger_tick)]);
}
@@ -680,6 +680,49 @@ impl Game<'_> {
Ok(())
})?;
}
"mob_interact" => {
// The owner's real Interact packet on a live mob, by UUID.
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("mob click: missing UUID")?
.replace('-', ""),
16,
)?;
self.in_owner_level(|g| -> Result<()> {
let id = g
.mob_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
.filter(|id| g.mobs.iter().any(|m| m.id == *id && !m.removed))
.ok_or("mob click: missing live mob")?;
let owner = g.serving.as_ref().ok_or("mob click: no server")?.owner;
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.map(|p| p.conn)
})
.ok_or("mob click: owner not connected")?;
let mut host = g.lan.take().ok_or("mob click: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::Interact(
horizon_protocol::packets::play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"use_item" => {
let owner = self
.serving
+42 -9
View File
@@ -2638,8 +2638,23 @@ impl Game<'_> {
.resolve(&state)
.map_err(|_| err("argument.block.id.invalid", &[&b.id]))
}
/// BlockPredicateArgument test.
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
/// BlockPredicateArgument test of the block at `p`: its state, then any
/// `{...}` against the block entity's `saveWithFullMetadata`
/// (`NbtUtils.compareNbt(…, true)`); no block entity fails.
fn block_matches(&self, b: &BlockInput, p: [i32; 3]) -> bool {
let state = self.world.block(p[0], p[1], p[2]);
if !self.block_state_matches(b, state) {
return false;
}
let Some(nbt) = &b.nbt else { return true };
// (the argument's own grammar, `TagParser`, as the predicate was read)
let Ok(pattern) = horizon_client::command::snbt::parse_fully(nbt) else {
return false;
};
horizon_client::nbt_path::compare_nbt(&pattern, self.block_entity_nbt(p).as_ref())
}
/// The state part of `block_matches`: name or tag, then properties.
fn block_state_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
block_input_matches(
self.pack.assets.registry,
[&self.res.items().block_tags, &self.items().block_tags],
@@ -3027,12 +3042,17 @@ impl Game<'_> {
continue;
}
if let Some(f) = &filter {
let ok = match matched.get(&current) {
Some(&ok) => ok,
None => {
let ok = self.block_matches(f, current);
matched.insert(current, ok);
ok
// (a filter with NBT reads each block's own block entity)
let ok = if f.nbt.is_some() {
self.block_matches(f, [x, y, z])
} else {
match matched.get(&current) {
Some(&ok) => ok,
None => {
let ok = self.block_state_matches(f, current);
matched.insert(current, ok);
ok
}
}
};
if !ok {
@@ -3116,7 +3136,7 @@ impl Game<'_> {
continue;
}
if let Some(f) = &filter
&& !self.block_matches(f, s)
&& !self.block_matches(f, [x, y, z])
{
continue;
}
@@ -3159,6 +3179,19 @@ impl Game<'_> {
};
}
}
// LevelTicks.copyAreaFrom: the block ticks pending anywhere in the
// source box go to the same offset in the destination, before the
// command can fail on an empty count.
{
use horizon_core::position::BlockPos;
self.block_ticks.blocks.copy_area(
horizon_core::ticks::TickArea::new(
BlockPos::new(min[0], min[1], min[2]),
BlockPos::new(max[0], max[1], max[2]),
),
BlockPos::new(dest[0] - min[0], dest[1] - min[1], dest[2] - min[2]),
);
}
if count == 0 {
return Err(err("commands.clone.failed", &[]));
}
@@ -144,15 +144,7 @@ impl Game<'_> {
let uuid = self.uuid_for(id);
let i = self.boats.iter().position(|b| b.id == id)?;
let b = &self.boats[i];
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
);
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
tag
@@ -376,6 +368,11 @@ impl Game<'_> {
if keep_liked_player {
slot.extra.tame = old.extra.tame;
slot.extra.owner = old.extra.owner;
if let (Some(st), Some(was)) =
(slot.extra.allay.as_mut(), old.extra.allay.as_ref())
{
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.
@@ -453,7 +450,7 @@ impl Game<'_> {
}
Target::Boat(id) => {
// AbstractBoat.readAdditionalSaveData over Entity.load: the
// position, rotation, motion, NoGravity and a chest boat's items.
// position, rotation, motion, NoGravity, Fire and a chest boat's items.
let (position, velocity, yaw) =
entity_nbt::boat_from_nbt(&tag).ok_or_else(stale)?;
let uuid = self.uuid_for(id);
@@ -469,6 +466,7 @@ impl Game<'_> {
b.previous_yaw = yaw;
b.pitch = entity_nbt::rotation(&tag)[1];
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
b.fire_ticks = crate::game::boats::saved_fire(&tag);
if b.chest {
b.items = save::decode_items(tag.get("Items"), crate::game::boats::CHEST_SLOTS);
}
@@ -1039,8 +1039,7 @@ impl Game<'_> {
let Some(Arg::Block(b)) = c.get("block").cloned() else {
return Ok((false, 0));
};
let state = self.world.block(p[0], p[1], p[2]);
return Ok(flag(self.block_matches(&b, state)));
return Ok(flag(self.block_matches(&b, p)));
}
if c.has("biome") {
let p = self.block_pos_arg(c, "pos")?;
@@ -1127,6 +1127,10 @@ impl Game<'_> {
}
"bench" => {
let seconds: f64 = arg(1)?.parse()?;
// `Duration::from_secs_f64` panics on a negative, NaN or huge number.
if !(seconds.is_finite() && (0.0..=86_400.0).contains(&seconds)) {
return Err("bench takes 0 to 86400 seconds".into());
}
if self.pending_bench.is_some() {
return Err("a benchmark is already running".into());
}
+15 -6
View File
@@ -1691,16 +1691,24 @@ impl Game<'_> {
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
let killer_entity = self.killer_loot_entity(killer.flatten());
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
// the environment long after a player's hit names no player.
// LAST_DAMAGE_PLAYER only while the player is still in the world and
// the hurt memory lasts (LivingEntity.dropFromLootTable). Experience
// and equipment use the timer alone (dropExperience / playerKilled):
// a guest who has left still counts.
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
self.lead_killers.insert(m.id, (player, self.dim));
}
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.drop_death_loot(
&mut dying,
player,
m.ai.last_hurt_by_player > 0,
killer_entity,
consumed,
pos,
);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1866,7 +1874,7 @@ impl Game<'_> {
&mut self,
m: &mut Mob,
player_uuid: Option<u128>,
by_player: bool,
player_killed: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
pos: [f64; 3],
@@ -1933,7 +1941,8 @@ impl Game<'_> {
let death = drops::Death {
loot: &loot,
damage,
player: by_player.then(player),
player: player_uuid.is_some().then(player),
player_killed,
luck: 0.0,
vehicle,
mob_drops: self.ui.rules.bool("mob_drops"),
+2 -2
View File
@@ -1284,8 +1284,8 @@ impl Game<'_> {
let mut braked = Instant::now();
let mut overload_warned: Option<Instant> = None;
// `/stop`, or a stop signal (the shutdown hook's `halt`): either way the players and levels are
// saved below.
while !state.lists.lock().unwrap().stop && horizon_threads::stop_requests() == 0 {
// saved below, after the wait `[transfer]` may make (transfer.rs).
while !self.stop_now(&state) {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
@@ -30,7 +30,7 @@ impl ServerState {
}
}
/// Ticks per second over the last few seconds.
fn tps(&self) -> f64 {
pub(super) fn tps(&self) -> f64 {
match (self.ticks.front(), self.ticks.back()) {
(Some(first), Some(last)) if self.ticks.len() > 1 => {
(self.ticks.len() - 1) as f64 / last.duration_since(*first).as_secs_f64().max(1e-9)
@@ -50,6 +50,66 @@ impl Game<'_> {
}
}
/// Whether the tick loop ends now. A stop (`/stop`, a stop signal) ends it at once, unless
/// `[transfer]` is on and players are here: the stop then waits for a proxy to move them out
/// (`arrivals::StopWait`), every login refused, the server ticking on. A second `stop` ends the
/// wait; a second signal ends the process (`horizon_threads::catch_stop_signals`).
pub(super) fn stop_now(&mut self, state: &super::ServerState) -> bool {
let asked = std::mem::take(&mut state.lists.lock().unwrap().stop);
let stop = asked || horizon_threads::stop_requests() > 0;
let Some(host) = self.lan.as_mut() else {
return stop;
};
let waiting = host
.hub
.transfers
.as_ref()
.is_some_and(|t| t.stop_wait.is_some());
if !stop && !waiting {
return false;
}
let (placed, frozen) = arrivals::placed_and_frozen(&host.hub);
let Some(transfers) = host.hub.transfers.as_mut() else {
return stop;
};
let now = Instant::now();
// A freeze lasts ticks: how long one takes now, for `check_freeze`.
let period = arrivals::tick_period(self.clock.tick_duration(), state.tps());
match &mut transfers.stop_wait {
None => {
transfers.stopping = true;
let wait = arrivals::StopWait::start(&transfers.settings, now, period);
let Some(wait) = wait.filter(|_| placed > 0) else {
return true;
};
transfers.stop_wait = Some(wait);
host.hub.lan.shared.refuse_logins();
super::log(
"INFO",
&arrivals::stop_wait_line(transfers.settings.stop_wait),
);
false
}
Some(_) if asked => {
super::log("INFO", "A second stop: stopping now");
true
}
Some(wait) => {
wait.period = period;
let over = wait.over(now, placed, frozen);
if over && frozen > 0 {
super::log(
"WARN",
&format!(
"Stopping with {frozen} hand-over(s) still frozen: their players are saved here"
),
);
}
over
}
}
}
/// `transfer-status`: the counts a proxy reads before it sends players here
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
/// the log.
@@ -279,6 +339,9 @@ impl Game<'_> {
Ok(i) => i,
Err(why) => return json!({ "error": why }),
};
if freeze && let Err(why) = arrivals::check_freeze(&host.hub, i, Instant::now()) {
return json!({ "error": why });
}
// A repeat while the hand-over lasts: the same record, and nothing touched.
if let Some(h) = &host.hub.guests[i].handover {
return arrivals::export_reply(&host.hub, i, h.record.clone());
+189 -72
View File
@@ -21,15 +21,46 @@ use serde_json::Value;
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
/// One of the player's equipment slots (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
enum PlayerSlot {
Main,
Off,
/// An `armor` index (head first).
Armor(usize),
}
/// `EquipmentSlot.VALUES` over what the player holds and wears: the main
/// hand, the off hand, then feet, legs, chest and head (`armor` holds head
/// first).
const PLAYER_SLOTS: [PlayerSlot; 6] = [
PlayerSlot::Main,
PlayerSlot::Off,
PlayerSlot::Armor(3),
PlayerSlot::Armor(2),
PlayerSlot::Armor(1),
PlayerSlot::Armor(0),
];
impl PlayerSlot {
/// `EquipmentSlot.getName`.
fn name(self) -> &'static str {
match self {
Self::Main => "mainhand",
Self::Off => "offhand",
Self::Armor(i) => ["head", "chest", "legs", "feet"][i],
}
}
fn equipment_slot(self) -> horizon_world::player_inventory::EquipmentSlot {
use horizon_world::player_inventory::EquipmentSlot;
match self {
Self::Main => EquipmentSlot::MainHand,
Self::Off => EquipmentSlot::OffHand,
Self::Armor(i) => super::armor_slot(i),
}
}
}
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
/// without the attribute keeps the vanilla default of 1.0.
fn block_break_speed_factor(value: Option<f64>) -> f32 {
@@ -461,71 +492,158 @@ impl Game<'_> {
let _ = self.wind_burst(feet, radius, knockback, false, false);
}
}
/// Thorns on worn armor when the mob `attacker` hits the player: the
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
/// entity that caused the hit.
/// `EnchantmentHelper.doPostAttackEffects`' `VICTIM` loop after the mob
/// `attacker` hurt the player: Thorns on what the player holds or wears
/// hurts the mob by `thorns` damage the player caused, so it is knocked
/// back from the player, turns on them and is theirs to kill.
pub(super) fn thorns(&mut self, attacker: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
return;
};
for damage in self.thorns_damage() {
let m = &mut self.mobs[index];
m.hurt(damage, None, &mut self.world.random);
let center = [m.position.x, m.position.y, m.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
}
self.player_thorns(|g, min, max| {
// DamageEntity: Mth.randomBetween from the hurt entity's own random.
let Some(index) = g.mobs.iter().position(|m| m.id == attacker) else {
return;
};
let damage = g.mobs[index].random.next_float() * (max - min) + min;
let Some(p) = g.player.as_ref() else { return };
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Thorns,
attacker: Some(Who::Player),
from: Some([p.position.x, p.position.y, p.position.z]),
attacker_species: None,
};
let alive = g.mobs[index].alive();
if g.hurt_by_player(index, damage, source) {
if alive && !g.mobs[index].alive() {
g.native_kill_event(index);
}
g.last_hurt_by_player.insert(attacker, g.lan_acting);
}
});
}
/// The victim's side of thorns: for each worn piece whose chance comes up,
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
/// piece's wear (`ChangeItemDamage` 2).
/// The victim's side of thorns when another player hit this one: the
/// damage each piece deals that player, rolled from the attacker's own
/// random (a player's, which no level draw shares).
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
let mut hits = Vec::new();
for slot in 0..4 {
let Some(stack) = self.armor[slot].clone() else {
self.player_thorns(|_, min, max| {
hits.push(random_source::entity().next_float() * (max - min) + min);
});
hits
}
/// `EnchantmentHelper.runIterationOnEquipment` over the player's hands and
/// armor, in `EquipmentSlot` order, running `Enchantment.doPostAttack` for
/// the `minecraft:post_attack` effects with `enchanted: victim` and
/// `affected: attacker` (Thorns): the chance from the level's random
/// (`Enchantment.damageContext`), then each `damage_entity` handed to `hit` (min,
/// max) and each `change_item_damage` worn off the piece, in the effect's
/// order. The attacker being dead stops neither the rolls nor the wear.
fn player_thorns(&mut self, mut hit: impl FnMut(&mut Self, f32, f32)) {
for slot in PLAYER_SLOTS {
let Some(stack) = self.player_slot(slot).clone() else {
continue;
};
let level = stack.enchantment_level("minecraft:thorns");
if level == 0 {
continue;
}
let Some(def) = self.stations().enchantments.get("minecraft:thorns") else {
continue;
};
let effect = def.effects["minecraft:post_attack"][0].clone();
let ctx = EffectContext {
items: Some(world_items!(self)),
..Default::default()
};
let mut random = self.world.random.clone();
let ok = self.stations().enchantments.condition(
&effect["requirements"],
level,
&ctx,
&mut random,
);
self.world.random = random;
if !ok {
continue;
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
let loss = self.wear(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += loss;
if self
.res
.items()
.get(&s.item)
.is_some_and(|i| s.damage >= i.max_damage)
let mut effects = Vec::new();
for (enchantment, level) in stack.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
continue;
};
// Enchantment.matchingSlot
let (name, hand) = (
slot.name(),
matches!(slot, PlayerSlot::Main | PlayerSlot::Off),
);
if !def.slots.iter().any(|s| {
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:post_attack"]
.as_array()
.into_iter()
.flatten()
{
self.armor[slot] = None;
self.player_equipment_broke(super::armor_slot(slot), &stack);
self.apply_equipment();
if effect["enchanted"] == "victim" && effect["affected"] == "attacker" {
effects.push((effect.clone(), *level));
}
}
}
for (effect, level) in effects {
let ctx = EffectContext {
this: Some("minecraft:player"),
items: Some(world_items!(self)),
..Default::default()
};
let ok = effect.get("requirements").is_none_or(|r| {
world_stations!(self).enchantments.condition(
r,
level,
&ctx,
&mut self.world.random,
)
});
if ok {
self.thorns_effect(&effect["effect"], level, slot, &mut hit);
}
}
}
hits
}
/// `AllOf.apply`, `DamageEntity.apply` and `ChangeItemDamage.apply` of one
/// Thorns-like effect of the piece in `slot`.
fn thorns_effect(
&mut self,
effect: &Value,
level: u32,
slot: PlayerSlot,
hit: &mut impl FnMut(&mut Self, f32, f32),
) {
match effect["type"]
.as_str()
.map(|t| t.trim_start_matches("minecraft:"))
{
Some("all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.thorns_effect(e, level, slot, hit);
}
}
Some("damage_entity") => {
let min = level_value(&effect["min_damage"], level);
let max = level_value(&effect["max_damage"], level);
hit(self, min, max);
}
Some("change_item_damage") => {
let amount = level_value(&effect["amount"], level) as u32;
self.wear_player_piece(slot, amount);
}
_ => {}
}
}
/// `ItemStack.hurtAndBreak` for what the player has in `slot`: nothing for
/// an item that takes no wear or a creative player, Unbreaking's share
/// from the level's random, and a piece worn out leaves the slot.
fn wear_player_piece(&mut self, slot: PlayerSlot, amount: u32) {
let Some(stack) = self.player_slot(slot).clone() else {
return;
};
if amount == 0
|| self.creative
|| !horizon_client::stations::is_damageable(world_items!(self), &stack)
{
return;
}
let loss = self.wear(&stack, amount);
let max = world_items!(self)
.get(&stack.item)
.map_or(0, |i| i.max_damage);
let Some(s) = self.player_slot_mut(slot).as_mut() else {
return;
};
s.damage += loss;
if s.damage >= max {
*self.player_slot_mut(slot) = None;
self.player_equipment_broke(slot.equipment_slot(), &stack);
if let PlayerSlot::Armor(_) = slot {
self.apply_equipment();
}
}
}
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
@@ -678,16 +796,14 @@ impl Game<'_> {
let mut amount = amount;
loop {
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
// chest and head (`armor` holds head first).
// EquipmentSlot order.
let mending = |s: &Option<ItemStack>| {
s.as_ref()
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
};
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
let candidates: Vec<PlayerSlot> = PLAYER_SLOTS
.into_iter()
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
.filter(|&slot| mending(self.mend_slot(slot)))
.filter(|&slot| mending(self.player_slot(slot)))
.collect();
if candidates.is_empty() {
return amount;
@@ -697,7 +813,7 @@ impl Game<'_> {
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0) as usize];
let Some(stack) = self.mend_slot(slot).clone() else {
let Some(stack) = self.player_slot(slot).clone() else {
return amount;
};
let ctx = EffectContext {
@@ -718,7 +834,7 @@ impl Game<'_> {
}
// A hook can re-enter experience granting or replace the slot. Only
// repair the same eligible stack, using its current damage.
let Some(current) = self.mend_slot_mut(slot).as_mut() else {
let Some(current) = self.player_slot_mut(slot).as_mut() else {
return amount;
};
let mut original = stack.clone();
@@ -759,18 +875,19 @@ impl Game<'_> {
amount = remaining;
}
}
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
/// `LivingEntity.getItemBySlot` for the player.
fn player_slot(&self, slot: PlayerSlot) -> &Option<ItemStack> {
match slot {
MendSlot::Main => &self.inventory[self.selected],
MendSlot::Off => &self.offhand,
MendSlot::Armor(i) => &self.armor[i],
PlayerSlot::Main => &self.inventory[self.selected],
PlayerSlot::Off => &self.offhand,
PlayerSlot::Armor(i) => &self.armor[i],
}
}
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
fn player_slot_mut(&mut self, slot: PlayerSlot) -> &mut Option<ItemStack> {
match slot {
MendSlot::Main => &mut self.inventory[self.selected],
MendSlot::Off => &mut self.offhand,
MendSlot::Armor(i) => &mut self.armor[i],
PlayerSlot::Main => &mut self.inventory[self.selected],
PlayerSlot::Off => &mut self.offhand,
PlayerSlot::Armor(i) => &mut self.armor[i],
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
+14 -6
View File
@@ -271,6 +271,10 @@ pub(super) trait Entity: EntityRules {
let position = self.position_mut();
*position = position.add_vector(movement);
}
/// `setOnGroundWithMovement` at the end of a piston's or shulker lid's
/// `Entity.move`, for the kinds that keep the flag: on a server it runs
/// for every push, sideways ones and those the collision turned away included.
fn set_piston_on_ground(&mut self, _on_ground: bool) {}
/// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity
/// along the movement axis.
fn piston_slime_bounce(&mut self, movement: Direction) {
@@ -1185,12 +1189,8 @@ impl Entity for ItemEntity {
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
// Entity.move(MoverType.PISTON): onGround only if the push was down.
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) {
ItemEntity::piston_slime_bounce(self, movement);
@@ -1490,6 +1490,11 @@ impl Entity for ArmorStand {
fn on_ground(&self) -> bool {
self.on_ground
}
/// A living entity's own flag too: keeping the old support flag would run
/// Frost Walker while the stand is moving over open water.
fn set_piston_on_ground(&mut self, on_ground: bool) {
self.on_ground = on_ground;
}
fn is_living(&self) -> bool {
true
}
@@ -1577,6 +1582,9 @@ impl Entity for PrimedTnt {
fn on_ground(&self) -> bool {
self.on_ground
}
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);
// Leave the ground so the next tick does not apply the on-ground
+16 -1
View File
@@ -3325,15 +3325,21 @@ impl Game<'_> {
return;
};
let at = g.position;
let players = self.stab_players_for(host, conn);
let sound = self
.guest_hit_by(host, conn, |g| {
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
None
} else {
g.jab()
let outer = std::mem::replace(&mut g.stab_players, players);
let sound = g.jab();
g.stab_players = outer;
sound
}
})
.flatten();
// The players the jab stabbed, now that the hub is back.
self.land_player_stabs(host);
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
// own client plays it.
if let Some(name) = sound {
@@ -4180,7 +4186,14 @@ impl Game<'_> {
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
if digging.is_some() || using || delayed {
// A spear in use reaches the other players too.
let players = if using {
self.stab_players_for(host, conn)
} else {
Vec::new()
};
self.as_guest(host, conn, |g| {
let outer = std::mem::replace(&mut g.stab_players, players);
g.attack_ticks = g.attack_ticks.saturating_add(1);
if let Some((pos, face)) = digging {
g.forced_hit = Some(Hit {
@@ -4197,10 +4210,12 @@ impl Game<'_> {
g.forced_hit = None;
g.right = g.using.is_some();
g.tick_using();
g.stab_players = outer;
if g.use_delay > 0 {
g.use_delay -= 1;
}
});
self.land_player_stabs(host);
// ServerPlayerGameMode.handleBlockBreakAction: creative and
// instant survival STARTs finish without a STOP from the client.
// tick_mining clears mining on completion; forget the START too,
+300 -4
View File
@@ -446,7 +446,7 @@ impl Game<'_> {
}
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
/// book places a known recipe in their inventory grid or crafting table
/// book places a known recipe in their inventory grid, crafting table or furnace
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
/// are missing.
pub(super) fn lan_place_recipe(
@@ -483,9 +483,62 @@ impl Game<'_> {
let (player, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
let creative = g.creative();
let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
// AbstractFurnaceMenu.handlePlacement: the current furnace's slots,
// rather than the separate crafting-table menu, own cooking inputs.
let furnace = host.hub.guests[gi]
.menu
.as_ref()
.filter(|m| p.container != 0 && m.window == p.container)
.and_then(|m| match m.slots {
Slots::Block(pos) => Some(pos),
_ => None,
})
.and_then(|pos| match self.world.block_entities.get(&pos) {
Some(BlockEntity::Furnace(f)) => {
let recipe = match f.kind.recipe_type() {
"minecraft:blasting" => "blasting",
"minecraft:smoking" => "smoking",
_ => "smelting",
};
Some((pos, Kind::Furnace(recipe), f.items.clone()))
}
_ => None,
});
let placed = if let Some((pos, kind, items)) = furnace {
// Opening need not have created the click/drag cache yet.
let mut menu = host
.play
.entry(conn)
.or_default()
.crafting
.machine
.take()
.filter(|(window, m)| *window == p.container && m.kind() == kind)
.map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m);
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
for (k, item) in items.iter().enumerate() {
menu.set_slot(env, k, item.clone());
}
menu.place_recipe(env, &id, p.use_max, creative, None)
});
let changed = (0..3).any(|k| menu.slot(k) != items[k].as_ref());
if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) {
for k in 0..3 {
f.items[k] = menu.slot(k).cloned();
}
f.input_clicked(world_recipes!(self), items[0].clone());
}
if changed {
self.container_changed(pos);
}
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
// Hub::sync_containers broadcasts the actual changed slots.
Some(placed)
} else {
self.lan_apply_recipe(host, gi, p.container, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
})
};
if placed == Some(PostPlace::Ghost) {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
@@ -824,3 +877,246 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod machine_recipe_placement_tests {
use super::*;
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{Serverbound, UseItemOn},
recipes::PlaceRecipe,
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h234_registered_place_recipe_reaches_actual_furnace_and_preserves_controls() {
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, "machine-place-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: 234,
name: "RecipeGuest".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 planks = game
.stations()
.recipe_index
.first_display("minecraft:oak_planks")
.unwrap();
let charcoal = game
.stations()
.recipe_index
.first_display("minecraft:charcoal")
.unwrap();
let command = |game: &mut Game<'_>, host: &mut LanHost, text: &str| {
assert!(
game.as_guest(host, 2, |g| g.run_command(text)).unwrap(),
"{text}"
);
};
let place = |game: &mut Game<'_>, host: &mut LanHost, container, recipe| {
game.lan_guest_packet(
host,
2,
Serverbound::PlaceRecipe(PlaceRecipe {
container,
recipe,
use_max: false,
}),
);
};
let open = |game: &mut Game<'_>, host: &mut LanHost, pos| {
game.lan_guest_packet(
host,
2,
Serverbound::UseItemOn(UseItemOn {
hand: 0,
pos,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence: 234,
}),
);
assert!(host.hub.guest(2).unwrap().ack_up_to.get() >= 234);
host.play
.get(&2)
.and_then(|p| p.crafting.table.as_ref())
.map(|(window, ..)| *window)
.or_else(|| host.hub.guest(2).unwrap().menu.as_ref().map(|m| m.window))
.expect("actual table or machine window opened")
};
command(&mut game, &mut host, "recipe give @s minecraft:oak_planks");
command(&mut game, &mut host, "recipe give @s minecraft:charcoal");
assert!(
host.hub
.guest(2)
.unwrap()
.recipe_book
.knows("minecraft:charcoal")
);
command(
&mut game,
&mut host,
"item replace entity @s hotbar.0 with oak_log 3",
);
place(&mut game, &mut host, 0, planks);
assert_eq!(
host.hub.guest(2).unwrap().craft_grid[0],
Some(ItemStack::new("minecraft:oak_log", 1))
);
assert_eq!(
host.hub.guest(2).unwrap().craft_result,
Some(ItemStack::new("minecraft:oak_planks", 4))
);
assert_eq!(
host.hub.guest(2).unwrap().inventory[0]
.as_ref()
.unwrap()
.count,
2
);
game.lan_crafting_closed(&mut host, 2, Some(0));
assert!(game.run_command("setblock 10 200 8 crafting_table"));
let window = open(&mut game, &mut host, [10, 200, 8]);
place(&mut game, &mut host, window, planks);
let (_, _, table, _) = host.play[&2].crafting.table.as_ref().unwrap();
assert_eq!(
table.slot(0),
Some(&ItemStack::new("minecraft:oak_planks", 4))
);
game.guest_close_all(&mut host, 2);
let mut failures = Vec::new();
for (label, count, known) in [
("place", 3, true),
("ghost", 0, true),
("forgotten", 3, false),
] {
game.as_guest(&mut host, 2, |g| {
g.run_command("clear @s");
})
.unwrap();
if count > 0 {
command(
&mut game,
&mut host,
"item replace entity @s hotbar.0 with oak_log 3",
);
}
command(
&mut game,
&mut host,
"item replace entity @s hotbar.8 with stick[minecraft:custom_data={h234:1}] 7",
);
assert!(game.run_command("setblock 10 200 8 air"));
assert!(game.run_command("setblock 10 200 8 furnace"));
assert!(game.run_command("item replace block 10 200 8 container.1 with coal[minecraft:custom_data={h234:2}] 2"));
let window = open(&mut game, &mut host, [10, 200, 8]);
if !known {
command(&mut game, &mut host, "recipe take @s minecraft:charcoal");
}
assert_eq!(
host.hub
.guest(2)
.unwrap()
.recipe_book
.knows("minecraft:charcoal"),
known
);
let before = host.hub.guest(2).unwrap().inventory.clone();
let pos = (10, 200, 8);
let Some(BlockEntity::Furnace(f)) = game.world.block_entities.get(&pos) else {
panic!("actual furnace missing")
};
assert!(f.items[0].is_none() && f.items[2].is_none());
let fuel = f.items[1].clone();
assert_eq!(fuel.as_ref().unwrap().count, 2);
place(&mut game, &mut host, window, charcoal);
let Some(BlockEntity::Furnace(f)) = game.world.block_entities.get(&pos) else {
panic!("furnace removed")
};
assert_eq!(f.items[1], fuel);
assert!(f.items[2].is_none());
let mut expected = before.clone();
let input = if label == "place" {
expected[0].as_mut().unwrap().count = 2;
Some(ItemStack::new("minecraft:oak_log", 1))
} else {
None
};
if f.items[0] != input || host.hub.guest(2).unwrap().inventory != expected {
failures.push((
label,
f.items.clone(),
host.hub.guest(2).unwrap().inventory.clone(),
));
}
assert_eq!(host.hub.guest(2).unwrap().uuid, 234);
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);
game.guest_close_all(&mut host, 2);
}
assert!(
failures.is_empty(),
"actual registered PlaceRecipe furnace failures: {failures:?}"
);
}
}
@@ -213,6 +213,10 @@ impl Game<'_> {
e.leash = self.boat_leash_link(b, &guests);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
// Entity.DATA_SHARED_FLAGS_ID: FLAG_ONFIRE.
if b.fire_ticks > 0 {
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
}
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
// Omitted false values must reset clients that saw a true value,
// including when the controlling passenger dismounts mid-stroke.
@@ -456,6 +456,13 @@ impl Game<'_> {
Some(Particle { id, data })
}
/// This window plays on an older release's server through the adapter.
fn older_server(&self) -> bool {
self.remote.as_ref().is_some_and(|r| {
r.session.server_version() != horizon_protocol::adapter::ServerVersion::Current
})
}
/// The options a server's particle stands for (None for a type or data
/// this client doesn't draw).
pub(super) fn received_particle(
@@ -463,7 +470,9 @@ impl Game<'_> {
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
if let Some(options) = legacy_effect_options(p) {
// The adapter's markers mean something only on an older server's connection: a
// 26.3 server's ids and durations are read as they are.
if let Some(options) = legacy_effect_options(p).filter(|_| self.older_server()) {
return Some(options);
}
if p.id == MOD_PARTICLE_ID
@@ -511,9 +520,10 @@ impl Game<'_> {
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base)) => {
ParticleOptions::geyser_base(ty, *water_blocks, *burst_impulse_base)
}
Some(ParticleData::Trail(target, color, duration)) => {
ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
}
Some(ParticleData::Trail(target, color, duration)) => ParticleOptions {
legacy_trail_lifetime: *duration == -1 && self.older_server(),
..ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
},
Some(ParticleData::Vibration { source, ticks }) => ParticleOptions::vibration(
source.clone(),
self.vibration_destination(source)?,
+57 -43
View File
@@ -54,7 +54,7 @@ pub(super) fn pvp_hit(victim_entity: i32) -> Option<PvpHit> {
}
/// Names `hit` as the one being landed until it drops.
struct PvpScope(Option<PvpHit>);
pub(super) struct PvpScope(Option<PvpHit>);
impl Drop for PvpScope {
fn drop(&mut self) {
@@ -62,7 +62,7 @@ impl Drop for PvpScope {
}
}
fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
pub(super) fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
PvpScope(PVP_HIT.replace(hit))
}
@@ -214,7 +214,7 @@ impl Game<'_> {
/// The held weapon's `minecraft:ignite` (Fire Aspect) seconds on a player
/// victim (`EnchantmentHelper.doPostAttackEffects`).
fn weapon_ignite_seconds(&mut self) -> f32 {
pub(super) fn weapon_ignite_seconds(&mut self) -> f32 {
let Some(held) = self.held().cloned() else {
return 0.0;
};
@@ -326,7 +326,7 @@ impl Game<'_> {
/// The hit of `attacker` (a guest; none: this game's player) on `victim` as
/// `native_player_hurt_by_player` names it, none when either has gone.
fn pvp_hit_of(
pub(super) fn pvp_hit_of(
&mut self,
host: &LanHost,
attacker: Option<u32>,
@@ -457,26 +457,6 @@ impl Game<'_> {
} else {
Default::default()
};
// ServerPlayer.awardKillScore: the blow that kills a player.
if hurt {
let died = match victim {
// A totem that fired is applied on the next tick_totem, so the
// victim is still at zero health but is not dying.
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref())
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
};
if died {
match s.attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
}
None => self.award_custom_stat("player_kills", 1),
}
}
}
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Guest(conn) => host
@@ -499,25 +479,7 @@ impl Game<'_> {
}
}
}
let thorns_cause = self.player_attacker(host, victim.conn());
for damage in thorns {
match s.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)
});
}
}
}
self.pvp_aftermath(host, victim, s.attacker, hurt, thorns);
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
(true, true, _) => "minecraft:entity.player.attack.crit",
@@ -554,6 +516,58 @@ impl Game<'_> {
hurt
}
/// What follows a player's blow on a player as the victim's hurt ends:
/// `ServerPlayer.awardKillScore` when it killed them, then the victim's
/// thorns dealt back to the attacker.
pub(super) fn pvp_aftermath(
&mut self,
host: &mut LanHost,
victim: Victim,
attacker: Option<u32>,
hurt: bool,
thorns: Vec<f32>,
) {
// ServerPlayer.awardKillScore: the blow that kills a player.
if hurt {
let died = match victim {
// A totem that fired is applied on the next tick_totem, so the
// victim is still at zero health but is not dying.
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref())
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
};
if died {
match attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
}
None => self.award_custom_stat("player_kills", 1),
}
}
}
let thorns_cause = self.player_attacker(host, victim.conn());
for damage in thorns {
match 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)
});
}
}
}
}
/// The sweep of the player in place, for the players it reaches: the
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
/// player, scaled by the attack strength) and who hits. The box is set
@@ -294,14 +294,9 @@ impl Game<'_> {
}
let mut ejected = false;
if let Some(i) = boat {
// AbstractBoat.tick on the server for a boat its rider drives:
// only the hurt animation and the damage wear off here.
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// `Entity.ejectPassengers`: every player in the boat must see
// the request; the common boat tick clears it once nobody rides.
ejected = b.eject_riders;
ejected = self.boats[i].eject_riders;
}
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
// is where it is now, so a move the server made (a piston, a push)
+6 -1
View File
@@ -572,11 +572,16 @@ impl Game<'_> {
moved = true;
}
}
if moved {
if moved && k + 1 < n {
self.enter_level(None, dim)?;
}
}
}
if let Some(&first) = order.first()
&& self.dim != first
{
let _ = self.enter_level(None, first);
}
self.level_pass = Pass::default();
if result.is_err() {
self.native_lifecycle.cancel_server_tick();
+1
View File
@@ -237,6 +237,7 @@ impl Game<'_> {
damage_type: Some(match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
horizon_client::mobs::DamageKind::Thorns => "minecraft:thorns",
_ => "minecraft:player_attack",
}),
items: Some(world_items!(self)),
+34 -4
View File
@@ -2347,6 +2347,30 @@ impl Game<'_> {
}
self.hurt_cart(i, damage, creative, false);
}
/// `Containers.dropContents(level, entity, container)` at a cart's
/// position: each slot, an empty one too, from a spot in the cart's block
/// in pieces of 10 to 30 with the level random's scatter, no pickup delay.
fn scatter_cart_contents(&mut self, at: [f64; 3], items: &[Option<ItemStack>]) {
let cell = horizon_core::position::BlockPos::new(
at[0].floor() as i32,
at[1].floor() as i32,
at[2].floor() as i32,
);
for stack in items {
let count = stack.as_ref().map_or(0, |s| s.count);
let pieces =
horizon_world::level::removal::drop_item_stack(&mut self.world.random, cell, count);
if let Some(stack) = stack {
for piece in pieces {
let piece_stack = ItemStack {
count: piece.count,
..stack.clone()
};
self.spawn_item(piece_stack, piece.pos, piece.motion, 0);
}
}
}
}
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
/// once for creative players); TNT minecarts prime instead when moving
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
@@ -2414,11 +2438,16 @@ impl Game<'_> {
self.entity_killed_event(at, false);
}
if self.ui.rules.bool("entity_drops") {
for s in c.items.iter().flatten() {
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
}
// AbstractMinecartContainer.remove: `Containers.dropContents`.
self.scatter_cart_contents(at, &c.items);
if !creative {
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, [0.0, 0.1, 0.0], 10);
// `spawnAtLocation`: an ItemEntity's own motion.
let motion = super::dispenser::new_item_motion();
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, motion, 10);
// `chestVehicleDestroyed` drops the emptied container
// again, which only draws each slot's spot.
let empty = vec![None; c.items.len()];
self.scatter_cart_contents(at, &empty);
}
}
}
@@ -2768,6 +2797,7 @@ impl Game<'_> {
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
font: Some(&self.gui.atlas.font),
dynamic_visible: None,
};
self.res
+11 -6
View File
@@ -307,13 +307,17 @@ fn build_lists_mods(built: &str) -> bool {
.unwrap_or(false)
}
/// Whether the game playing was built with native mods: the launcher writes their ids into
/// [`BUILD_INFO`] beside the executable, and a release or Nightly has no such file.
/// Whether the game playing was built with native mods: horizon-mixins applied bundles to its
/// source (`HORIZON_MIXIN_MODS`, set by the build script from the record it leaves, as with a mod
/// author's own build that has no [`BUILD_INFO`]), trait mods are installed, or the launcher wrote
/// their ids into [`BUILD_INFO`] beside the executable. A release or Nightly has none of them.
pub(super) fn built_with_native_mods() -> bool {
std::env::current_exe()
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.is_some_and(|built| build_lists_mods(&built))
option_env!("HORIZON_MIXIN_MODS").is_some_and(|mods| !mods.is_empty())
|| !crate::mod_api::mods().is_empty()
|| std::env::current_exe()
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.is_some_and(|built| build_lists_mods(&built))
}
/// The native mods the launcher built this game with, `id version, id version`, for the crash
@@ -457,6 +461,7 @@ impl Game<'_> {
}
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods
|| built_with_native_mods()
|| self
.settings
.mods
+10 -1
View File
@@ -575,7 +575,16 @@ impl Game<'_> {
};
let m = &self.mobs[i];
let mut at = Vec3::new(m.position.x, m.position.y + seat(m.species()), m.position.z);
if horizon_client::mobs::camel::is_camel(m.species()) {
if &*m.kind.id == "minecraft:player" || &*m.kind.id == "minecraft:mannequin" {
// Avatar attachments follow pose and minecraft:scale, just as for
// remote avatars riding other avatars. Do not guess a standing height.
at = horizon_client::mobs::riders::seat(
horizon_client::mobs::riders::passenger_attachment(m),
f64::from(0.6_f32),
m.position,
m.yaw,
);
} else if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.getPassengerAttachmentPoint: the driver half a block
// forward, at the body anchor (lower while sitting), turned with
// the camel.
@@ -600,6 +600,7 @@ impl Game<'_> {
x,
y: at[1],
text: shown,
style: Default::default(),
color: 0xFF00_0000 | (span.style.color.unwrap_or(color) & 0xFF_FFFF),
outline: if outline == 0 {
0
@@ -609,7 +610,12 @@ impl Game<'_> {
light,
tier: 0,
};
text_vertices(&glyph, &call, &mut draw.vertices);
text_vertices(
&glyph,
Some(self.gui.atlas.font.white()),
&call,
&mut draw.vertices,
);
x += width;
}
x - at[0]
@@ -1001,6 +1001,7 @@ use super::{
use crate::mod_api::NativeTextField;
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::EntityDataValue;
use horizon_client::mod_api::WorldHold;
use horizon_client::mod_api::{
self, BlockProperties, HudMode, NativeBossBar, NativeCamera, NativeCommand, NativeDispensed,
NativeFluidAction, NativeFluidDrain, NativeFluidStack, NativeFluidTankView,
@@ -3199,6 +3200,15 @@ impl Game<'_> {
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
mod_api::dispatch_shader_params(self, mod_api::mods(), partial, params);
}
/// Whether native mods hold the terrain's picture this frame: call `hold.hold()` to keep
/// every section drawn as it is while the world changes under it, or `hold.hold_within(x, z,
/// radius)` for only the sections inside circles. Not asking lets go, and every change
/// shows on that frame. Asked once a frame while a world is shown, before the frame's
/// section uploads, by each player's game (splitscreen: each its own). Client only.
/// API @API:world_hold@.
pub(super) fn native_world_hold(&self, hold: &mut WorldHold) {
mod_api::dispatch_world_hold(self, mod_api::mods(), hold);
}
/// The minimap a native mod shows: set `minimap` when it is none yet.
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
mod_api::dispatch_minimap(self, mod_api::mods(), minimap);
@@ -10778,6 +10788,30 @@ impl Game<'_> {
self.audio.play_after(sound, at, volume, pitch, delay);
true
}
/// Muffles the game's sounds for a while, as ears ringing after a close
/// blast hear them: `amount` (0 to 1) of every sound's volume is taken
/// away and `lowpass` (0 to 1) dulls it, from untouched to a 300 Hz
/// low-pass. Both start in full and fade back in a straight line over
/// `ticks` client ticks, held while the game is paused. The sound events in
/// `exempt` (`mymod:ear.ringing`) play clearly through it, those already
/// playing too, so the mod's own ringing stays sharp. Everything else is
/// muffled, music and the interface's clicks included. A new call replaces
/// the muffle in place; `ticks` 0 ends it at once. Nothing changes while no
/// mod calls it. False, with nothing changed, on a server, for an amount or
/// strength outside 0 to 1, or for an exempt event that isn't
/// `namespace:path` or more than 64 of them. API @API:muffle_sounds@.
pub(super) fn native_muffle_sounds(
&mut self,
amount: f32,
lowpass: f32,
ticks: u32,
exempt: &[&str],
) -> bool {
if !self.native_window() || exempt.len() > 64 || !exempt.iter().all(|e| sound_id(e)) {
return false;
}
self.audio.muffle(amount, lowpass, ticks, exempt)
}
/// Draws one vanilla HUD layer between the mods' hooks: `native_gui_layer_pre`
/// (which may hide it), the layer, then `native_gui_layer_post`. `None` when
/// a mod hid the layer.
@@ -12317,6 +12351,12 @@ impl Game<'_> {
self.native_shader_params(partial, &mut params);
finite_params(params)
}
/// What native mods ask of the terrain's picture this frame (`native_world_hold`).
pub(super) fn native_world_hold_now(&self) -> WorldHold {
let mut hold = WorldHold::default();
self.native_world_hold(&mut hold);
hold
}
/// The minimap a native mod shows.
pub(super) fn native_minimap_now(&self) -> Option<NativeMinimap> {
let mut minimap = None;
@@ -1078,15 +1078,7 @@ impl Game<'_> {
self.release_vehicle_container(id);
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
[b.yaw, b.pitch],
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
let mut tag = b.to_nbt(uuid, self.persist_raw(id));
// The mobs riding it cross with it and mount it again.
self.put_mob_passengers(&mut tag, id);
for rider in self.remove_mob_riders(id) {
+64 -4
View File
@@ -44,6 +44,16 @@ use winit::{
const STICK_STEP: f32 = 0.55;
/// Scrolls a step may try to reveal the next row of a list.
const SCROLL_TRIES: u8 = 4;
/// How far apart two rectangles may be and still be one widget found again (GUI pixels).
const SAME_RECT: f32 = 0.5;
/// Frames after a scroll that may still have been drawn before it: their rectangles are stale.
const STALE_FRAMES: u64 = 1;
/// Whether the frames drawn since `pad_reveal` scrolled a row, at frame `revealed`, may still
/// show the list where it was.
fn reveal_pending(revealed: Option<u64>, now: u64) -> bool {
revealed.is_some_and(|at| now <= at + STALE_FRAMES)
}
/// A focus step that ran off the visible part of a list: the list was
/// scrolled and the step runs again once a new frame is drawn. When the
@@ -131,6 +141,9 @@ pub(super) struct MenuCursor {
/// The focused button A went down on: letting go presses it, unless the
/// focus moved or the input was cancelled first.
pub(super) armed: Option<Rect>,
/// The frame count when `pad_reveal` scrolled the focused row into view: the frames drawn
/// before it still show the row where it was, so the focus isn't looked for in them.
pub(super) revealed: Option<u64>,
/// The OS pointer is hidden while the pad's cursor is drawn.
os_hidden: bool,
/// Polls since a menu opened (its widgets are drawn after one frame).
@@ -236,10 +249,12 @@ fn beside(rect: Rect, targets: &[Rect]) -> Option<usize> {
.map(|(i, _)| i)
}
/// Where letting go of A clicks: the armed button, if the focus is still on it.
/// Where letting go of A clicks: the armed button, if the focus is still on it. A row scrolled
/// into view under A (`pad_reveal`) is found again a fraction of a pixel off.
fn armed_click(armed: Option<Rect>, focus: Option<Target>) -> Option<(f32, f32)> {
let same = |a: Rect, b: Rect| a.iter().zip(b).all(|(a, b)| (a - b).abs() < SAME_RECT);
focus
.filter(|focus| armed == Some(focus.rect))
.filter(|focus| armed.is_some_and(|armed| same(armed, focus.rect)))
.map(|focus| focus.center())
}
@@ -848,7 +863,8 @@ impl Game<'_> {
Button::Select | Button::LeftStick if book_control => {}
Button::East | Button::Start => return self.pad_back(),
Button::South if self.pad_focusing() => {
let focus = self.pad.menu.focus.expect("focusing");
let focus = self.pad_reveal(self.pad.menu.focus.expect("focusing"));
self.pad.menu.focus = Some(focus);
match focus.kind {
// A click would jump the handle: A takes hold of it, or lets it go.
Kind::Slider { .. } => {
@@ -1092,8 +1108,22 @@ impl Game<'_> {
if self.pad.menu.pending.is_some() {
return;
}
// Nor while a row scrolled into view: the targets still show it where it was, and a step
// from them could land on its neighbour.
if reveal_pending(self.pad.menu.revealed, widgets::generation()) {
return;
}
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
let vertical = matches!(button, Button::DPadUp | Button::DPadDown);
// A step the screen declared comes first (`widgets::focus_link`).
let linked = vertical
.then(|| widgets::linked_step(focus.rect, button == Button::DPadDown))
.flatten()
.and_then(|to| gamepad::refind(to, &rects, 0.0));
if let Some(i) = linked {
self.pad_focus_on(event_loop, targets[i]);
return;
}
let areas = widgets::last_scroll_areas();
let step = if vertical {
gamepad::list_step(focus.rect, button, &rects, &areas)
@@ -1137,7 +1167,23 @@ impl Game<'_> {
}
}
/// `AbstractSelectionList.setSelected`: a settings row the focus lands on, or presses, while
/// its list hides part of it scrolls all into view first. Where it is now.
fn pad_reveal(&mut self, target: Target) -> Target {
if self.screen != Screen::Options {
return target;
}
let size = self.gui_size();
let mut moved = target;
if let Some(dy) = self.ui.options.reveal(&self.settings, size, target.rect) {
moved.rect[1] += dy;
self.pad.menu.revealed = Some(widgets::generation());
}
moved
}
fn pad_focus_on(&mut self, event_loop: &dyn EventLoopCtl, target: Target) {
let target = self.pad_reveal(target);
self.pad.menu.shortcut_focus = None;
let left = self.pad.menu.focus.replace(target);
if left.is_some_and(|l| l.rect != target.rect) {
@@ -1706,7 +1752,10 @@ impl Game<'_> {
}
None => {}
}
} else if ready.is_none() {
} else if ready.is_none()
// Two frames on, as after a step that scrolled: the first may predate the scroll.
&& !reveal_pending(self.pad.menu.revealed, widgets::generation())
{
if let Some(focus) = self.pad.menu.focus {
let kind = std::mem::discriminant(&focus.kind);
let named = self.pad_named_focus(focus, &rects);
@@ -1967,6 +2016,8 @@ impl Game<'_> {
self.pad_focus_move(event_loop, button, focus, &targets);
}
(Act::Press, Some(focus)) => {
let focus = self.pad_reveal(focus);
self.pad.menu.focus = Some(focus);
// AbstractButton.keyPressed: onPress, with its click sound.
self.pad_click_at(event_loop, focus.center(), false);
}
@@ -2553,6 +2604,15 @@ mod tests {
let kind = Kind::Button;
let focus = Some(Target { rect, kind });
assert_eq!(armed_click(Some(rect), focus), Some((60.0, 30.0)));
let refound = Some(Target {
rect: [10.0, 20.000_002, 100.0, 20.0],
kind,
});
assert!(armed_click(Some(rect), refound).is_some());
// A row scrolled into view at frame 10 is looked for again from frame 12.
assert!(!reveal_pending(None, 10));
assert!(reveal_pending(Some(10), 10) && reveal_pending(Some(10), 11));
assert!(!reveal_pending(Some(10), 12));
let moved = Some(Target {
rect: [10.0, 44.0, 100.0, 20.0],
kind,
+88 -19
View File
@@ -155,23 +155,42 @@ impl Game<'_> {
.map(|(i, m)| (m.id, i))
.collect();
// Riders of riders settle over a few passes.
for pass in 0..3 {
for _ in 0..3 {
for i in 0..self.mobs.len() {
let Some(v) = self.mobs[i].extra.mount else {
continue;
};
let Some(&j) = index.get(&v) else {
// The display doesn't move between passes.
if pass > 0 {
// Boats, minecarts, primed TNT and cushions seated their
// riders above, each in its own seat and from the
// vehicle's drawn position; re-seating them here from the
// shared seat 0 yanked boat riders between two places.
if self.seats_own_riders(v) {
continue;
}
// A mob on a text display: NPC plugins seat their sitting
// NPCs on one. The server's position for a passenger
// gives way to its seat, as `rideTick` places it.
let Some((at, before)) = self.text_displays.get(&(v as i32)).map(|d| d.seat())
else {
let me = self.remote.as_ref().map(|r| r.entity_id as u32);
let own_seat = self.player.as_ref().map(|p| {
p.position
.add_vector(Vec3::new(0.0, f64::from(p.dimensions.height), 0.0))
});
let Some(at) = self.passenger_riding_position(v, me, own_seat, &index) else {
continue;
};
let before = self
.text_displays
.get(&(v as i32))
.map(|d| d.seat().1)
.unwrap_or_else(|| {
let old = self.mobs[i].previous;
glam::DVec3::new(
old.x,
old.y + riders::vehicle_attachment(&self.mobs[i], 0.0),
old.z,
)
});
let m = &mut self.mobs[i];
// EntityType.TEXT_DISPLAY's box is zero wide.
let offset = riders::vehicle_attachment(m, 0.0);
@@ -297,13 +316,22 @@ impl Game<'_> {
let Some(vehicle) = self.armor_stands[i].vehicle else {
continue;
};
let Some(&j) = index.get(&vehicle).filter(|_| Some(vehicle) != me) else {
let Some(at) = self.passenger_riding_position(vehicle, me, own_seat, &index) else {
continue;
};
let vehicle = &self.mobs[j];
let attach = riders::passenger_attachment(vehicle);
let at = riders::seat(attach, 0.0, vehicle.position, vehicle.yaw);
let before = riders::seat(attach, 0.0, vehicle.previous, vehicle.previous_yaw);
let before = index
.get(&vehicle)
.map(|&j| {
let v = &self.mobs[j];
riders::seat(
riders::passenger_attachment(v),
0.0,
v.previous,
v.previous_yaw,
)
})
.unwrap_or(self.armor_stands[i].previous);
let at = Vec3::new(at.x, at.y, at.z);
let stand = &mut self.armor_stands[i];
stand.position = at;
stand.previous = before;
@@ -338,6 +366,14 @@ impl Game<'_> {
}
}
/// A vehicle whose `seat_*_mobs` pass places its mob riders itself.
fn seats_own_riders(&self, vehicle: u32) -> bool {
self.boats.iter().any(|b| b.id == vehicle)
|| self.carts.iter().any(|c| c.id == vehicle)
|| self.cushions.iter().any(|c| c.id == vehicle)
|| self.tnts.iter().any(|t| t.id == vehicle && t.id != 0)
}
/// `Entity.getPassengerRidingPosition` of a vehicle: where the feet of a
/// passenger whose own vehicle attachment is 0 land.
pub(super) fn passenger_riding_position(
@@ -347,19 +383,44 @@ impl Game<'_> {
own_seat: Option<Vec3>,
index: &HashMap<u32, usize>,
) -> Option<glam::DVec3> {
self.passenger_riding_position_inner(vehicle, me, own_seat, index, &mut Vec::new())
}
fn passenger_riding_position_inner(
&self,
vehicle: u32,
me: Option<u32>,
own_seat: Option<Vec3>,
index: &HashMap<u32, usize>,
path: &mut Vec<u32>,
) -> Option<glam::DVec3> {
if path.len() >= 64 || path.contains(&vehicle) {
return None;
}
path.push(vehicle);
if Some(vehicle) == me {
let at = own_seat?;
return Some(glam::DVec3::new(at.x, at.y, at.z));
}
if let Some(&j) = index.get(&vehicle) {
let v = &self.mobs[j];
if let Some(v) = index
.get(&vehicle)
.map(|&j| &self.mobs[j])
.or_else(|| self.mobs.iter().find(|m| m.id == vehicle))
{
let at = riders::seat(riders::passenger_attachment(v), 0.0, v.position, v.yaw);
return Some(glam::DVec3::new(at.x, at.y, at.z));
}
if let Some(d) = self.text_displays.get(&(vehicle as i32)) {
return Some(d.position());
return if let Some(parent) = d.vehicle {
self.passenger_riding_position_inner(parent, me, own_seat, index, path)
} else {
Some(d.position())
};
}
if let Some(i) = self.interactions.get(&(vehicle as i32)) {
if let Some(parent) = i.vehicle {
return self.passenger_riding_position_inner(parent, me, own_seat, index, path);
}
let at = i.position;
return Some(glam::DVec3::new(at.x, at.y, at.z));
}
@@ -383,16 +444,24 @@ impl Game<'_> {
} else {
1.975
};
return Some(glam::DVec3::new(
a.position.x,
a.position.y + height,
a.position.z,
));
let at = if let Some(parent) = a.vehicle {
self.passenger_riding_position_inner(parent, me, own_seat, index, path)?
} else {
glam::DVec3::new(a.position.x, a.position.y, a.position.z)
};
return Some(at + glam::DVec3::Y * height);
}
// Any other tracked entity sits on its feet; an area effect cloud is
// 0.5 tall, and ModelEngine parks a model's pivot half a block below
// its feet for exactly that.
let (kind, position) = self.remote.as_ref()?.tracked(vehicle as i32)?;
let remote = self.remote.as_ref()?;
let (kind, position) = remote.tracked(vehicle as i32)?;
let position = if let Some(&parent) = remote.passenger_vehicles.get(&vehicle) {
let at = self.passenger_riding_position_inner(parent, me, own_seat, index, path)?;
[at.x, at.y, at.z]
} else {
position
};
let lift = if kind == "minecraft:area_effect_cloud" {
0.5
} else {
+3 -18
View File
@@ -799,6 +799,7 @@ impl Game<'_> {
let (wood, chest) = super::boats::any_boat_wood(k).unwrap_or(("oak", false));
let mut b = super::boats::Boat::new(id, std::sync::Arc::from(wood), position, yaw);
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
b.fire_ticks = super::boats::saved_fire(&tag);
b.pitch = entity_nbt::rotation(&tag)[1];
if chest {
b = b.with_chest();
@@ -1165,15 +1166,7 @@ impl Game<'_> {
if self.boats.iter().any(|b| b.id == root) {
let uuid = self.uuid_for(root);
let b = self.boats.iter().find(|b| b.id == root)?;
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&root),
b.chest.then_some(&b.items[..]),
);
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&root));
self.put_mob_passengers(&mut tag, root);
return Some(tag);
}
@@ -2067,15 +2060,7 @@ impl Game<'_> {
return None;
}
let uuid = self.saved_uuid_or_random(id);
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
[b.yaw, b.pitch],
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
);
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the lead's holder.
self.write_boat_leash(&mut tag, i);
+329 -5
View File
@@ -140,6 +140,7 @@ impl Game<'_> {
) else {
return;
};
self.set_pushed_on_ground(target, movement, moved);
if moved.length_squared() > 0.0 {
self.apply_push(target, moved);
}
@@ -507,11 +508,31 @@ impl Game<'_> {
)
.unwrap_or(Vec3::ZERO)
});
// Entity.move updates an authoritative living entity's ground flag
// even for a horizontal piston push. Keeping the old support flag
// would run Frost Walker while the stand is moving over open water.
if let Target::Entity(EntityHandle::ArmorStand(i)) = target {
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
self.set_pushed_on_ground(target, limited, resolved);
// Entity.move updates ground before its tiny-collision return. This
// native slice covers only unoccupied server NoAI horizontal moves.
if let Target::Entity(EntityHandle::Mob(i)) = target {
let m = &self.mobs[i];
let eligible = !self.options.execution_role.is_client()
&& self.remote.is_none()
&& limited.y == 0.0
&& m.no_ai
&& !m.extra.passenger
&& m.extra.mount.is_none()
&& m.rider.is_none()
&& m.extra.rider.is_none()
&& self.riding != Some(m.id)
&& self.lan.as_ref().is_none_or(|host| {
!host
.hub
.guests
.iter()
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
});
if eligible {
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
}
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
@@ -521,6 +542,16 @@ impl Game<'_> {
self.apply_push(target, resolved);
}
/// The end of a block's `Entity.move` push: a server entity's ground flag
/// is set after every push, a sideways one and one the collision turns
/// away included, on ground only when the push's downward part was blocked.
fn set_pushed_on_ground(&mut self, target: Target, wanted: Vec3, moved: Vec3) {
if let Target::Entity(handle) = target {
let on_ground = wanted.y < 0.0 && wanted.y != moved.y;
with_entity_mut!(self, handle, entity => entity.set_piston_on_ground(on_ground));
}
}
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
/// per game tick, and a move touches only its first non-zero axis.
fn limit_piston(
@@ -805,3 +836,296 @@ mod order_tests {
assert!(moved[1] < 0.02, "far stone still in the way: {}", moved[1]);
}
}
#[cfg(test)]
mod h255_mob_piston_ground_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, random::LegacyRandom};
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
with_role_game(
crate::game::execution_role::ExecutionRole::IntegratedServer,
test,
);
}
fn with_role_game(
role: crate::game::execution_role::ExecutionRole,
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!("h255-{:?}", std::thread::current().id())),
);
options.execution_role = role;
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);
let mut center = Chunk::empty(0, 0);
center.lit = true;
game.world.chunks.insert((0, 0), Arc::new(center));
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 in -1..=1 {
for z in -1..=1 {
if x == 0 && z == 0 {
continue;
}
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
game.world.chunks.insert((x, z), Arc::new(chunk));
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"));
game.world.random = LegacyRandom::new(255);
game.update_chunk_tickets();
assert!(crate::game::ticking_chunk(&game.world, 0, 0));
assert!(game.tickets.levels().block_ticking(0, 0));
test(&mut game);
}
fn mob_index(game: &Game<'_>, n: i32) -> usize {
let id = game
.persist
.uuids
.iter()
.find_map(|(id, u)| (*u == [255, 1, 0, n]).then_some(*id))
.unwrap();
game.mobs.iter().position(|m| m.id == id).unwrap()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_real_honey_piston_lifecycle_clears_mob_ground_before_second_drag() {
with_game(|game| {
game.world.game_time = 0;
for cmd in [
"gamerule spawn_mobs false",
"gamerule random_tick_speed 0",
"gamerule advance_time false",
"setblock 3 201 4 piston[facing=east]",
"setblock 4 201 4 honey_block",
"setblock 4 201 12 honey_block",
"setblock 12 200 12 piston[facing=up]",
"setblock 12 201 12 stone",
"summon husk 4.5 202 4.5 {UUID:[I;255,1,0,1],Health:10f,NoAI:1b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]}",
"summon husk 4.5 202 12.5 {UUID:[I;255,1,0,2],Health:10f,NoAI:1b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]}",
"summon husk 12.5 202 12.5 {UUID:[I;255,1,0,3],Health:10f,NoAI:1b,NoGravity:1b,OnGround:0b,Motion:[0d,0d,0d]}",
] {
assert!(game.run_command(cmd), "{cmd}");
}
let main = mob_index(game, 1);
let idle = mob_index(game, 2);
let vertical = mob_index(game, 3);
let id = game.mobs[main].id;
let age = game.mobs[main].age;
assert!(game.mobs[main].on_ground);
assert!(game.mobs[idle].on_ground);
assert!(!game.mobs[vertical].on_ground);
assert_eq!(game.mobs[main].position, Vec3::new(4.5, 202.0, 4.5));
assert_eq!(
game.res
.facts()
.get(game.world.block(4, 201, 4))
.name
.as_ref(),
"minecraft:honey_block"
);
assert!(game.run_command("setblock 3 201 5 redstone_block"));
assert!(game.run_command("setblock 13 200 12 redstone_block"));
let mut failures = Vec::new();
for tick in 1..=2 {
game.world.game_time += 1;
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(4, 4));
game.tick_blocks().unwrap();
game.tick_creature_entities().unwrap();
// Real block-entity ticker generates and drains the piston pushes.
game.tick_moving_pistons().unwrap();
let p = game
.block_ticks
.moving_pistons
.iter()
.find(|(p, _)| *p == BlockPos::new(5, 201, 4))
.expect("real moving honey block");
assert_eq!(p.1.progress, 0.5 * tick as f32);
assert_eq!(
p.1.moved_state,
game.pack
.assets
.registry
.default_state("minecraft:honey_block")
.unwrap()
);
assert_eq!(game.mobs[main].id, id);
assert_eq!(game.uuid_for(id), [255, 1, 0, 1]);
assert_eq!(game.mobs[main].age, age + tick);
assert!(game.mobs[main].no_ai);
assert_eq!(game.mobs[main].health, 10.0);
assert_eq!(game.mobs[main].velocity, Vec3::ZERO);
if game.mobs[main].on_ground {
failures.push(format!("tick{tick}: actual Mob still grounded"));
}
if game.mobs[main].position != Vec3::new(5.0, 202.0, 4.5) {
failures.push(format!(
"tick{tick}: position {:?}",
game.mobs[main].position
));
}
if game.mob_tree_nbt(main).get("OnGround")
!= Some(&horizon_world::nbt::Tag::Byte(0))
{
failures.push(format!("tick{tick}: saved ground is stale"));
}
assert_eq!(game.mobs[idle].position, Vec3::new(4.5, 202.0, 12.5));
assert!(game.mobs[idle].on_ground);
assert_eq!(
game.mobs[vertical].position,
Vec3::new(12.5, if tick == 1 { 202.51 } else { 203.01 }, 12.5)
);
assert!(!game.mobs[vertical].on_ground);
}
assert!(
failures.is_empty(),
"actual lifecycle failures: {failures:#?}"
);
});
}
// Added source/unit controls; these do not extend the Java live contract.
fn added_mob(game: &mut Game<'_>, n: i32, kind: &str, no_ai: bool, x: f64) -> usize {
let tame = if kind == "horse" { ",Tame:1b" } else { "" };
assert!(game.run_command(&format!("summon {kind} {x} 202 4.5 {{UUID:[I;255,1,0,{n}],Health:10f,NoAI:{}b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]{tame}}}",i32::from(no_ai))));
let i = mob_index(game, n);
assert_eq!(game.mobs[i].no_ai, no_ai);
assert!(game.mobs[i].on_ground);
assert_eq!(game.uuid_for(game.mobs[i].id), [255, 1, 0, n]);
i
}
fn added_push(game: &mut Game<'_>, i: usize, movement: Vec3) {
let m = &game.mobs[i];
let (bounds, at, step, ground) = (
m.bounds(),
[m.position.x, m.position.z],
m.kind.step_height as f32,
m.on_ground,
);
// Mob owns its actual piston budget; the map is for other entity kinds.
game.move_pushed(
Target::Entity(EntityHandle::Mob(i)),
bounds,
at,
step,
ground,
movement,
Direction::East,
&mut std::collections::HashMap::new(),
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
with_game(|game| {
let i = added_mob(game, 20, "horse", true, 8.5);
added_push(game, i, Vec3::ZERO);
assert!(game.mobs[i].on_ground);
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
assert!(game.run_command("ride @s mount 000000ff-0000-0001-0000-000000000014"));
assert_eq!(game.riding, Some(game.mobs[i].id));
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
assert!(game.run_command("ride @s dismount"));
assert!(game.riding.is_none());
assert!(game.mobs[i].rider.is_none());
assert!(game.mobs[i].extra.rider.is_none());
assert!(game.mobs[i].extra.mount.is_none());
assert!(!game.mobs[i].extra.passenger);
let before = game.mobs[i].position;
added_push(game, i, Vec3::new(0.25, 0.0, 0.0));
assert_eq!(
game.mobs[i].position, before,
"real same-tick piston budget is exhausted"
);
assert!(
game.mobs[i].on_ground,
"limitedZERO precedes the ground update"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_blocked_request_updates_ground_before_tiny_return() {
with_game(|game| {
assert!(game.run_command("fill 9 202 4 9 204 4 stone"));
// Match the existing Mob::bounds_at f32 width boundary exactly.
let width = game.res.mob_types().get("minecraft:husk").unwrap().width as f32;
let x = 9.0 - f64::from(width) * 0.5;
let i = added_mob(game, 21, "husk", true, x);
assert_eq!(game.mobs[i].bounds().max_position().x, 9.0);
let before = game.mobs[i].position;
added_push(game, i, Vec3::new(0.25, 0.0, 0.0));
assert_eq!(
game.mobs[i].position, before,
"actual loaded wall blocks the nonzero request"
);
assert!(
!game.mobs[i].on_ground,
"Entity.move ground ordering, source/unit-only coverage"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
use crate::game::execution_role::ExecutionRole as Role;
for (role, no_ai, movement) in [
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
] {
with_role_game(role, |game| {
let i = added_mob(game, 22, "husk", no_ai, 8.5);
let before = game.mobs[i].position;
added_push(game, i, movement);
assert_eq!(game.mobs[i].position, before.add_vector(movement));
assert!(
game.mobs[i].on_ground,
"excluded native path remains unchanged; not Java parity"
);
});
}
}
}
+157
View File
@@ -614,6 +614,8 @@ pub(super) struct Remote {
/// follows (`horizon:menu_extra`).
menu_extra: Option<horizon_protocol::menu_extra::Message>,
entities: HashMap<i32, Tracked>,
/// Complete network riding graph, including invisible plugin seat entities.
pub(super) passenger_vehicles: HashMap<u32, u32>,
lerps: HashMap<i32, Lerp>,
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
/// (`NewMinecartBehavior`), which have no interpolation handler.
@@ -1165,6 +1167,7 @@ impl Game<'_> {
use_target: None,
menu_extra: None,
entities: HashMap::new(),
passenger_vehicles: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
stepped: HashMap::new(),
@@ -1388,6 +1391,7 @@ impl Game<'_> {
use_target: None,
menu_extra: None,
entities: HashMap::new(),
passenger_vehicles: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
stepped: HashMap::new(),
@@ -2602,6 +2606,7 @@ impl Game<'_> {
self.pickups.clear();
if let Some(r) = self.remote.as_mut() {
r.entities.clear();
r.passenger_vehicles.clear();
r.lerps.clear();
r.cart_lerps.clear();
r.stepped.clear();
@@ -3360,6 +3365,8 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.passenger_vehicles.remove(&(id as u32));
r.passenger_vehicles.retain(|_, v| *v != id as u32);
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
@@ -6366,6 +6373,25 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.last_teleport_sequence = r.sequence;
}
// handleMovePlayer: a passenger keeps its own position and look (its vehicle
// carries it), so a vehicle a datapack teleports every tick doesn't snap the
// rider's view; the teleport is still accepted, with where the player is.
if self.riding.is_some()
&& let Some(player) = self.player.as_ref()
{
let at = player.position;
self.send(S::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: at.x,
y: at.y,
z: at.z,
yaw: self.yaw,
pitch: self.pitch,
}));
self.stop_remote_mining();
self.mining = None;
return Ok(());
}
match self.player.as_mut() {
Some(player) => {
player.position = position;
@@ -6905,6 +6931,14 @@ impl Game<'_> {
}
// A stand riding the player follows this move (tickPassenger).
self.seat_own_stands();
// Other players on this player (possibly through a plugin seat) follow this tick too;
// the full pass only when something rides this player.
if self.remote.as_ref().is_some_and(|r| {
let me = r.entity_id as u32;
r.passenger_vehicles.values().any(|&v| v == me)
}) {
self.position_riders();
}
// The server owns health and hunger.
self.apply_remote_health();
self.tick_avatar(before, false);
@@ -12074,6 +12108,129 @@ mod tests {
assert_eq!(game.ui.chat_screen.as_ref().unwrap().input.value, "/tp ");
}
/// The packet riding graph must retain invisible intermediary entities. A custom
/// sit plugin can use an area-effect cloud, whose server position stops updating
/// while it rides a player, with a player or marker stand as its own passenger.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn plugin_player_stack_follows_network_moves_and_local_carriers() {
use super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_client::{motion::PlayerMotion, sprint::Keys};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{self, Clientbound as C};
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.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(client, "SeatTest").unwrap();
game.remote.as_mut().unwrap().entity_id = 7;
game.player = Some(PlayerMotion::new(game.motion, Vec3::ZERO).unwrap());
game.spawn_entity(40, "minecraft:player", [2.0, 64.0, 3.0], [0.0; 3]);
game.spawn_entity(41, "minecraft:player", [0.0, 64.0, 0.0], [0.0; 3]);
game.spawn_entity(42, "minecraft:area_effect_cloud", [0.0; 3], [0.0; 3]);
let set = |g: &mut Game<'_>, vehicle, passengers: Vec<i32>| {
g.handle_packet(C::SetPassengers(play::SetPassengers {
vehicle,
passengers,
}))
.unwrap();
};
set(&mut game, 40, vec![42]);
set(&mut game, 42, vec![7, 41]);
let close = |at: Vec3, x: f64, y: f64, z: f64| {
assert!(
(at.x - x).abs() < 1e-5 && (at.y - y).abs() < 1e-5 && (at.z - z).abs() < 1e-5,
"position {at:?}, expected [{x}, {y}, {z}]"
);
};
assert!(game.remote_ride(Keys::default(), 0).unwrap());
close(game.player.as_ref().unwrap().position, 2.0, 65.7, 3.0);
close(
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
2.0,
65.7,
3.0,
);
game.handle_packet(C::EntityPositionSync(play::EntityPositionSync {
id: 40,
path: play::PositionPath::Linear([12.0, 67.0, -4.0]),
yaw: 45.0,
pitch: 0.0,
on_ground: true,
}))
.unwrap();
for _ in 0..4 {
game.tick_remote_entities(true);
game.remote_ride(Keys::default(), 0).unwrap();
}
close(game.player.as_ref().unwrap().position, 12.0, 68.7, -4.0);
close(
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
12.0,
68.7,
-4.0,
);
// The intermediary's packet position must remain unchanged for relative decoding.
assert_eq!(
game.remote.as_ref().unwrap().tracked(42).unwrap().1,
[0.0; 3]
);
// A remote player riding the local player must follow our own movement too.
set(&mut game, 42, vec![41]);
set(&mut game, 7, vec![42]);
game.player.as_mut().unwrap().position = Vec3::new(20.0, 70.0, 25.0);
game.position_riders();
close(
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
20.0,
71.7,
25.0,
);
// A direct stack has no intermediary's 0.5-height offset.
set(&mut game, 40, vec![7, 41]);
game.remote_ride(Keys::default(), 0).unwrap();
close(game.player.as_ref().unwrap().position, 12.0, 68.2, -4.0);
close(
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
12.0,
68.2,
-4.0,
);
// Removing the carrier detaches passengers even without a final empty packet.
game.remove_entity(40);
assert_eq!(game.riding, None);
assert_eq!(
game.mobs.iter().find(|m| m.id == 41).unwrap().extra.mount,
None
);
}
/// A mod's chat goes out only from a joined player, and only what the
/// Chat option allows.
#[test]
+105 -10
View File
@@ -157,6 +157,13 @@ impl Game<'_> {
return;
};
let vehicle_id = vehicle as u32;
if let Some(r) = self.remote.as_mut() {
r.passenger_vehicles
.retain(|_, parent| *parent != vehicle_id);
for &passenger in passengers {
r.passenger_vehicles.insert(passenger as u32, vehicle_id);
}
}
self.remote_seat_passengers(vehicle_id, passengers);
let player_controls = passengers.first().is_some_and(|&id| {
self.remote
@@ -288,6 +295,9 @@ impl Game<'_> {
if let Some(p) = self.player.as_mut() {
p.sprint_input_tick_riding(keys, sprint_window, can_sprint, local)?;
}
// Settle an invisible seat riding a moving avatar before reading it.
// The normal end-of-tick pass alone leaves the local rider a tick behind.
self.position_riders();
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
// LocalPlayer.aiStep: the jump bar charges while jump is held.
let jump = self.charge_mount_jump(i, keys);
@@ -362,7 +372,7 @@ impl Game<'_> {
self.boats[i] = b;
// In front of a mob that shares the boat.
Some(self.player_boat_seat(i))
} else {
} else if self.carts.iter().any(|c| c.id == id) {
// Minecart.positionRider runs on the client after the cart tick.
// The offset belongs to the cart, so switching vehicles cannot
// transfer the previous vehicle's pending turn.
@@ -375,18 +385,23 @@ impl Game<'_> {
}
}
// A minecart: the server moves it, the rider sits in it.
let cart = self.carts.iter().find(|c| c.id == id).map(|c| {
self.carts.iter().find(|c| c.id == id).map(|c| {
c.position
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
})
} else if let Some(at) =
self.passenger_riding_position(id, None, None, &std::collections::HashMap::new())
{
let scale = self.player.as_ref().map_or(1.0, |p| {
p.dimensions.width / horizon_client::pose::Pose::Standing.dimensions(1.0).width
});
if cart.is_none() {
// Entity.baseTick: a passenger whose vehicle is gone stops
// riding it. Nothing with the id is in the level any more (it
// died, or the world it rode in was left), so the seat goes.
self.riding = None;
return Ok(false);
}
cart
Some(Vec3::new(at.x, at.y - f64::from(0.6_f32 * scale), at.z))
} else {
// Entity.baseTick: a passenger whose vehicle is gone stops
// riding it. Nothing with the id is in the level any more (it
// died, or the world it rode in was left), so the seat goes.
self.riding = None;
return Ok(false);
};
if let Some(p) = self.player.as_mut() {
// The tick a rider takes its seat is a teleport (`Entity.moveTo`,
@@ -862,3 +877,83 @@ mod tests {
}
}
}
#[cfg(test)]
mod plugin_seat_tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn local_rider_uses_attachments_and_follows_a_moving_seat_chain() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{mobs::Mob, motion::PlayerMotion, sprint::Keys};
use horizon_core::geometry::Vec3;
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.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.player = Some(PlayerMotion::new(game.motion, Vec3::ZERO).unwrap());
let mut stand =
horizon_client::entities::ArmorStand::new(42, Vec3::new(2.0, 64.0, 3.0), 0.0);
stand.marker = true;
game.armor_stands.push(stand);
game.riding = Some(42);
assert!(game.remote_ride(Keys::default(), 0).unwrap());
let close = |actual: f64, expected: f64| {
assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}")
};
// EntityAttachments: zero-height marker minus Avatar's 0.6 attachment.
close(game.player.as_ref().unwrap().position.y, 63.4);
game.armor_stands[0].marker = false;
game.armor_stands[0].small = true;
game.remote_ride(Keys::default(), 0).unwrap();
close(game.player.as_ref().unwrap().position.y, 64.3875);
game.armor_stands[0].small = false;
game.remote_ride(Keys::default(), 0).unwrap();
close(game.player.as_ref().unwrap().position.y, 65.375);
let kind = game.res.player_type().clone().unwrap();
game.mobs
.push(Mob::new(43, kind, Vec3::new(5.0, 70.0, 6.0), 0.0));
game.armor_stands[0].marker = true;
game.armor_stands[0].vehicle = Some(43);
for at in [Vec3::new(5.0, 70.0, 6.0), Vec3::new(12.0, 72.0, -4.0)] {
game.mobs[0].position = at;
// No manual position_riders call: local ride must resolve the chain itself.
game.remote_ride(Keys::default(), 0).unwrap();
let p = game.player.as_ref().unwrap().position;
close(p.x, at.x);
close(p.z, at.z);
close(p.y, at.y + 1.2);
}
// Direct riding and a scaled avatar share the same attachment calculation.
game.riding = Some(43);
game.mobs[0].extra.model_scale = 1.5;
game.remote_ride(Keys::default(), 0).unwrap();
close(game.player.as_ref().unwrap().position.y, 74.1);
game.mobs.clear();
game.armor_stands.clear();
assert!(!game.remote_ride(Keys::default(), 0).unwrap());
assert_eq!(game.riding, None);
}
}
+14 -2
View File
@@ -265,10 +265,11 @@ impl Game<'_> {
{
self.native_player_shield_disable(player, &name, ticks);
}
let item = self
let used = self
.using
.and_then(|(slot, _)| self.stack_in(slot))
.map(|stack| stack.item.clone());
.cloned();
let item = used.as_ref().map(|stack| stack.item.clone());
let mut cancel = false;
if let (Some(player), Some(name), Some(item)) =
(self.lan_acting_uuid, self.lan_acting_name.clone(), item)
@@ -277,6 +278,17 @@ impl Game<'_> {
}
if !cancel {
self.shield_cooldown = ticks;
// ItemCooldowns.addCooldown(stack, ticks) on the shield's group: a guest is
// sent it (ServerItemCooldowns), which keeps the shield down on their screen,
// and the 0 that ends it.
if let Some(stack) = &used {
let default = self
.items()
.get(&stack.item)
.and_then(|i| i.use_cooldown.clone());
let group = horizon_client::cooldowns::group(stack, default.as_ref());
self.add_cooldown(group, ticks as i32);
}
}
self.using = None;
if let Some(sound) = &component.disabled_sound {
+84 -29
View File
@@ -25,6 +25,14 @@ use horizon_core::{
/// The key of this window's own player in `Game::kinetic_feedback`.
pub(super) const OWN: i32 = i32::MIN;
/// What a stab reaches along the view: a mob (its index in `mobs`) or
/// another player (its index in `stab_players`).
#[derive(Clone, Copy)]
enum Target {
Mob(usize),
Player(usize),
}
impl Game<'_> {
/// `Item.use` of a kinetic weapon: `startUsingItem` with a new
/// `recentKineticEnemies`, and `KineticWeapon.makeSound` at the user.
@@ -108,8 +116,9 @@ impl Game<'_> {
.collect()
}
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`.
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`:
/// the mobs and the other players along the view, nearest first.
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<Target> {
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
let (min, max) = if self.creative {
(REACH.min_creative, REACH.max_creative)
@@ -118,10 +127,18 @@ impl Game<'_> {
};
let reach = (min, max, REACH.hitbox_margin);
let clip = |from, to| self.clip_collider(from, to);
let boxes = self.hittable_mob_boxes();
let mobs = self.mobs.len();
let mut boxes = self.hittable_mob_boxes();
boxes.extend((self.stab_players.iter().enumerate()).map(|(k, p)| (mobs + k, p.bounds)));
hits_in_reach(&boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(i, _)| i)
.map(|(i, _)| {
if i < mobs {
Target::Mob(i)
} else {
Target::Player(i - mobs)
}
})
.collect()
}
@@ -132,7 +149,7 @@ impl Game<'_> {
return;
};
let used = ticks as i32 - kinetic.delay_ticks;
let (Some(eye), Some(feet)) = (
let (Some(eye), Some(_)) = (
self.eye_position(),
self.player.as_ref().map(|p| p.position),
) else {
@@ -149,8 +166,16 @@ impl Game<'_> {
let now = self.world.game_time;
let actor = self.lan_acting;
let mut affected = false;
for i in self.stab_targets(eye, look, movement) {
let key = (actor, self.mobs[i].id);
let mut on_players = Vec::new();
for target in self.stab_targets(eye, look, movement) {
let (entity, target_motion) = match target {
Target::Mob(i) => (self.mobs[i].id, self.mob_speed(i)),
Target::Player(k) => {
let p = &self.stab_players[k];
(p.entity_id as u32, p.movement.scale(20.0))
}
};
let key = (actor, entity);
let recent = self
.stabbed
.get(&key)
@@ -159,7 +184,7 @@ impl Game<'_> {
continue;
}
self.stabbed.insert(key, now);
let target_speed = look.dot(self.mob_speed(i));
let target_speed = look.dot(target_motion);
let relative = (attacker_speed - target_speed).max(0.0);
// An entity factor of 1 for a player.
let dismounts = kinetic.dismount.test(used, attacker_speed, relative, 1.0);
@@ -168,20 +193,36 @@ impl Game<'_> {
if dismounts || pushes || damages {
// The attack damage attribute's base value (1) and the speed.
let amount = 1.0 + (relative * f64::from(kinetic.damage_multiplier)).floor() as f32;
affected |= self.stab_mob(i, amount, damages, pushes, dismounts);
match target {
Target::Mob(i) => {
affected |= self.stab_mob(i, amount, damages, pushes, dismounts)
}
Target::Player(k) => on_players
.extend(self.queue_player_stab(k, amount, damages, pushes, dismounts)),
}
}
}
if affected {
// Level.broadcastEntityEvent(player, 2), felt by the player too.
if self.audio.capture.is_some() {
self.lan_kinetic_hits.push(actor);
}
let who = actor.map_or(OWN, |conn| conn as i32);
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
self.trigger_spear_mobs(stabbed);
self.spear_hit_feedback(&kinetic);
}
self.keep_player_stabs(kinetic, true, affected, on_players);
}
/// What follows a charge tick that affected an entity:
/// `Level.broadcastEntityEvent(player, 2)`, felt by the player too, and
/// SPEAR_MOBS_TRIGGER with the living entities stabbed in this use.
pub(super) fn spear_hit_feedback(&mut self, kinetic: &Kinetic) {
let Some(feet) = self.player.as_ref().map(|p| p.position) else {
return;
};
let actor = self.lan_acting;
if self.audio.capture.is_some() {
self.lan_kinetic_hits.push(actor);
}
let who = actor.map_or(OWN, |conn| conn as i32);
self.kinetic_hit_feedback(who, kinetic, [feet.x, feet.y, feet.z]);
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
self.trigger_spear_mobs(stabbed);
}
/// `Player.stabAttack` on the mob at `index`: whether it was affected.
@@ -264,6 +305,22 @@ impl Game<'_> {
}
// damageStatsAndHearts.
let actual = old_health - self.mobs[index].health;
self.damage_stats_and_hearts(actual, feet, height);
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
true
}
/// `Player.damageStatsAndHearts`: the damage dealt statistic, and the
/// damage indicator particles over a target that lost more than 2 health
/// (`feet` and `height`: the target's).
pub(super) fn damage_stats_and_hearts(&mut self, actual: f32, feet: [f64; 3], height: f64) {
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
if actual > 2.0 {
let indicator = horizon_client::particles::ParticleOptions::simple(
@@ -288,15 +345,6 @@ impl Game<'_> {
randomization: 0,
});
}
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
true
}
/// `Player.cannotAttackWithItem` for the held item: a spear needs its
@@ -321,8 +369,14 @@ impl Game<'_> {
let damage = self.attack_damage();
let movement = self.known_player_movement();
let mut hit = false;
for i in self.stab_targets(eye, look, movement) {
hit |= self.stab_mob(i, damage, true, true, false);
let mut on_players = Vec::new();
for target in self.stab_targets(eye, look, movement) {
match target {
Target::Mob(i) => hit |= self.stab_mob(i, damage, true, true, false),
Target::Player(k) => {
on_players.extend(self.queue_player_stab(k, damage, true, true, false))
}
}
}
// Player.onAttack, then postPiercingAttack.
self.attack_ticks = 0;
@@ -330,6 +384,7 @@ impl Game<'_> {
if hit {
self.play_at_feet(kinetic.hit_sound);
}
self.keep_player_stabs(kinetic, false, hit, on_players);
// swingAndResetAttackStrength (ServerPlayer): a swing that starts
// resets the attack strength.
let swings = self.swings_started;
@@ -0,0 +1,329 @@
//! A player's spear on the other players of a server: the charge
//! (`KineticWeapon.damageEntities`) and the jab (`PiercingWeapon.attack`)
//! stab every living entity along the view, players too. The stab is worked
//! out as the attacker in their turn (`Player.stabAttack`'s damage, magic
//! boost and pushes), lands on the victim like a player's melee blow once the
//! hub is back (`lan_pvp`), and the attacker's side of a landed stab
//! (`attackVisualEffects`, `itemAttackInteraction`, `damageStatsAndHearts`,
//! the exhaustion, and the hit's feedback) follows as the attacker again.
use super::{
Game,
lan::LanHost,
lan_pvp::{Victim, pvp_scope},
};
use horizon_client::{health::HurtSource, spear::Kinetic, text::Text};
use horizon_core::geometry::{Aabb, Vec3};
/// Another player a spear can reach in the acting player's turn, taken from
/// the hub before it: `PiercingWeapon.canHitEntity` holds for them.
#[derive(Clone, Copy)]
pub(super) struct StabPlayer {
pub(super) conn: u32,
pub(super) entity_id: i32,
pub(super) bounds: Aabb,
/// `getKnownSpeed`: the movement their client reported, per tick.
pub(super) movement: Vec3,
}
/// One stab on a player, as the attacker worked it out.
pub(super) struct PlayerStab {
victim: u32,
/// The damage after the enchantments (and the attack strength when the
/// spear isn't in use); 0 when the stab deals none.
amount: f32,
deals_damage: bool,
deals_knockback: bool,
dismounts: bool,
/// `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
/// `getKnockback`'s enchanted strength, and the attacker's yaw as a direction.
knockback: (f32, f64, f64),
disable_blocking: f32,
item: Option<Text>,
ignite: f32,
}
/// The stabs of one charge tick or one jab on players.
pub(super) struct StabBatch {
/// The stabbing guest.
attacker: u32,
kinetic: Kinetic,
/// A charge's tick (its hit feedback), else a jab (its hit sound).
charge: bool,
/// Whether a mob stabbed in the same tick gave the feedback already.
felt: bool,
stabs: Vec<PlayerStab>,
}
/// What a landed stab did to the victim.
struct Landed {
hurt: bool,
/// The stab's enchantments added damage (the magic crit).
magic: bool,
/// `stabAttack`'s result: hurt, pushed or dismounted.
affected: bool,
/// Health lost, for `damageStatsAndHearts`.
actual: f32,
id: i32,
feet: [f64; 3],
width: f64,
height: f64,
}
/// A spear's push on a player as the native hooks name it.
const SPEAR_SOURCE: HurtSource<'static> = HurtSource {
damage_type: "minecraft:spear",
causing: 0,
direct: 0,
};
impl Game<'_> {
/// The players the guest `actor` could stab now: in their level, placed,
/// alive and no spectators (`canBeHitByProjectile`), allowed by
/// `canHarmPlayer` (the `pvp` game rule and the victim's team), and not
/// on the attacker's vehicle.
pub(super) fn stab_players_for(&self, host: &LanHost, actor: u32) -> Vec<StabPlayer> {
let board = &self.score.board;
let Some(attacker) = host.hub.guest(actor).filter(|_| self.ui.rules.bool("pvp")) else {
return Vec::new();
};
let own_team = board.players_team(&attacker.name);
let own_vehicle = attacker.vehicle;
host.hub
.guests
.iter()
.filter(|g| {
g.conn != actor
&& host.hub.here(g)
&& g.placed
&& g.loaded
&& !g.dead
&& !g.spectator()
&& (own_vehicle.is_none() || g.vehicle != own_vehicle)
&& horizon_client::scoreboard::teams_allow_damage(
board.players_team(&g.name),
own_team,
)
})
.map(|g| {
let [x, y, z] = g.known_movement;
StabPlayer {
conn: g.conn,
entity_id: g.entity_id,
bounds: g.bounds(),
movement: Vec3::new(x, y, z),
}
})
.collect()
}
/// The attacker's side of `Player.stabAttack` on the player `target`
/// (an index in `stab_players`), queued to land once the hub is back.
pub(super) fn queue_player_stab(
&mut self,
target: usize,
base_damage: f32,
deals_damage: bool,
deals_knockback: bool,
dismounts: bool,
) -> Option<PlayerStab> {
let victim = self.stab_players.get(target)?.conn;
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z])?;
let magic_boost = self.enchant_damage_bonus("minecraft:player");
let (base_damage, magic_boost) = if self.using.is_none() {
let scale = self.attack_strength_scale();
(
base_damage * (0.2 + scale * scale * 0.8),
magic_boost * scale,
)
} else {
(base_damage, magic_boost)
};
let amount = if deals_damage {
base_damage + magic_boost
} else {
0.0
};
let (xd, zd) = self.extra_knockback_direction();
let strength = if deals_knockback {
// causeExtraKnockback: the attacker stops sprinting.
let (strength, _) = self.extra_knockback("minecraft:player", 0.0);
self.stop_sprint_after_extra_knockback();
strength
} else {
0.0
};
// LivingEntity.getSecondsToDisableBlocking: none while an item is in use.
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(PlayerStab {
victim,
amount,
deals_damage,
deals_knockback,
dismounts,
magic: magic_boost > 0.0,
from,
knockback: (strength, xd, zd),
disable_blocking,
item: self.main_hand_custom_name(),
ignite: self.weapon_ignite_seconds(),
})
}
/// Keeps a guest's charge tick's or jab's stabs on players: they land
/// once the guest's turn ends and the hub is back (`land_player_stabs`).
pub(super) fn keep_player_stabs(
&mut self,
kinetic: Kinetic,
charge: bool,
felt: bool,
stabs: Vec<PlayerStab>,
) {
let Some(attacker) = self.lan_acting.filter(|_| !stabs.is_empty()) else {
return;
};
self.player_stabs.push(StabBatch {
attacker,
kinetic,
charge,
felt,
stabs,
});
}
/// The queued stabs on players landing, each then followed by the
/// attacker's side of a stab that affected its victim.
pub(super) fn land_player_stabs(&mut self, host: &mut LanHost) {
for batch in std::mem::take(&mut self.player_stabs) {
let landed: Vec<Landed> = (batch.stabs.iter())
.filter_map(|s| self.land_player_stab(host, batch.attacker, s))
.collect();
self.as_guest(host, batch.attacker, |g| {
g.after_player_stabs(&batch, &landed)
});
}
}
/// `stabAttack`'s hurt, pushes and dismount on the victim, then
/// `awardKillScore` and the victim's thorns. None when the victim has gone.
fn land_player_stab(
&mut self,
host: &mut LanHost,
attacker: u32,
s: &PlayerStab,
) -> Option<Landed> {
let victim = Victim::Guest(s.victim);
let g = host.hub.guest(s.victim).filter(|g| !g.dead)?;
let id = g.entity_id;
let (attacker_id, attacker_name) = host
.hub
.guest(attacker)
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))?;
let hit = self.pvp_hit_of(host, Some(attacker), victim, "melee");
let land = |g: &mut Self| {
let _attacker = g.knockback_scope(attacker_id);
g.combat.player_attacker = Some((s.from, s.disable_blocking));
g.note_player_attacker(
"minecraft:spear",
attacker_id as u32,
attacker_name.clone(),
s.item.clone(),
);
let health = |g: &Self| g.player.as_ref().map_or(0.0, |p| p.health.health());
let old_health = health(g);
let hurt = s.deals_damage && {
let _hit = pvp_scope(hit);
g.mob_hits_player(s.amount, s.from, "minecraft:spear")
};
g.combat.player_attacker = None;
// causeExtraKnockback: a push of 0.4, then the weapon's Knockback,
// whether or not the hurt went through.
let (strength, xd, zd) = s.knockback;
if s.deals_knockback {
g.knock_back_player(0.4, xd, zd, SPEAR_SOURCE);
if strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd, SPEAR_SOURCE);
}
}
if hurt
&& s.ignite > 0.0
&& let Some(p) = g.player.as_mut()
{
let _ = p.ignite_for_ticks((s.ignite * 20.0) as i32);
}
let dismounted = s.dismounts && g.riding.is_some();
if dismounted {
g.stop_riding();
}
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
let (feet, width, height) = g.player.as_ref().map_or(([0.0; 3], 0.6, 1.8), |p| {
(
[p.position.x, p.position.y, p.position.z],
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
)
});
let landed = Landed {
hurt,
magic: s.magic,
affected: hurt || s.deals_knockback || dismounted,
actual: old_health - health(g),
id,
feet,
width,
height,
};
(landed, thorns)
};
let (landed, thorns) = self.guest_struck_by(host, s.victim, land)?;
self.pvp_aftermath(host, victim, Some(attacker), landed.hurt, thorns);
Some(landed)
}
/// The attacker's side of the stabs that affected their victims
/// (`stabAttack` past its hurt), then the charge's hit feedback
/// (`broadcastEntityEvent(player, 2)`) or the jab's hit sound when no
/// mob's stab gave it already.
fn after_player_stabs(&mut self, batch: &StabBatch, landed: &[Landed]) {
let mut affected = false;
for l in landed.iter().filter(|l| l.affected) {
affected = true;
// attackVisualEffects: no attack sound, the enchantments' magic crit.
if l.magic {
self.critical_hit(self.lan_acting, l.id, l.feet, l.width, l.height, true);
}
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
// post-attack enchantments when the target was hurt.
if let Some(stack) = self.held().cloned() {
self.award_stat("used", &stack.item, 1);
if l.hurt {
self.post_attack_bursts(&stack, "minecraft:player");
}
self.weapon_wear(&stack);
}
self.damage_stats_and_hearts(l.actual, l.feet, l.height);
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
if !affected || batch.felt {
return;
}
if batch.charge {
self.spear_hit_feedback(&batch.kinetic);
} else {
self.play_at_feet(batch.kinetic.hit_sound);
}
}
}
@@ -0,0 +1,220 @@
//! `SulfurCube.mobInteract`'s last step, `Bucketable.bucketMobPickup`: an
//! empty bucket picks up an adult, unprimed sulfur cube, and the held stack
//! becomes `ItemUtils.createFilledResult(held, player, sulfur_cube_bucket)`
//! (tools/test_sulfur_bucket_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::{chunk::Chunk, mobs::Mob};
use horizon_world::nbt::Tag;
use std::sync::Arc;
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.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
game.screen = Screen::Playing;
game.selected = 0;
game
}
/// A fresh mob of `kind` in front of the player, alone; returns its id.
fn mob(game: &mut Game<'_>, kind: &str, edit: impl FnOnce(&mut Mob)) -> u32 {
game.mobs.clear();
let kind = game.res.mob_types().get(kind).unwrap().clone();
let mut m = Mob::new(42, kind, Vec3::new(0.5, 64.0, 2.5), 0.0);
m.no_ai = true;
edit(&mut m);
game.mobs.push(m);
42
}
/// Clicks mob `id` holding `count` of `item` in `mode`: whether the click
/// was used, whether the mob is still there, and the inventory after it.
fn click(
game: &mut Game<'_>,
id: u32,
mode: i32,
item: &str,
count: u32,
) -> (bool, bool, Vec<(String, u32)>) {
game.set_mode_id(mode);
game.inventory.iter_mut().for_each(|s| *s = None);
game.inventory[0] = Some(ItemStack::new(item, count));
let used = game
.interact_entity(Some(InteractTarget {
id: id as i32,
location: [0.0, 1.0, 0.0],
}))
.unwrap();
let there = game.mobs.iter().any(|m| m.id == id && !m.removed);
let held = game
.inventory
.iter()
.flatten()
.map(|s| (s.item.to_string(), s.count))
.collect();
(used, there, held)
}
fn owned(items: &[(&str, u32)]) -> Vec<(String, u32)> {
items.iter().map(|(i, n)| (i.to_string(), *n)).collect()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_empty_bucket_picks_up_an_adult_sulfur_cube() {
let mut game = game();
// Survival, two buckets: one is filled, the other stays in the hand.
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
assert_eq!(
click(&mut game, id, 0, "minecraft:bucket", 2),
(
true,
false,
owned(&[("minecraft:bucket", 1), ("minecraft:sulfur_cube_bucket", 1)])
)
);
// The last bucket becomes the filled one.
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
assert_eq!(
click(&mut game, id, 0, "minecraft:bucket", 1),
(true, false, owned(&[("minecraft:sulfur_cube_bucket", 1)]))
);
// Creative keeps its bucket and gets the filled one.
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
assert_eq!(
click(&mut game, id, 1, "minecraft:bucket", 1),
(
true,
false,
owned(&[("minecraft:bucket", 1), ("minecraft:sulfur_cube_bucket", 1)])
)
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn no_pickup_without_an_empty_bucket_or_of_a_baby_or_primed_cube() {
let mut game = game();
fn plain(_: &mut Mob) {}
fn baby(m: &mut Mob) {
m.extra.age = -24000;
}
fn primed(m: &mut Mob) {
m.extra.body = Some("minecraft:tnt".into());
assert!(horizon_client::mobs::sulfur_cube::prime(m, false));
}
let cases: [(&str, fn(&mut Mob), &str); 3] = [
("water bucket", plain, "minecraft:water_bucket"),
("baby", baby, "minecraft:bucket"),
("primed", primed, "minecraft:bucket"),
];
for (case, edit, item) in cases {
let id = mob(&mut game, "minecraft:sulfur_cube", edit);
let (_, there, held) = click(&mut game, id, 0, item, 1);
assert!(there, "{case}: the cube stays");
assert_eq!(held, owned(&[(item, 1)]), "{case}: the hand is unchanged");
}
// A cod still takes a water bucket.
let id = mob(&mut game, "minecraft:cod", |_| {});
assert_eq!(
click(&mut game, id, 0, "minecraft:water_bucket", 1),
(true, false, owned(&[("minecraft:cod_bucket", 1)]))
);
}
/// The filled bucket the click left in the inventory.
fn filled(game: &Game<'_>, item: &str) -> ItemStack {
game.inventory
.iter()
.flatten()
.find(|s| &*s.item == item)
.cloned()
.unwrap()
}
fn component(stack: &ItemStack, id: &str) -> Option<Tag> {
stack.extra.as_ref().and_then(|d| d.other(id)).cloned()
}
fn field(tag: &Option<Tag>, key: &str) -> Option<Tag> {
match tag {
Some(Tag::Compound(c)) => c.get(&key.into()).cloned(),
_ => None,
}
}
/// Added controls (after the baseline): `saveToBucketTag` writes the live
/// cube's age, age lock and body, and nothing for a cube with no body; a
/// cod's bucket keeps its own data.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_sulfur_bucket_carries_the_live_cube_s_age_lock_and_body() {
let mut game = game();
let id = mob(&mut game, "minecraft:sulfur_cube", |m| {
m.extra.age = 1200;
m.extra.age_locked = true;
m.extra.body = Some("minecraft:oak_planks".into());
m.health = 3.0;
});
assert!(click(&mut game, id, 0, "minecraft:bucket", 1).0);
let bucket = filled(&game, "minecraft:sulfur_cube_bucket");
let data = component(&bucket, "minecraft:bucket_entity_data");
assert_eq!(field(&data, "age"), Some(Tag::Int(1200)));
assert_eq!(field(&data, "age_locked"), Some(Tag::Byte(1)));
assert_eq!(field(&data, "Health"), Some(Tag::Float(3.0f32.to_bits())));
assert_eq!(field(&data, "NoAI"), Some(Tag::Byte(1)));
assert_eq!(
field(&component(&bucket, "minecraft:sulfur_cube_content"), "id"),
Some(Tag::String("minecraft:oak_planks".into()))
);
// No body, no content; an unlocked adult.
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
assert!(click(&mut game, id, 0, "minecraft:bucket", 1).0);
let bucket = filled(&game, "minecraft:sulfur_cube_bucket");
let data = component(&bucket, "minecraft:bucket_entity_data");
assert_eq!(
(field(&data, "age"), field(&data, "age_locked")),
(Some(Tag::Int(0)), Some(Tag::Byte(0)))
);
assert_eq!(component(&bucket, "minecraft:sulfur_cube_content"), None);
// A cod's bucket has no sulfur fields.
let id = mob(&mut game, "minecraft:cod", |_| {});
assert!(click(&mut game, id, 0, "minecraft:water_bucket", 1).0);
let bucket = filled(&game, "minecraft:cod_bucket");
let data = component(&bucket, "minecraft:bucket_entity_data");
assert_eq!(
(field(&data, "age"), field(&data, "age_locked")),
(None, None)
);
assert_eq!(component(&bucket, "minecraft:sulfur_cube_content"), None);
}
@@ -172,8 +172,43 @@ impl Game<'_> {
self.post_game_event("minecraft:entity_interact", [at.x, at.y, at.z], ctx);
return true;
}
if item == "minecraft:bucket" {
self.bucket_sulfur_cube(index);
return true;
}
false
}
/// `Bucketable.bucketMobPickup` for a sulfur cube
/// (`SulfurCube.canBePickedUpWithBucket`: an empty bucket), as the water
/// bucket's mobs are picked up in `interact_mob`.
fn bucket_sulfur_cube(&mut self, index: usize) {
let held = self.held().cloned();
let m = &self.mobs[index];
self.audio.play(
"minecraft:item.bucket.fill_sulfur_cube",
Some(m.center()),
1.0,
1.0,
);
// getBucketItemStack, then saveToBucketTag.
let mut filled = ItemStack::new("minecraft:sulfur_cube_bucket", 1);
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(m)));
self.trigger_item("minecraft:filled_bucket", &filled);
// PlayerInteractTrigger: the bucket as it was, and the cube picked up.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
let id = self.mobs[index].id;
// Leashable.dropLeash, then discard.
self.break_leash(id, true, false);
self.mobs[index].removed = true;
// ItemUtils.createFilledResult.
self.filled_result(filled);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_bucket_entity(player, &name, id as i32, "minecraft:sulfur_cube");
}
}
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
/// no block has an own signal, so the neighbour signal is all there is).
+123 -71
View File
@@ -86,6 +86,80 @@ impl TestDef {
}
}
/// `GameTestInstance`'s codec, as the game reads a `test_instance` file.
fn parse_test_def(json: &serde_json::Value) -> Option<TestDef> {
let string = |key: &str| json.get(key).and_then(|v| v.as_str());
let structure = string("structure")?.to_owned();
let environment = string("environment")?.to_owned();
let dimension = string("dimension").unwrap_or("minecraft:overworld");
let function = string("function");
// `ExtraCodecs.POSITIVE_INT`, 1 when absent.
let positive = |key: &str| {
json.get(key)
.and_then(|v| v.as_i64())
.unwrap_or(1)
.clamp(1, i64::from(i32::MAX)) as i32
};
// GameTestInstance.describe: the type, the function (function tests
// only), then the structure, batch and dimension.
let block_based = string("type") == Some("minecraft:block_based");
let mut description = Text::translate(
"test_instance.description.type",
&[Text::translate(
if block_based {
"test_instance.type.block_based"
} else {
"test_instance.type.function"
},
&[],
)
.with_color(0x5555FF)],
)
.append(Text::literal("\n"));
if !block_based && let Some(function) = function {
description = description.append(row("test_instance.description.function", function));
}
description = description
.append(row("test_instance.description.structure", &structure))
.append(row("test_instance.description.batch", &environment))
.append(row("test_instance.description.dimension", dimension));
Some(TestDef {
structure,
rotation: string("rotation")
.and_then(Rotation::from_name)
.unwrap_or(Rotation::None),
padding: json
.get("padding")
.and_then(|v| v.as_i64())
.unwrap_or(0)
.clamp(0, 128) as i32,
required: json
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(true),
sky_access: json
.get("sky_access")
.and_then(|v| v.as_bool())
.unwrap_or(false),
block_based,
function: function.map(str::to_owned),
environment: environment.clone(),
dimension: dimension.to_owned(),
max_ticks: json
.get("max_ticks")
.and_then(|v| v.as_i64())
.unwrap_or(1)
.clamp(1, i64::from(i32::MAX)) as i32,
setup_ticks: json
.get("setup_ticks")
.and_then(|v| v.as_i64())
.unwrap_or(0)
.clamp(0, i64::from(i32::MAX)) as i32,
max_attempts: positive("max_attempts"),
required_successes: positive("required_successes"),
description,
})
}
/// `GameTestInstance.descriptionRow`: the row's value in blue, then a line break.
fn row(key: &str, value: &str) -> Text {
Text::translate(key, &[Text::literal(value).with_color(0x5555FF)]).append(Text::literal("\n"))
@@ -102,6 +176,10 @@ impl Game<'_> {
if let Some(known) = self.test_defs.borrow().get(id) {
return known.clone();
}
// A world's test instances are the ones it opened with.
if self.test_defs_snapshot {
return None;
}
let def = self.read_test_def(id);
self.test_defs
.borrow_mut()
@@ -109,6 +187,50 @@ impl Game<'_> {
def
}
/// `Registries.TEST_INSTANCE` as a world loads it: the built-in pack's
/// `test_instance` files, then those of the packs the world starts with
/// enabled, later packs overriding earlier ones. The set stays as it is
/// until the next world opens; `/reload` and `/datapack` do not change it.
/// It replaces the previous world's definitions and selector ids.
pub(super) fn snapshot_test_defs(&mut self) {
let stack = super::functions::pack_stack(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
let mut defs = std::collections::HashMap::new();
for pack in stack.builtin.iter().chain(stack.enabled_packs()) {
for file in pack.list_folder("test_instance") {
let Some(id) = file
.strip_prefix("data/")
.and_then(|r| r.split_once("/test_instance/"))
.and_then(|(ns, path)| Some(format!("{ns}:{}", path.strip_suffix(".json")?)))
else {
continue;
};
let Some(json) = pack
.read(&file)
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
else {
continue;
};
if let Some(def) = parse_test_def(&json) {
defs.insert(id, Some(def));
}
}
}
let mut ids: Vec<String> = defs.keys().cloned().collect();
ids.sort();
self.ui
.registries
.by_registry
.entry("minecraft:test_instance".into())
.or_default()
.ids = ids;
*self.test_defs.borrow_mut() = defs;
self.test_defs_snapshot = true;
}
fn read_test_def(&self, id: &str) -> Option<TestDef> {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = self
@@ -119,77 +241,7 @@ impl Game<'_> {
.join("test_instance")
.join(format!("{path}.json"));
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(file).ok()?).ok()?;
let string = |key: &str| json.get(key).and_then(|v| v.as_str());
let structure = string("structure")?.to_owned();
let environment = string("environment")?.to_owned();
let dimension = string("dimension").unwrap_or("minecraft:overworld");
let function = string("function");
// `ExtraCodecs.POSITIVE_INT`, 1 when absent.
let positive = |key: &str| {
json.get(key)
.and_then(|v| v.as_i64())
.unwrap_or(1)
.clamp(1, i64::from(i32::MAX)) as i32
};
// GameTestInstance.describe: the type, the function (function tests
// only), then the structure, batch and dimension.
let block_based = string("type") == Some("minecraft:block_based");
let mut description = Text::translate(
"test_instance.description.type",
&[Text::translate(
if block_based {
"test_instance.type.block_based"
} else {
"test_instance.type.function"
},
&[],
)
.with_color(0x5555FF)],
)
.append(Text::literal("\n"));
if !block_based && let Some(function) = function {
description = description.append(row("test_instance.description.function", function));
}
description = description
.append(row("test_instance.description.structure", &structure))
.append(row("test_instance.description.batch", &environment))
.append(row("test_instance.description.dimension", dimension));
Some(TestDef {
structure,
rotation: string("rotation")
.and_then(Rotation::from_name)
.unwrap_or(Rotation::None),
padding: json
.get("padding")
.and_then(|v| v.as_i64())
.unwrap_or(0)
.clamp(0, 128) as i32,
required: json
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(true),
sky_access: json
.get("sky_access")
.and_then(|v| v.as_bool())
.unwrap_or(false),
block_based,
function: function.map(str::to_owned),
environment: environment.clone(),
dimension: dimension.to_owned(),
max_ticks: json
.get("max_ticks")
.and_then(|v| v.as_i64())
.unwrap_or(1)
.clamp(1, i64::from(i32::MAX)) as i32,
setup_ticks: json
.get("setup_ticks")
.and_then(|v| v.as_i64())
.unwrap_or(0)
.clamp(0, i64::from(i32::MAX)) as i32,
max_attempts: positive("max_attempts"),
required_successes: positive("required_successes"),
description,
})
parse_test_def(&json)
}
pub(super) fn test_instance_at(&self, pos: Pos) -> Option<&TestInstanceBlock> {
@@ -122,6 +122,14 @@ impl Game<'_> {
}
}
}
// A new world opened without the Create screen still gets the game folder's packs.
if create.is_none() {
let game_dir = std::env::current_dir()?;
let world = game_dir.join("saves").join(folder);
if let Err(e) = horizon_client::datapack::install_new_world_packs(&game_dir, &world) {
log::warn!("Horizon: data packs for {folder}: {e}");
}
}
// IntegratedServer.initServer's line, which other launchers read in `latest.log` to
// offer the world again.
horizon_client::logging::write_as(
@@ -0,0 +1,201 @@
//! Test instances come from the data packs the world enables
//! (`Registries.TEST_INSTANCE` is loaded from them), not only from the
//! game's own data: a definition only a world pack has is found and runs
//! (tools/test_world_test_def_parity.py measures the same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// A world folder with one data pack holding `h275:broken`.
fn world_with_pack(tag: &str) -> std::path::PathBuf {
let world = std::env::temp_dir().join(format!("h275-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&world);
let pack = world.join("datapacks/fixture");
let tests = pack.join("data/h275/test_instance");
std::fs::create_dir_all(&tests).unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"H275 fixture","pack_format":100}}"#,
)
.unwrap();
std::fs::write(
tests.join("broken.json"),
r#"{"type":"minecraft:block_based","structure":"minecraft:empty","environment":"minecraft:default","max_ticks":20,"required":true}"#,
)
.unwrap();
world
}
fn game(world: &std::path::Path) -> 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.world_root = world.to_path_buf();
game.build_registries();
game.set_allow_commands(true);
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_test_instance_from_an_enabled_world_pack_is_found_and_runs() {
let world = world_with_pack("found");
let mut game = game(&world);
// The world opens: its state, registries and test instances.
game.load_world_state();
game.ensure_datapacks();
let enabled = &game.ui.registries.datapacks_enabled;
assert!(
enabled.iter().any(|id| id == "file/fixture"),
"the world's pack is enabled: {enabled:?}"
);
let found = game.test_def("h275:broken").expect("the world pack's test");
assert!(found.block_based && found.required, "its own settings");
assert!(
game.test_def("minecraft:always_pass").is_some(),
"the built-in one"
);
assert!(game.test_def("h275:missing").is_none(), "no pack has it");
// `/test run` places and starts it.
for cx in -1..=2 {
for cz in -1..=2 {
game.world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(24.5, 100.0, 8.5))
.unwrap(),
);
game.chat_capture = Some(Vec::new());
assert!(game.run_command("test run h275:broken"));
let said: Vec<String> = game
.chat_capture
.take()
.unwrap()
.iter()
.map(|t| t.plain())
.collect();
assert_eq!(
said.first().map(String::as_str),
Some("Running 1 test(s)..."),
"{said:?}"
);
let _ = std::fs::remove_dir_all(&world);
}
/// A world folder with one pack holding these `test_instance` files.
fn world_with(tag: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let world = std::env::temp_dir().join(format!("h275-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&world);
let pack = world.join("datapacks/fixture");
std::fs::create_dir_all(&pack).unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"H275 fixture","pack_format":100}}"#,
)
.unwrap();
for (id, body) in files {
let (ns, path) = id.split_once(':').unwrap();
let file = pack.join(format!("data/{ns}/test_instance/{path}.json"));
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, body).unwrap();
}
world
}
const FAILING: &str = r#"{"type":"minecraft:block_based","structure":"minecraft:empty","environment":"minecraft:default","max_ticks":20,"required":true}"#;
/// Added controls (after the baseline): the world opens with its test
/// instances and keeps them. Files changed after it opened, and packs
/// disabled and reloaded, change nothing. The next world replaces them,
/// cached misses and selector ids included. A pack overrides a built-in id.
/// A game with no world opened reads the game's own data as before.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_world_keeps_the_test_instances_it_opened_with() {
let a = world_with(
"a",
&[("h275:broken", FAILING), ("minecraft:always_pass", FAILING)],
);
let b = world_with("b", &[("h275:other", FAILING)]);
let mut game = game(&a);
// No world opened yet: the game's own data, as before.
let builtin = game.test_def("minecraft:always_pass").expect("built in");
assert!(!builtin.block_based, "the built-in function test");
game.load_world_state();
let ids = |game: &Game<'_>| game.ui.registries.ids("minecraft:test_instance").to_vec();
assert_eq!(ids(&game), ["h275:broken", "minecraft:always_pass"]);
assert!(
game.test_def("minecraft:always_pass").unwrap().block_based,
"the pack's override"
);
assert!(game.test_def("h275:other").is_none(), "a miss in world A");
// A file changed after the world opened.
let file = a.join("datapacks/fixture/data/h275/test_instance/broken.json");
std::fs::write(
&file,
FAILING.replace("\"required\":true", "\"required\":false"),
)
.unwrap();
assert!(
game.test_def("h275:broken").unwrap().required,
"the opened definition"
);
// The pack disabled and the data reloaded.
assert!(game.run_command("datapack disable \"file/fixture\""));
assert!(game.run_command("reload"));
assert!(
game.test_def("h275:broken").is_some(),
"kept over /datapack and /reload"
);
assert_eq!(ids(&game), ["h275:broken", "minecraft:always_pass"]);
// World B.
game.world_root = b.clone();
game.load_world_state();
assert_eq!(ids(&game), ["h275:other", "minecraft:always_pass"]);
assert!(
game.test_def("h275:broken").is_none(),
"nothing from world A"
);
assert!(
game.test_def("h275:other").is_some(),
"world A's miss is gone"
);
assert!(
!game.test_def("minecraft:always_pass").unwrap().block_based,
"built in again"
);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
+235 -8
View File
@@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// `server.toml`'s `[transfer]`, as horizon-server passes it: `--transfers
/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60`.
/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60,stop_wait_seconds=10`.
/// A key left out keeps its default.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Settings {
@@ -42,6 +42,8 @@ pub struct Settings {
/// Server ticks a held player may wait for its hand-over before the server
/// drops it.
pub max_hold_ticks: u64,
/// How long a stop waits for a proxy to move the players out ([`StopWait`]); zero stops at once.
pub stop_wait: Duration,
}
impl Default for Settings {
@@ -52,13 +54,14 @@ impl Default for Settings {
max_expected: 64,
max_freeze_ticks: 20 * 30,
max_hold_ticks: 20 * 60,
stop_wait: Duration::from_secs(10),
}
}
}
impl Settings {
/// Reads `--transfers`' value: `key=value` pairs separated by commas, each a
/// whole number above zero.
/// whole number above zero, but `stop_wait_seconds`, which is zero to [`MAX_STOP_WAIT`].
pub fn parse(text: &str) -> Result<Self, String> {
let mut s = Self::default();
for pair in text.split(',').map(str::trim).filter(|p| !p.is_empty()) {
@@ -70,9 +73,16 @@ impl Settings {
.trim()
.parse()
.ok()
.filter(|n| *n > 0)
.filter(|n| *n > 0 || key == "stop_wait_seconds")
.ok_or_else(|| format!("{key} must be a whole number above zero"))?;
match key {
"stop_wait_seconds" if n > MAX_STOP_WAIT.as_secs() => {
return Err(format!(
"stop_wait_seconds is above {}",
MAX_STOP_WAIT.as_secs()
));
}
"stop_wait_seconds" => s.stop_wait = Duration::from_secs(n),
"expect_seconds" => s.expect = Duration::from_secs(n),
"max_expect_seconds" => s.max_expect = Duration::from_secs(n),
"max_expected" => {
@@ -91,6 +101,15 @@ impl Settings {
if s.expect > s.max_expect {
return Err("expect_seconds is above max_expect_seconds".into());
}
// A stop's wait is cut at STOP_WAIT_LIMIT: past it, no hand-over could start during the wait.
if !s.stop_wait.is_zero()
&& s.stop_wait.as_secs() + s.max_freeze_ticks / 20 > STOP_WAIT_LIMIT.as_secs()
{
return Err(format!(
"stop_wait_seconds and max_freeze_seconds add up to more than {}",
STOP_WAIT_LIMIT.as_secs()
));
}
Ok(s)
}
}
@@ -106,6 +125,8 @@ pub struct Transfers {
/// taken back): no player is expected, imported or placed any more, but the players here can
/// still be exported and released, which is their way out.
pub stopping: bool,
/// The wait of that stop, while it lasts.
pub stop_wait: Option<StopWait>,
}
impl Transfers {
@@ -118,6 +139,7 @@ impl Transfers {
settings,
expected,
stopping: false,
stop_wait: None,
}
}
@@ -128,6 +150,95 @@ impl Transfers {
}
}
/// The longest `stop_wait_seconds` may be.
pub const MAX_STOP_WAIT: Duration = Duration::from_secs(60);
/// The longest a stop waits, a hand-over's freeze included: horizon-server kills a game that has not
/// stopped within 120 s (its `STOP_TIMEOUT`), and the saves need the rest.
pub const STOP_WAIT_LIMIT: Duration = Duration::from_secs(90);
/// A stop with `[transfer]` on and players here waits, so a proxy told the server is stopping
/// (`server/stopping`) can move them out first: no login comes in, the server ticks on and answers the
/// proxy's requests. The wait ends as soon as no player is left, at `until` when no hand-over is frozen,
/// and at `limit` whatever is under way: `until` and a freeze's longest time, so a hand-over started in
/// time can finish, within [`STOP_WAIT_LIMIT`]. No freeze starts that the limit would cut: a player whose
/// source saved it after the target placed it would be on both servers.
///
/// A freeze lasts `max_freeze_ticks` ticks, not seconds: its length in time is reckoned at the tick
/// length the server has now (`period`, [`tick_period`]), which the game sets again at every tick, so a
/// slow server or `/tick rate 10` refuses a freeze sooner. A server that slows down after a freeze began
/// can still reach the limit with it: that is the warning of the stop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StopWait {
pub until: Instant,
pub limit: Instant,
freeze_ticks: u64,
/// The length of a tick now.
pub period: Duration,
}
impl StopWait {
/// The wait of a stop at `now`, if the settings make one, with ticks of `period`.
pub fn start(settings: &Settings, now: Instant, period: Duration) -> Option<Self> {
let freeze = freeze_time(settings.max_freeze_ticks, period);
(!settings.stop_wait.is_zero()).then(|| Self {
until: now + settings.stop_wait,
limit: now
+ settings
.stop_wait
.saturating_add(freeze)
.min(STOP_WAIT_LIMIT),
freeze_ticks: settings.max_freeze_ticks,
period,
})
}
/// Whether the stop goes on now, with these players placed and frozen here.
pub fn over(&self, now: Instant, placed: usize, frozen: usize) -> bool {
placed == 0 || (now >= self.until && frozen == 0) || now >= self.limit
}
/// Whether a hand-over that starts now would end before the limit, at the tick length of now.
pub fn may_freeze(&self, now: Instant) -> bool {
now.checked_add(freeze_time(self.freeze_ticks, self.period))
.is_some_and(|end| end <= self.limit)
}
}
/// How long `ticks` ticks of `period` take.
fn freeze_time(ticks: u64, period: Duration) -> Duration {
period.saturating_mul(u32::try_from(ticks).unwrap_or(u32::MAX))
}
/// The length of a tick: the rate's (`target`, which `/tick rate` sets), or longer when the server
/// is slower than that (`tps`, the ticks it made a second lately; 0 when unknown).
pub fn tick_period(target: Duration, tps: f64) -> Duration {
let measured = if tps > 0.0 {
Duration::try_from_secs_f64(1.0 / tps).unwrap_or(Duration::MAX)
} else {
Duration::ZERO
};
measured.max(target)
}
/// The players placed here (a player a `moved` release took out is not counted) and those of them
/// frozen for a hand-over.
pub fn placed_and_frozen(hub: &Hub) -> (usize, usize) {
let here = || hub.guests.iter().filter(|g| g.placed && !g.released);
(
here().count(),
here().filter(|g| g.handover.is_some()).count(),
)
}
/// The log line of a stop that waits.
pub fn stop_wait_line(wait: Duration) -> String {
format!(
"Stopping in {} s: a proxy may move the players out first",
wait.as_secs()
)
}
/// Why an expectation could not be recorded.
#[derive(Debug, PartialEq, Eq)]
pub enum ExpectError {
@@ -290,6 +401,16 @@ pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool {
now.saturating_sub(since) >= max
}
/// `player-export <uuid> freeze` during a stop's wait: refused once a hand-over that starts now could
/// not finish before the stop's limit ([`StopWait`]). A player already frozen is answered as before.
pub fn check_freeze(hub: &Hub, i: usize, now: Instant) -> Result<(), &'static str> {
let wait = hub.transfers.as_ref().and_then(|t| t.stop_wait);
if hub.guests[i].handover.is_none() && wait.is_some_and(|w| !w.may_freeze(now)) {
return Err("the server is stopping: no hand-over starts this late");
}
Ok(())
}
/// Why the connected player `uuid` cannot be exported now, or the index of its
/// guest. A held player is not exportable: it has no state of its own yet.
pub fn check_exportable(hub: &Hub, uuid: u128) -> Result<usize, &'static str> {
@@ -597,15 +718,14 @@ pub fn status_request(hub: &Hub, now: Instant) -> serde_json::Value {
let Some(transfers) = &hub.transfers else {
return error(OFF);
};
let count =
|keep: fn(&super::guest::Guest) -> bool| hub.guests.iter().filter(|g| keep(g)).count();
let (online, max_players) = (hub.lan.shared.online(), hub.lan.shared.max_players());
let (placed, frozen) = placed_and_frozen(hub);
serde_json::json!({
"ok": true,
"players": count(|g| g.placed && !g.released),
"held": count(|g| g.held),
"players": placed,
"held": hub.guests.iter().filter(|g| g.held).count(),
"expected": transfers.table().alive(now),
"frozen": count(|g| g.handover.is_some() && !g.released),
"frozen": frozen,
"online": online,
"max_players": max_players,
"full": online >= max_players,
@@ -852,9 +972,97 @@ mod tests {
"expect_seconds",
"colour=blue",
"expect_seconds=90",
"stop_wait_seconds=61",
"max_freeze_seconds=81",
"stop_wait_seconds=60,max_freeze_seconds=31",
] {
assert!(Settings::parse(bad).is_err(), "{bad:?} was taken");
}
// A stop that does not wait is zero seconds.
assert_eq!(Settings::default().stop_wait, secs(10));
assert_eq!(
Settings::parse("stop_wait_seconds=0").unwrap().stop_wait,
secs(0)
);
assert_eq!(
Settings::parse("stop_wait_seconds=60").unwrap().stop_wait,
secs(60)
);
// The two fit in the stop's limit, or the stop does not wait.
assert!(Settings::parse("stop_wait_seconds=60,max_freeze_seconds=30").is_ok());
assert!(Settings::parse("stop_wait_seconds=0,max_freeze_seconds=300").is_ok());
}
/// A stop's wait ends when nobody is left, at its time when no hand-over is frozen, and at its
/// limit (its time and a freeze's) whatever is under way; no freeze starts that the limit would
/// cut. Zero seconds is no wait.
#[test]
fn a_stop_waits_for_the_players_to_be_moved_out_but_not_for_ever() {
let now = Instant::now();
let settings = Settings::default();
let tick = Duration::from_millis(50);
let wait = StopWait::start(&settings, now, tick).unwrap();
assert_eq!((wait.until, wait.limit), (now + secs(10), now + secs(40)));
assert!(!wait.over(now, 2, 0));
assert!(wait.over(now, 0, 0), "nobody is left");
assert!(!wait.over(now + secs(9), 2, 0));
assert!(
wait.over(now + secs(10), 2, 0),
"its time, and no hand-over"
);
assert!(!wait.over(now + secs(39), 2, 1), "a hand-over may finish");
assert!(wait.over(now + secs(40), 2, 1), "the limit");
assert!(wait.may_freeze(now + secs(10)));
assert!(!wait.may_freeze(now + secs(11)));
// However long the settings, the whole wait fits in the supervisor's time for a stop.
let long = Settings {
stop_wait: secs(60),
max_freeze_ticks: 20 * 300,
..Settings::default()
};
let wait = StopWait::start(&long, now, tick).unwrap();
assert_eq!(wait.limit, now + STOP_WAIT_LIMIT);
assert!(!wait.may_freeze(now), "a freeze of 300 s would be cut");
let none = Settings {
stop_wait: secs(0),
..Settings::default()
};
assert_eq!(StopWait::start(&none, now, tick), None);
}
/// A freeze lasts ticks: at 10 ticks a second it lasts twice as long, the limit with it, and a
/// server that slows down during the wait refuses a freeze it would have let start at 20.
#[test]
fn a_stop_reckons_a_freeze_at_the_tick_length_of_now() {
let now = Instant::now();
let slow = Duration::from_millis(100);
let wait = StopWait::start(&Settings::default(), now, slow).unwrap();
assert_eq!((wait.until, wait.limit), (now + secs(10), now + secs(70)));
assert!(wait.may_freeze(now + secs(10)));
assert!(!wait.may_freeze(now + secs(11)));
let mut wait =
StopWait::start(&Settings::default(), now, Duration::from_millis(50)).unwrap();
assert!(wait.may_freeze(now));
wait.period = slow;
assert!(
!wait.may_freeze(now),
"60 s of freeze would pass the limit at 40 s"
);
// The tick length: the rate's, or longer when the server is slower than its rate.
let rate = Duration::from_millis(50);
assert_eq!(tick_period(rate, 20.0), rate);
assert_eq!(
tick_period(rate, 40.0),
rate,
"sprinting is not a shorter freeze"
);
assert_eq!(tick_period(rate, 10.0), Duration::from_millis(100));
assert_eq!(tick_period(rate, 0.0), rate, "no measure yet");
assert_eq!(
tick_period(Duration::from_millis(100), 20.0),
slow,
"/tick rate 10"
);
}
#[test]
@@ -1578,6 +1786,25 @@ mod hub_tests {
assert_eq!(ids(&seen), Vec::<i32>::new());
}
/// During a stop's wait a player is exported, and frozen while a hand-over can still finish in
/// time; later only an export already frozen is answered.
#[test]
fn a_stop_lets_a_hand_over_start_only_while_it_can_finish() {
let (mut hub, _seen, _mine, _h) = frozen_with_a_watcher();
let now = Instant::now();
let transfers = hub.transfers.as_mut().unwrap();
transfers.stop_wait =
super::StopWait::start(&transfers.settings, now, Duration::from_millis(50));
assert_eq!(super::placed_and_frozen(&hub), (2, 1));
assert_eq!(
super::check_freeze(&hub, 0, now + Duration::from_secs(10)),
Ok(())
);
let late = now + Duration::from_secs(11);
assert!(super::check_freeze(&hub, 0, late).is_err());
assert_eq!(super::check_freeze(&hub, 1, late), Ok(()), "already frozen");
}
/// After the commit, a retry finds the player placed from that hand-over whatever came since:
/// a stop that began meanwhile does not turn the retry into a refusal.
#[test]
+79
View File
@@ -306,11 +306,44 @@ fn watched_outgoing(player: u128, body: Vec<u8>, watched: u8) -> Option<Vec<u8>>
})
}
/// The packets (and their bytes) a connection's network thread has handed the hub and the hub hasn't
/// taken from its channel yet. The hub's inbox caps apply only as it drains that channel, at the start
/// of a tick: while a tick runs long (an autosave, chunk generation) a client could otherwise queue
/// tiny packets without bound, each a boxed `Serverbound` many times its size on the wire.
#[derive(Debug, Default)]
pub struct Undrained {
packets: AtomicUsize,
bytes: AtomicUsize,
}
impl Undrained {
/// Twice the hub's inbox caps: a client within them never comes near.
pub const MAX_PACKETS: usize = 2 * super::hub::INBOX_LIMIT;
pub const MAX_BYTES: usize = 2 * super::hub::INBOX_BYTES;
/// Count a packet of `bytes` handed to the hub, or `false` (counting nothing) when that would
/// pass a cap: the network thread disconnects the client then.
pub fn handed(&self, bytes: usize) -> bool {
let packets = self.packets.fetch_add(1, Ordering::AcqRel);
let total = self.bytes.fetch_add(bytes, Ordering::AcqRel);
if packets >= Self::MAX_PACKETS || total.saturating_add(bytes) > Self::MAX_BYTES {
self.taken(bytes);
return false;
}
true
}
/// The hub took a packet of `bytes` from its channel.
pub fn taken(&self, bytes: usize) {
self.packets.fetch_sub(1, Ordering::AcqRel);
self.bytes.fetch_sub(bytes, Ordering::AcqRel);
}
}
/// The sending half given to the game thread.
#[derive(Clone)]
pub struct PlayerSender {
/// The player it sends to, for the hooks on what it sends ([`outgoing`]); 0 for a test's.
player: u128,
/// What the player's network thread has handed the hub and the hub hasn't taken yet.
pub undrained: Arc<Undrained>,
/// The bytes of the bodies sent and not framed yet.
pub pending: Arc<AtomicUsize>,
held: Arc<Mutex<Vec<Vec<u8>>>>,
@@ -331,6 +364,7 @@ impl PlayerSender {
pub fn new(tx: Sender<Out>, pending: Arc<AtomicUsize>) -> Self {
Self {
player: 0,
undrained: Arc::default(),
pending,
held: Arc::default(),
to: To::Channel(tx),
@@ -1426,6 +1460,20 @@ fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt
/// RateKickingConnection: a player whose packets a second passed rate-limit
/// is disconnected ("disconnect.exceeded_packet_rate") with a WARN, as
/// vanilla's kick logs it.
/// The kick for a client whose packets wait past [`Undrained`]'s caps: the same message as the
/// hub's own inbox check, and a line in the log.
fn undrained_exceeded(shared: &Shared, name: &str) -> (horizon_world::nbt::Tag, String) {
let reason = translatable("disconnect.exceeded_packet_rate", &[]);
let Some(log) = &shared.config.log else {
return (reason, String::new());
};
let line =
format!("{name} sent more packets than the server handles before its tick took them");
(log.write)("Server thread", "WARN", &line);
let text = horizon_protocol::text::plain(&reason, &log.lang);
(reason, text)
}
fn rate_exceeded(shared: &Shared, average: f32) -> (horizon_world::nbt::Tag, String) {
let reason = translatable("disconnect.exceeded_packet_rate", &[]);
let Some(log) = &shared.config.log else {
@@ -1573,8 +1621,10 @@ fn play(
start: Instant::now(),
alive: Arc::new(AtomicI64::new(-1)),
});
let undrained = Arc::<Undrained>::default();
let sender = PlayerSender {
player: parts.uuid,
undrained: undrained.clone(),
pending,
held: Arc::default(),
to: To::Pool(outbox.clone(), watch.clone()),
@@ -1597,6 +1647,7 @@ fn play(
next_keep_alive: watch.start + KEEP_ALIVE_EVERY,
watch,
counted,
undrained,
};
let joined = Joined {
conn,
@@ -1641,6 +1692,8 @@ struct PlayConn {
next_keep_alive: Instant,
/// The connection's place among the server's, given back once closed.
counted: Option<super::Counted>,
/// Shared with the player's [`PlayerSender`]: what the hub hasn't taken yet.
undrained: Arc<Undrained>,
}
impl io_pool::Session for PlayConn {
/// Forward the player's packet, or say why it ends the connection: a
@@ -1674,6 +1727,10 @@ impl io_pool::Session for PlayConn {
kick(pack_rejected(&self.shared, &self.name, r.id))
}
Ok(p) => {
// Bounded here, where packets are made, not only where the hub drains them.
if !self.undrained.handed(payload.len()) {
return kick(undrained_exceeded(&self.shared, &self.name));
}
let event = NetEvent::Packet(self.conn, Box::new(p), payload.len());
match self.shared.events.send(event) {
Ok(()) => Verdict::Go,
@@ -1926,6 +1983,28 @@ impl KeepAliveWatch {
#[cfg(test)]
mod tests {
#[test]
fn undrained_packets_stop_at_twice_the_inbox() {
let u = Undrained::default();
// Up to the cap, counted; one more is refused and counts nothing.
for _ in 0..Undrained::MAX_PACKETS {
assert!(u.handed(3));
}
assert!(!u.handed(3));
// The hub taking one gives its place back.
u.taken(3);
assert!(u.handed(3));
// Bytes too: a few large packets reach the byte cap long before the count.
let big = Undrained::default();
let size = Undrained::MAX_BYTES / 4;
for _ in 0..4 {
assert!(big.handed(size));
}
assert!(!big.handed(1));
big.taken(size);
assert!(big.handed(size));
}
use super::*;
/// Having tables for Horizon to join an old server does not make the reverse direction supported.
+4 -2
View File
@@ -2255,6 +2255,8 @@ impl Hub {
let Some(g) = self.guest_mut(conn) else {
continue;
};
// Out of the channel: the network thread may hand this player more.
g.sender.undrained.taken(bytes);
// A kicked player's packets wait for nothing.
if !g.left_reason.is_empty() {
continue;
@@ -5164,10 +5166,10 @@ fn wrap_degrees(degrees: f32) -> f32 {
const PACKETS_PER_TICK: usize = 400;
/// The packets a player may have waiting (a second of its budget) before it is
/// disconnected, as vanilla's rate-limit does: no real client gets near it.
const INBOX_LIMIT: usize = 20 * PACKETS_PER_TICK;
pub(crate) const INBOX_LIMIT: usize = 20 * PACKETS_PER_TICK;
/// The same in bytes on the wire: 8000 full books would hold gigabytes. A
/// real client's largest packets (a 100-page book) are a few hundred kB.
const INBOX_BYTES: usize = 16 << 20;
pub(crate) const INBOX_BYTES: usize = 16 << 20;
/// The `minecraft:gravity` attribute of a player.
const PLAYER_GRAVITY: f64 = 0.08;
+29
View File
@@ -451,6 +451,8 @@ pub struct Shared {
closing: AtomicUsize,
/// Connections turned away since the last report of them, and when that was.
turned_away: Mutex<(usize, Option<Instant>)>,
/// A stop waits for a proxy to move the players out (`arrivals::StopWait`): no login comes in.
shutting_down: AtomicBool,
}
impl Shared {
/// The registries and tags a new connection is sent.
@@ -473,6 +475,9 @@ impl Shared {
uuid: u128,
name: &str,
) -> Option<horizon_world::nbt::Tag> {
if self.shutting_down.load(Ordering::Relaxed) {
return Some(translatable("multiplayer.disconnect.server_shutdown", &[]));
}
if let Some(gate) = &self.config.gate
&& let Some(reason) = gate(address, uuid, name)
{
@@ -499,6 +504,10 @@ impl Shared {
self.online.lock().unwrap().push((uuid, name.to_owned()));
None
}
/// A stop has begun: every login from now on is refused as the server's shutdown.
pub fn refuse_logins(&self) {
self.shutting_down.store(true, Ordering::Relaxed);
}
fn is_online(&self, uuid: u128) -> bool {
self.online.lock().unwrap().iter().any(|(u, _)| *u == uuid)
}
@@ -687,6 +696,7 @@ impl Lan {
next_connection: AtomicU64::new(0),
closing: AtomicUsize::new(0),
turned_away: Mutex::new((0, None)),
shutting_down: AtomicBool::new(false),
random: Mutex::new(crate::server::java_properties::now_millis() as u64 | 1),
config,
});
@@ -1138,6 +1148,25 @@ mod tests {
}
}
/// Once a stop waits for a proxy, no login takes a place, an op's neither.
#[test]
fn a_server_that_refuses_logins_takes_no_one_in() {
let config = NetConfig {
announce: false,
listen: false,
bypasses_limit: Some(Arc::new(|_| true)),
..NetConfig::loopback(0, "Stopping".into(), 8)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
lan.shared.refuse_logins();
assert_eq!(
lan.shared.try_admit(MEMORY_ADDRESS, 2, "Late"),
Some(translatable("multiplayer.disconnect.server_shutdown", &[]))
);
assert_eq!(lan.shared.online(), 1);
}
#[test]
fn hide_online_players_updates_status_response() {
let config = NetConfig {
+54
View File
@@ -1225,6 +1225,9 @@ pub struct Extra {
/// Species scratch values (the wither's head targets and clocks).
pub ints: [i32; 6],
pub counter: i32,
/// Nautilus ATTACK_TARGET_COOLDOWN saved slot: None is absent,
/// Some(MAX) is permanent, otherwise the remaining lifetime. The value is `counter`.
pub nautilus_attack_cooldown_ttl: Option<i64>,
pub counter2: i32,
pub home: Option<[i32; 3]>,
/// A copper golem's `openedChestPos`.
@@ -3821,6 +3824,13 @@ impl Mob {
.push(MobEvent::Sound(sound, at, ai::sound_volume(self), pitch));
}
}
// playSecondaryHurtSound: a thorns hit sounds where the mob
// stands, silent or not.
if source.kind == DamageKind::Thorns {
let at = [self.position.x, self.position.y, self.position.z];
self.outbox
.push(MobEvent::Sound("enchant.thorns.hit", at, 1.0, 1.0));
}
}
// IronGolem.hurtServer: a new crack level plays the damage sound.
if let Some(before) = crackiness
@@ -5444,6 +5454,50 @@ mod magic_absorb_tests {
assert_eq!(m.extra.effects, vec![(speed, 900, 0)]);
}
/// `LivingEntity.playSecondaryHurtSound`: a `thorns` hit that lands whole
/// sounds `enchant.thorns.hit` where the mob stands, after its own hurt
/// sound and for a silent mob too (`level.playSound`, not the mob's). The
/// rest of a stronger hit inside the hurt cooldown makes no sound, and
/// another kind of hit makes none.
#[test]
fn a_thorns_hit_sounds_where_the_mob_stands() {
let thorns = Damage {
kind: DamageKind::Thorns,
attacker: Some(Who::Player),
from: Some([4.0, 2.0, 3.0]),
attacker_species: None,
};
let sounds = |m: &Mob| -> Vec<(&'static str, [f64; 3], f32, f32)> {
m.outbox
.iter()
.filter_map(|e| match e {
MobEvent::Sound(s, at, volume, pitch) => Some((*s, *at, *volume, *pitch)),
_ => None,
})
.collect()
};
let mut m = witch();
m.position = Vec3::new(1.0, 2.0, 3.0);
m.silent = true;
assert!(m.damage(4.0, thorns));
assert_eq!(
sounds(&m),
vec![("enchant.thorns.hit", [1.0, 2.0, 3.0], 1.0, 1.0)]
);
m.outbox.clear();
m.damage(8.0, thorns);
assert!(sounds(&m).is_empty(), "inside the hurt cooldown");
let mut loud = witch();
assert!(loud.damage(4.0, thorns));
let heard = sounds(&loud);
assert_eq!(heard.last().map(|s| s.0), Some("enchant.thorns.hit"));
let mut hit = witch();
assert!(hit.damage(4.0, Damage::player([4.0, 2.0, 3.0])));
assert_eq!(heard.len(), sounds(&hit).len() + 1, "{heard:?}");
assert!(sounds(&hit).iter().all(|s| s.0 != "enchant.thorns.hit"));
}
/// `Witch.getDamageAfterMagicAbsorb`: `#witch_resistant_to` (`magic`,
/// `indirect_magic`, `sonic_boom`, `thorns`) takes 15% of the hit, which
/// leaves a 10 health witch on 7 of a 20 hit it would not survive whole.
+44
View File
@@ -14,6 +14,9 @@ use horizon_core::geometry::trig::Mth;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::entity_data::PositionSource;
#[cfg(test)]
#[path = "allay_liked_player_tests.rs"]
mod allay_liked_player_tests;
pub mod interaction;
// Allay state: `main_hand` the held filter; `State::inventory` the fetched stack,
@@ -96,6 +99,10 @@ pub struct State {
pub inventory: Option<ItemStack>,
pub vibration: sculk::Data,
pub noteblock: Option<GlobalPos>,
/// The `LIKED_PLAYER` memory's time to live (`MemorySlot.timeToLive`);
/// None for a memory that never expires. The memory itself is
/// `Extra::tame` and `Extra::owner`.
pub liked_player_ttl: Option<i64>,
}
/// The pocket is independent of the main-hand filter.
@@ -412,6 +419,7 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
}
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
tick_liked_player(m);
passive_heal(m);
for i in [2, 3] {
if m.extra.ints[i] > 0 {
@@ -421,6 +429,42 @@ pub fn ai_step(m: &mut Mob) {
update_duplication_cooldown(m);
animate(m);
}
/// `MemorySlot.tick` for `LIKED_PLAYER` in the Allay's brain tick
/// (`customServerAiStep`, which `NoAI` skips): a memory with a time to live
/// counts down, and is cleared on the tick after it reaches 0.
fn tick_liked_player(m: &mut Mob) {
if !m.extra.tame {
return;
}
let Some(st) = m.extra.allay.as_mut() else {
return;
};
match st.liked_player_ttl {
Some(ttl) if ttl <= 0 => {
st.liked_player_ttl = None;
m.extra.tame = false;
m.extra.owner = None;
}
Some(ttl) => st.liked_player_ttl = Some(ttl - 1),
None => {}
}
}
/// `Brain.setMemory(LIKED_PLAYER, uuid)`: the memory never expires.
pub fn like_player(m: &mut Mob, uuid: [i32; 4]) {
m.extra.tame = true;
m.extra.owner = Some(uuid);
if let Some(st) = m.extra.allay.as_mut() {
st.liked_player_ttl = None;
}
}
/// `Brain.eraseMemory(LIKED_PLAYER)`.
pub fn forget_liked_player(m: &mut Mob) {
m.extra.tame = false;
m.extra.owner = None;
if let Some(st) = m.extra.allay.as_mut() {
st.liked_player_ttl = None;
}
}
/// `Allay.aiStep`: server-side healing runs independently of the AI gate.
pub fn passive_heal(m: &mut Mob) {
if m.alive() && m.age.is_multiple_of(10) {
@@ -43,8 +43,7 @@ pub fn predict(
{
given.count = 1;
mob.extra.set_main_hand_stack(Some(given));
mob.extra.tame = true;
mob.extra.owner = Some(actor);
super::like_player(mob, actor);
return Some(Interaction::Give);
}
if main_hand
@@ -52,8 +51,7 @@ pub fn predict(
&& let Some(held) = held
{
mob.extra.set_main_hand(None);
mob.extra.tame = false;
mob.extra.owner = None;
super::forget_liked_player(mob);
mob.swing();
return Some(Interaction::Take(held));
}
@@ -0,0 +1,234 @@
//! An Allay's `minecraft:liked_player` memory and its time to live, as
//! `Brain` reads, writes and ticks it (`MemorySlot`): vanilla 26.3 counts a
//! finite memory down on each brain tick and clears it on the tick after 0;
//! `NoAI` runs no brain tick; a memory without a time to live never expires
//! (tools/test_allay_liked_player_parity.py measures the same).
use super::*;
use horizon_core::random::LegacyRandom;
use horizon_world::nbt::Tag;
use std::sync::Arc;
const OWNER: [i32; 4] = [95, 0, 0, 99];
/// The Allay as `/summon` would load it, with the memory absent (`None`),
/// permanent (`Some(None)`) or with a time to live.
fn saved_tag(memory: Option<Option<i64>>, no_ai: bool) -> Tag {
let d = |v: f64| Tag::Double(v.to_bits());
let mut entries = vec![
("id", Tag::String("minecraft:allay".into())),
("Pos", Tag::List(vec![d(8.5), d(200.0), d(8.5)])),
("NoAI", Tag::Byte(i8::from(no_ai))),
("NoGravity", Tag::Byte(1)),
];
if let Some(ttl) = memory {
let mut liked = vec![("value", Tag::IntArray(OWNER.to_vec()))];
if let Some(ttl) = ttl {
liked.push(("ttl", Tag::Long(ttl)));
}
entries.push((
"Brain",
Tag::compound([(
"memories",
Tag::compound([("minecraft:liked_player", Tag::compound(liked))]),
)]),
));
}
Tag::compound(entries)
}
fn load(types: &crate::mobs::MobTypes, tag: &Tag) -> Mob {
let kinds: std::collections::HashMap<String, Arc<crate::mobs::MobType>> = types
.types
.iter()
.map(|(id, kind)| (id.clone(), Arc::new(kind.clone())))
.collect();
crate::entity_nbt::mob_from_nbt(tag, &kinds, 1, None).expect("an allay")
}
/// The saved memory: None when absent, else its value and time to live.
fn saved_memory(m: &Mob, raw: Option<&Tag>) -> Option<(Option<Tag>, Option<i64>)> {
let tag = crate::entity_nbt::mob_to_nbt(m, [95, 0, 0, 1], raw, None, |_| None);
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:liked_player"))?;
let ttl = match liked.get("ttl") {
Some(Tag::Long(t)) => Some(*t),
None => None,
other => panic!("ttl: {other:?}"),
};
Some((liked.get("value").cloned(), ttl))
}
fn value() -> Option<Tag> {
Some(Tag::IntArray(OWNER.to_vec()))
}
/// `Brain.readAdditionalSaveData` keeps the time to live, and the save writes
/// the mob's own memory: with no raw tag at all, and over a raw tag whose
/// time to live the mob has since counted down.
#[test]
#[ignore = "needs prepared resources in .local/horizon-client"]
fn a_loaded_finite_liked_player_saves_its_own_time_to_live() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|w, types| {
for (memory, expected) in [
(Some(Some(2)), Some((value(), Some(2)))),
(Some(Some(0)), Some((value(), Some(0)))),
(Some(None), Some((value(), None))),
(None, None),
] {
let tag = saved_tag(memory, false);
let m = load(types, &tag);
assert_eq!(saved_memory(&m, None), expected, "{memory:?}, no raw tag");
}
let tag = saved_tag(Some(Some(2)), false);
let mut m = load(types, &tag);
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
assert_eq!(
saved_memory(&m, Some(&tag)),
Some((value(), Some(1))),
"stale raw ttl 2"
);
},
);
assert!(ran, "the prepared resources were not found");
}
/// `MemorySlot.tick` in the Allay's brain tick: 2, 1, 0, then gone; a memory
/// at 0 goes on the first tick; `NoAI` keeps it as it was; a permanent one
/// stays. A gone memory no longer makes the Allay like its player.
#[test]
#[ignore = "needs prepared resources in .local/horizon-client"]
fn a_finite_liked_player_expires_only_under_ai() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|w, types| {
let ttl_at = |memory, no_ai| {
let tag = saved_tag(memory, no_ai);
let mut m = load(types, &tag);
let mut seen = vec![saved_memory(&m, None).map(|(_, t)| t)];
for _ in 0..3 {
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
seen.push(saved_memory(&m, None).map(|(_, t)| t));
}
let liked = is_liked_player(&m, Some(crate::mobs::Who::Player), OWNER);
(seen, liked)
};
assert_eq!(
ttl_at(Some(Some(2)), false),
(
vec![Some(Some(2)), Some(Some(1)), Some(Some(0)), None],
false
)
);
assert_eq!(
ttl_at(Some(Some(0)), false),
(vec![Some(Some(0)), None, None, None], false)
);
assert_eq!(ttl_at(Some(Some(2)), true), (vec![Some(Some(2)); 4], true));
assert_eq!(ttl_at(Some(Some(0)), true), (vec![Some(Some(0)); 4], true));
assert_eq!(ttl_at(Some(None), false), (vec![Some(None); 4], true));
assert_eq!(ttl_at(Some(None), true), (vec![Some(None); 4], true));
assert_eq!(ttl_at(None, false), (vec![None; 4], false));
},
);
assert!(ran, "the prepared resources were not found");
}
/// `Allay.mobInteract` sets `LIKED_PLAYER` with `Brain.setMemory`, which
/// never expires, over a loaded finite memory; taking the item back erases
/// it.
#[test]
#[ignore = "needs prepared resources in .local/horizon-client"]
fn a_given_item_makes_the_liked_player_permanent_again() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|w, types| {
let tag = saved_tag(Some(Some(2)), false);
let mut m = load(types, &tag);
let actor = [7, 8, 9, 10];
let stick = crate::items::ItemStack::new("minecraft:stick", 1);
assert!(interaction::predict(&mut m, Some(&stick), true, false, actor).is_some());
let mine = Some(Tag::IntArray(actor.to_vec()));
assert_eq!(saved_memory(&m, Some(&tag)), Some((mine.clone(), None)));
for _ in 0..4 {
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
}
assert_eq!(
saved_memory(&m, Some(&tag)),
Some((mine, None)),
"never expires"
);
assert!(interaction::predict(&mut m, None, true, false, actor).is_some());
assert_eq!(saved_memory(&m, Some(&tag)), None, "erased");
},
);
assert!(ran, "the prepared resources were not found");
}
/// Added controls. `MemorySlot.canExpire` is `ttl != Long.MAX_VALUE`: a
/// memory saved with that time to live never expires and is saved without
/// one. A memory that expired and is given again is permanent. A new owner
/// replaces the old one with no time to live carried over.
#[test]
#[ignore = "needs prepared resources in .local/horizon-client"]
fn never_expiring_readded_and_replaced_liked_players() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|_, _| {},
|w, types| {
let step = |m: &mut Mob| {
crate::mobs::ai::tick(m, w, &mut LegacyRandom::new(1), &mut Vec::new());
};
// Long.MAX_VALUE: permanent.
let tag = saved_tag(Some(Some(i64::MAX)), false);
let mut m = load(types, &tag);
for _ in 0..3 {
step(&mut m);
}
assert_eq!(
saved_memory(&m, Some(&tag)),
Some((value(), None)),
"MAX never expires"
);
// Expired, then given again.
let tag = saved_tag(Some(Some(0)), false);
let mut m = load(types, &tag);
step(&mut m);
assert_eq!(saved_memory(&m, Some(&tag)), None, "expired");
let stick = crate::items::ItemStack::new("minecraft:stick", 1);
let readded = [1, 2, 3, 4];
assert!(interaction::predict(&mut m, Some(&stick), true, false, readded).is_some());
for _ in 0..3 {
step(&mut m);
}
assert_eq!(
saved_memory(&m, Some(&tag)),
Some((Some(Tag::IntArray(readded.to_vec())), None)),
"re-added, permanent"
);
// A finite owner, replaced by another player through take and give.
let tag = saved_tag(Some(Some(2)), false);
let mut m = load(types, &tag);
assert!(interaction::predict(&mut m, Some(&stick), true, false, OWNER).is_some());
assert!(interaction::predict(&mut m, None, true, false, OWNER).is_some());
let other = [5, 6, 7, 8];
assert!(interaction::predict(&mut m, Some(&stick), true, false, other).is_some());
for _ in 0..3 {
step(&mut m);
}
assert_eq!(
saved_memory(&m, None),
Some((Some(Tag::IntArray(other.to_vec())), None))
);
assert!(!is_liked_player(&m, Some(crate::mobs::Who::Player), OWNER));
assert!(is_liked_player(&m, Some(crate::mobs::Who::Player), other));
},
);
assert!(ran, "the prepared resources were not found");
}
+71 -2
View File
@@ -314,14 +314,16 @@ impl Goal for RandomSitting {
MOVE
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// checkExtraStartConditions: not leashed, and no controlling
// passenger, a player's or a mob's (a husk on its back).
// checkExtraStartConditions: not leashed, no controlling passenger
// (a player's or a mob's, a husk on its back), and room for the pose
// it would take (`Camel.canCamelChangePose`).
!m.fluids.in_water
&& pose_time(m) >= 400
&& m.extra.leash.is_none()
&& m.on_ground
&& m.rider.is_none()
&& !super::riders::mob_controlled(m, cx.w)
&& can_change_pose(m, cx.w)
&& m.random.next_int_bound(4).unwrap_or(1) == 0
}
fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool {
@@ -678,4 +680,71 @@ mod tests {
finish_load(&mut m, 5000);
assert!(!refuse_to_move(&m));
}
/// `CamelAi.RandomSitting.checkExtraStartConditions` ends with
/// `body.canCamelChangePose()`. `Camel.canCamelChangePose` is
/// `wouldNotSuffocateAtTargetPose(STANDING)` while it sits: the standing
/// box is 2.375 tall against the sitting pose's 0.945, so a sitting camel
/// under a two-block ceiling has no room to stand and must not try.
#[test]
fn a_camel_changes_pose_only_when_the_other_pose_fits() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
for x in -1..=1 {
for z in -1..=1 {
world.chunks.insert(
(x, z),
std::sync::Arc::new(crate::chunk::Chunk::empty(x, z)),
);
}
}
let stone = registry.default_state("minecraft:stone").unwrap();
for x in -2..=2 {
for z in -2..=2 {
world.set(x, 63, z, stone).unwrap();
}
}
// In the standing camel's box (y 64..66.375) but above the
// sitting one (y 64..64.945): blocks standing only.
world.set(0, 65, 0, stone).unwrap();
},
|w, types| {
let kind = std::sync::Arc::new(types.types["minecraft:camel"].clone());
let sitting_camel = |id: u32, x: f64| {
let mut m = Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 0.5), 0.0);
register(&mut m);
m.extra.camel_sitting = true;
// 20 seconds in the pose (`getPoseTime() >= 20 * 20`).
m.extra.egg_time = (i64::from(m.age) - 401) as i32;
m.on_ground = true;
m.random = horizon_core::random::LegacyRandom::new(1234 + i64::from(id));
m
};
// Under the ceiling, and out in the open.
let mut blocked = sitting_camel(1, 0.5);
let mut open = sitting_camel(2, 5.5);
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goal = RandomSitting;
// The random gate fires within a few asks when it is reached.
for _ in 0..200 {
assert!(
!goal.can_use(&mut blocked, &mut cx),
"a sitting camel stood up into the ceiling"
);
}
assert!(
(0..200).any(|_| goal.can_use(&mut open, &mut cx)),
"with room to stand the goal should fire"
);
},
);
let _ = ran;
}
}
+50 -2
View File
@@ -422,8 +422,9 @@ impl Goal for CubeKeepJumping {
fn flags(&self) -> u8 {
JUMP | MOVE
}
fn can_use(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
true
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
// `CubeMobKeepOnJumpingGoal.canUse`: not while it is a passenger.
!m.extra.passenger
}
fn tick(&mut self, m: &mut Mob, _: &mut Cx) {
set_wanted(m, 1.0);
@@ -590,3 +591,50 @@ mod golem_touch_tests {
);
}
}
#[cfg(test)]
mod keep_jumping_tests {
use super::*;
use crate::mobs::world::with_test_world;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// `CubeMobKeepOnJumpingGoal.canUse` is `!isPassenger()`: a cube riding
/// a boat or another mob holds still, while one on its own keeps hopping.
#[test]
fn a_cube_that_rides_something_does_not_keep_jumping() {
let mut starts = None;
let ran = with_test_world(&[], |w, types| {
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
starts = Some([true, false].map(|passenger| {
let mut m = Mob::new(
1,
Arc::new(types.types["minecraft:slime"].clone()),
Vec3::ZERO,
0.0,
);
m.extra.passenger = passenger;
m.ai.goals.add(5, CubeKeepJumping);
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
let running = goals.is_running("CubeKeepJumping");
m.ai.goals = goals;
running
}));
});
if !ran {
return;
}
assert_eq!(
starts,
Some([false, true]),
"a cube riding anything must not start CubeMobKeepOnJumpingGoal"
);
}
}
+82 -2
View File
@@ -36,8 +36,12 @@ pub struct Death<'a> {
pub loot: &'a LootData,
/// The killing `DamageSource`.
pub damage: Option<LootDamage>,
/// The player who last hurt the mob, when `lastHurtByPlayerMemoryTime > 0`.
/// `LAST_DAMAGE_PLAYER`: the player who last hurt the mob, when they are
/// still in the world. Gone after they leave, even while the memory lasts.
pub player: Option<LootEntity>,
/// `LivingEntity.lastHurtByPlayerMemoryTime > 0` (`playerKilled`):
/// experience and equipment follow this, not whether `player` is present.
pub player_killed: bool,
/// That player's luck.
pub luck: f32,
/// The entity the mob rides.
@@ -430,7 +434,7 @@ pub fn die(
skulls = items.iter().any(|i| !i.is_empty());
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
}
let killed_by_player = d.player.is_some();
let killed_by_player = d.player_killed;
let s = m.species();
// Fox.dropAllDeathLoot: whatever it holds in its mouth comes out first.
if s == Species::Fox
@@ -658,6 +662,7 @@ mod death_loot_table_tests {
loot: &loot,
damage: None,
player: None,
player_killed: false,
luck: 0.0,
vehicle: None,
mob_drops: true,
@@ -690,6 +695,7 @@ mod slot_tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::Vec3;
use horizon_world::loot::PackOverlay;
use std::sync::Arc;
fn mob(id: &str, species: Species) -> Mob {
@@ -730,4 +736,78 @@ mod slot_tests {
assert_eq!(body.item, "minecraft:iron_helmet");
assert!(slot_item(&zombie, "head").is_none());
}
/// `LivingEntity.dropExperience` and `Mob.dropCustomDeathLoot`: the
/// memory timer (`playerKilled`) awards experience and equipment even
/// when `LAST_DAMAGE_PLAYER` is gone (the player left).
#[test]
fn experience_and_equipment_follow_the_hurt_memory_not_the_player_entity() {
let root =
std::env::temp_dir().join(format!("horizon-guest-left-xp-{}", std::process::id()));
std::fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap();
std::fs::write(root.join("items.json"), "{}").unwrap();
let loot = LootData::load(&root, &PackOverlay::default()).unwrap();
let death = |player_killed| Death {
loot: &loot,
damage: None,
player: None,
player_killed,
luck: 0.0,
vehicle: None,
mob_drops: true,
world_seed: 0,
};
let zombie = |helmet: bool| {
let mut m = mob("minecraft:zombie", Species::Zombie);
if helmet {
m.extra.head = Some("minecraft:iron_helmet".into());
m.extra.drop_chances = Some(Box::new([1.0; 8]));
}
m
};
let orbs = |out: &[Drop]| out.iter().filter(|d| matches!(d, Drop::Orb(_))).count();
let helmet = |out: &[Drop]| {
out.iter().any(|d| match d {
Drop::Item(s) => s.item == "minecraft:iron_helmet",
Drop::Stack(s) => &*s.item == "minecraft:iron_helmet",
_ => false,
})
};
let mut m = zombie(false);
let (out, _) = die(
&mut m,
&death(true),
&mut RandomSequences::default(),
&mut LegacyRandom::new(7),
);
assert!(orbs(&out) > 0, "memory timer awards experience: {out:?}");
let mut m = zombie(false);
let (out, _) = die(
&mut m,
&death(false),
&mut RandomSequences::default(),
&mut LegacyRandom::new(7),
);
assert_eq!(orbs(&out), 0, "no timer, no experience: {out:?}");
let mut m = zombie(true);
let (out, _) = die(
&mut m,
&death(true),
&mut RandomSequences::default(),
&mut LegacyRandom::new(7),
);
assert!(helmet(&out), "memory timer drops equipment: {out:?}");
let mut m = zombie(true);
let (out, _) = die(
&mut m,
&death(false),
&mut RandomSequences::default(),
&mut LegacyRandom::new(7),
);
assert!(!helmet(&out), "no timer, no equipment: {out:?}");
}
}
@@ -710,6 +710,7 @@ mod tests {
loot: &loot,
damage: None,
player: None,
player_killed: false,
luck: 0.0,
vehicle: None,
mob_drops,
+54 -1
View File
@@ -342,7 +342,9 @@ impl Goal for HappyGhastFloat {
!on_still_timeout(m) && Float.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
Float.can_use(m, cx)
// `HappyGhastFloatGoal` inherits `Goal.canContinueToUse`, which asks
// the overridden `canUse`: the still timeout applies mid-float too.
self.can_use(m, cx)
}
fn every_tick(&self) -> bool {
true
@@ -705,3 +707,54 @@ mod tests {
assert!(m.ai.nav.can_float);
}
}
#[cfg(test)]
mod still_float_tests {
use super::*;
use crate::mobs::{Species, world::with_test_world};
/// `HappyGhastFloatGoal` inherits `Goal.canContinueToUse` (`canUse`), so
/// the still-timeout guard is re-checked on a running float goal too: a
/// ghast a player stands on stops floating instead of hopping in water.
#[test]
fn a_happy_ghast_on_a_still_timeout_stops_floating() {
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = Species::HappyGhast;
let mut m = Mob::new(
1,
std::sync::Arc::new(t),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
m.fluids.water = 1.0;
let mut stopped = None;
let ran = with_test_world(&[], |w, _| {
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
assert!(
goals.is_running("HappyGhastFloatGoal"),
"an unburdened happy ghast in deep water floats"
);
// `isOnStillTimeout`: an adult with serverStillTimeout > 0.
m.extra.counter = 1;
goals.tick(&mut m, &mut cx);
stopped = Some(!goals.is_running("HappyGhastFloatGoal"));
m.ai.goals = goals;
});
if !ran {
return;
}
assert_eq!(
stopped,
Some(true),
"a happy ghast on a still timeout must stop floating"
);
}
}
+243 -13
View File
@@ -24,6 +24,7 @@ use std::collections::HashMap;
const DASH_COOLDOWN: i32 = 40;
const TIME_BETWEEN_ATTACKS: i32 = 80;
const ANGER_DURATION: i32 = 400;
const ATTACK_TARGET_COOLDOWN: &str = "minecraft:attack_target_cooldown";
/// `ZombieNautilusVariants`: the variant keys in index order.
pub const VARIANTS: [&str; 2] = ["temperate", "warm"];
@@ -72,6 +73,7 @@ pub fn register(m: &mut Mob) {
/// first looks for prey) and the zombie's variant by biome.
pub fn finalize(m: &mut Mob, warm_ocean: bool) {
m.extra.counter = 2400 + m.random.next_int_bound(1201).unwrap_or(0);
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
if zombie(m) {
m.extra.variant = u8::from(warm_ocean);
}
@@ -315,9 +317,18 @@ pub fn ai_step(
if m.fluids.in_water {
bubbles(m, events);
}
// CountDownCooldownTicks and the ANGRY_AT expiry.
// Brain's saved slot lifetime is independent of the existing attack counter.
tick_attack_memory(m);
// CountDownCooldownTicks and the ANGRY_AT expiry. Keep the legacy
// counter/target policy, including after the saved slot expires.
if m.extra.counter > 0 {
m.extra.counter -= 1;
if m.extra.nautilus_attack_cooldown_ttl.is_some() {
// CountDownCooldownTicks.tick writes with Brain.setMemory (permanent).
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
}
} else {
m.extra.nautilus_attack_cooldown_ttl = None;
}
if m.extra.counter2 > 0 {
m.extra.counter2 -= 1;
@@ -329,6 +340,16 @@ pub fn ai_step(
m.ai.target = find_target(m, w, level_random);
}
}
/// `MemorySlot.tick`: expire before decrementing a finite saved lifetime.
/// The existing NoAI gate controls this class step; this does not replace
/// the legacy integer attack counter or its selection policy.
fn tick_attack_memory(m: &mut Mob) {
if let Some(ttl) = m.extra.nautilus_attack_cooldown_ttl
&& ttl != i64::MAX
{
m.extra.nautilus_attack_cooldown_ttl = if ttl <= 0 { None } else { Some(ttl - 1) };
}
}
/// `spawnBubbles`: more as it swims faster, from behind its mouth.
fn bubbles(m: &mut Mob, events: &mut Vec<MobEvent>) {
let v = m.velocity;
@@ -402,6 +423,7 @@ fn find_target(
}
// The cooldown and the coin come from the level's random (`level.getRandom()`).
m.extra.counter = 2400 + level_random.next_int_bound(1201).unwrap_or(0);
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
if level_random.next_float() < 0.5 {
return None;
}
@@ -632,15 +654,16 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
}
}
map.insert("equipment".into(), Tag::Compound(equipment));
let memory = m.extra.nautilus_attack_cooldown_ttl.map(|ttl| {
let mut fields = vec![("value", Tag::Int(m.extra.counter))];
if ttl != i64::MAX {
fields.push(("ttl", Tag::Long(ttl)));
}
(ATTACK_TARGET_COOLDOWN, Tag::compound(fields))
});
map.insert(
"Brain".into(),
Tag::compound([(
"memories",
Tag::compound([(
"minecraft:attack_target_cooldown",
Tag::compound([("value", Tag::Int(m.extra.counter))]),
)]),
)]),
Tag::compound([("memories", Tag::compound(memory))]),
);
}
/// `TamableAnimal.readAdditionalSaveData`: Sitting is independent of Owner.
@@ -658,13 +681,17 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
.and_then(|e| e.get("saddle"))
.and_then(crate::save::item_from_nbt);
m.set_saddle(saddle);
if let Some(Tag::Int(v)) = tag
let memory = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:attack_target_cooldown"))
.and_then(|b| b.get("value"))
{
m.extra.counter = *v;
.and_then(|b| b.get(ATTACK_TARGET_COOLDOWN));
if let Some(Tag::Int(value)) = memory.and_then(|m| m.get("value")) {
m.extra.counter = *value;
m.extra.nautilus_attack_cooldown_ttl =
Some(crate::save::number(memory.and_then(|m| m.get("ttl"))).unwrap_or(i64::MAX));
} else {
m.extra.counter = 0;
m.extra.nautilus_attack_cooldown_ttl = None;
}
}
@@ -892,3 +919,206 @@ mod hostile_target_tests {
assert!(beyond.iter().all(Option::is_none), "{beyond:?}");
}
}
#[cfg(test)]
mod cooldown_memory_tests {
use super::*;
use horizon_world::nbt::Tag;
use std::sync::Arc;
const MEMORY: &str = "minecraft:attack_target_cooldown";
fn raw(id: &str, state: Option<(i32, Option<i64>)>, no_ai: bool) -> Tag {
let memory = state.map(|(value, ttl)| {
let mut entries = vec![("value", Tag::Int(value))];
if let Some(ttl) = ttl {
entries.push(("ttl", Tag::Long(ttl)));
}
Tag::compound(entries)
});
let memories = Tag::compound(memory.into_iter().map(|m| (MEMORY, m)));
let mut fields = vec![
("id", Tag::String(id.into())),
(
"Pos",
Tag::List(vec![
Tag::Double(4.5f64.to_bits()),
Tag::Double(3.0f64.to_bits()),
Tag::Double(4.5f64.to_bits()),
]),
),
("NoAI", Tag::Byte(i8::from(no_ai))),
("NoGravity", Tag::Byte(1)),
("Health", Tag::Float(10.0f32.to_bits())),
("PersistenceRequired", Tag::Byte(1)),
("Brain", Tag::compound([("memories", memories)])),
];
if !no_ai {
fields.push(("Owner", Tag::IntArray(vec![1, 2, 3, 4])));
}
Tag::compound(fields)
}
fn memory(tag: &Tag) -> Option<&Tag> {
tag.get("Brain")?.get("memories")?.get(MEMORY)
}
fn expected(state: Option<(i32, Option<i64>)>) -> Option<Tag> {
state.map(|(value, ttl)| {
let mut entries = vec![("value", Tag::Int(value))];
if let Some(ttl) = ttl.filter(|&ttl| ttl != i64::MAX) {
entries.push(("ttl", Tag::Long(ttl)));
}
Tag::compound(entries)
})
}
fn saved(m: &Mob, stale: Option<&Tag>) -> Tag {
crate::entity_nbt::mob_to_nbt(m, [140, 1, 0, 7], stale, None, |_| None)
}
#[test]
fn decoded_cooldown_presence_ttl_and_model_mutation_save_without_raw() {
let mut failures = Vec::new();
for (id, species) in [
("minecraft:nautilus", Species::Nautilus),
("minecraft:zombie_nautilus", Species::ZombieNautilus),
] {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.id = id.into();
kind.species = species;
let types = std::collections::HashMap::from([(id.to_owned(), Arc::new(kind))]);
for state in [
None,
Some((0, None)),
Some((37, None)),
Some((37, Some(5))),
Some((37, Some(i64::MAX))),
] {
let tag = raw(id, state, true);
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 7, None)
.expect("actual class decode");
assert_eq!(m.species(), species);
assert!(m.no_ai && m.no_gravity && m.alive());
assert_eq!(m.health, 10.0);
assert_eq!(m.extra.counter, state.map_or(0, |s| s.0));
for source in [None, Some(&tag)] {
let out = saved(&m, source);
if memory(&out).cloned() != expected(state) {
failures.push(format!(
"{id} {state:?} raw={} saved={:?}",
source.is_some(),
memory(&out)
));
}
}
if memory(&saved(&m.clone(), None)).cloned() != expected(state) {
failures.push(format!("{id} cloned model lost memory presence/lifetime"));
}
if state.is_some() {
m.extra.counter = 41; // The live value changes; the raw compound still holds the old value.
let want = state.map(|(_, ttl)| (41, ttl));
if memory(&saved(&m, Some(&tag))).cloned() != expected(want) {
failures.push(format!("{id} stale raw value/TTL replaced modeled state"));
}
}
// A later valid empty memory map clears the class memory, not merely its value.
from_nbt(&mut m, &raw(id, None, true));
if memory(&saved(&m, Some(&tag))).is_some() {
failures.push(format!("{id} valid empty map retained stale memory"));
}
}
}
assert!(
failures.is_empty(),
"saved nautilus cooldown state mismatches: {failures:?}"
);
}
#[test]
#[ignore = "needs actual prepared mob data; must execute, not skip"]
fn actual_mob_ticks_retain_noai_lifetime_and_apply_active_memory_boundaries() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("actual prepared resources"),
);
let mut failures = Vec::new();
let ran = crate::mobs::world::with_resource_test_world(
&root,
&[],
true,
true,
|world, registry| {
let water = registry.default_state("minecraft:water").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
for x in 0..16 {
for z in 0..16 {
world.set(x, 0, z, stone).unwrap();
for y in 1..6 {
world.set(x, y, z, water).unwrap();
}
}
}
},
|world, types| {
let types: std::collections::HashMap<_, _> = types
.types
.iter()
.map(|(id, kind)| (id.clone(), Arc::new(kind.clone())))
.collect();
for id in ["minecraft:nautilus", "minecraft:zombie_nautilus"] {
for (no_ai, state) in [
(true, None),
(true, Some((0, None))),
(true, Some((37, None))),
(true, Some((37, Some(5)))),
(true, Some((37, Some(i64::MAX)))),
(false, Some((37, Some(5)))),
(false, Some((37, Some(1)))),
(false, Some((37, Some(0)))),
] {
let tag = raw(id, state, no_ai);
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 7, None).unwrap();
m.attrs.speed = 0.0;
let mut level_random = horizon_core::random::LegacyRandom::new(140);
let mut events = Vec::new();
for elapsed in 0..=6 {
if elapsed > 0 {
let mut w = world.clone();
w.game_time += elapsed;
assert!(m.tick(&w, &mut level_random, &mut events));
assert!(m.fluids.in_water && m.fluids.eye_in_water);
assert!(m.ai.target.is_none(), "safe no-attack control");
assert!(
!events.iter().any(|e| matches!(
e,
MobEvent::AttackPlayer(..) | MobEvent::AttackMob { .. }
)),
"no attack fixture"
);
}
assert_eq!(m.health, 10.0);
assert_eq!(m.no_ai, no_ai);
assert!(m.no_gravity);
let want = if no_ai || elapsed == 0 {
state
} else if state.unwrap().1 == Some(0) {
None
} else {
Some((37 - elapsed as i32, None))
};
if memory(&saved(&m, None)).cloned() != expected(want) {
failures.push(format!(
"{id} NoAI={no_ai} {state:?} tick={elapsed}: {:?}",
memory(&saved(&m, None))
));
}
}
}
}
},
);
assert!(ran, "actual prepared world route must execute");
assert!(
failures.is_empty(),
"nautilus cooldown tick/save mismatches: {failures:?}"
);
}
}
+86 -6
View File
@@ -317,6 +317,43 @@ struct Target {
pos: [i32; 3],
}
/// Reusable allocation buffers for pathfinding queries.
#[derive(Default)]
pub struct FinderBuffers {
pub nodes: Vec<Node>,
pub index: FxHashMap<(i32, i32, i32), u32>,
pub heap: Vec<u32>,
pub types: FxHashMap<(i32, i32, i32), PathType>,
pub statics: std::cell::RefCell<FxHashMap<(i32, i32, i32), PathType>>,
pub collisions: FxHashMap<[u64; 6], bool>,
}
impl FinderBuffers {
pub fn new() -> Self {
Self {
nodes: Vec::with_capacity(256),
index: FxHashMap::default(),
heap: Vec::with_capacity(256),
types: FxHashMap::default(),
statics: std::cell::RefCell::new(FxHashMap::default()),
collisions: FxHashMap::default(),
}
}
pub fn clear(&mut self) {
self.nodes.clear();
self.index.clear();
self.heap.clear();
self.types.clear();
self.statics.borrow_mut().clear();
self.collisions.clear();
}
}
thread_local! {
static TLS_BUFFERS: std::cell::RefCell<FinderBuffers> = std::cell::RefCell::new(FinderBuffers::new());
}
/// One pathfinding query: the node arena, open set and per-query caches.
pub struct Finder<'a, 'w> {
w: &'a MobWorld<'w>,
@@ -335,18 +372,61 @@ pub struct Finder<'a, 'w> {
entity_depth: i32,
}
impl<'a, 'w> Drop for Finder<'a, 'w> {
fn drop(&mut self) {
TLS_BUFFERS.with(|cell| {
if let Ok(mut buf) = cell.try_borrow_mut() {
self.nodes.clear();
self.index.clear();
self.heap.clear();
self.types.clear();
self.statics.borrow_mut().clear();
self.collisions.clear();
buf.nodes = std::mem::take(&mut self.nodes);
buf.index = std::mem::take(&mut self.index);
buf.heap = std::mem::take(&mut self.heap);
buf.types = std::mem::take(&mut self.types);
buf.statics = std::mem::take(&mut self.statics);
buf.collisions = std::mem::take(&mut self.collisions);
}
});
}
}
impl<'a, 'w> Finder<'a, 'w> {
pub fn new(w: &'a MobWorld<'w>, mob: &'a PathMob, eval: Evaluator) -> Self {
let (nodes, index, heap, types, statics, collisions) = TLS_BUFFERS.with(|cell| {
if let Ok(mut buf) = cell.try_borrow_mut() {
buf.clear();
(
std::mem::take(&mut buf.nodes),
std::mem::take(&mut buf.index),
std::mem::take(&mut buf.heap),
std::mem::take(&mut buf.types),
std::mem::take(&mut buf.statics),
std::mem::take(&mut buf.collisions),
)
} else {
(
Vec::with_capacity(128),
FxHashMap::default(),
Vec::with_capacity(128),
FxHashMap::default(),
std::cell::RefCell::new(FxHashMap::default()),
FxHashMap::default(),
)
}
});
Self {
w,
mob,
eval,
nodes: Vec::new(),
index: FxHashMap::default(),
heap: Vec::new(),
types: FxHashMap::default(),
statics: Default::default(),
collisions: FxHashMap::default(),
nodes,
index,
heap,
types,
statics,
collisions,
entity_width: (mob.width + 1.0).floor() as i32,
entity_height: (mob.height + 1.0).floor() as i32,
entity_depth: (mob.width + 1.0).floor() as i32,
+1
View File
@@ -2298,6 +2298,7 @@ mod crossbow_tests {
loot: &loot,
damage: None,
player: None,
player_killed: false,
luck: 0.0,
vehicle: None,
mob_drops: true,
+57
View File
@@ -1077,6 +1077,11 @@ impl Goal for CatTempt {
!m.extra.tame && self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `CatTemptGoal` has no `canContinueToUse`: `TemptGoal`'s calls
// `canUse()` again, so `!isTame()` is re-checked mid-tempt.
if m.extra.tame {
return false;
}
// CatTemptGoal.canScare: a selected player that is the tempter is not scared.
let picked = self.1.is_some() && self.1 == cx.w.player.as_ref().map(|p| p.uuid);
let scare = self.0.can_scare;
@@ -2034,3 +2039,55 @@ mod universal_anger_tests {
});
}
}
#[cfg(test)]
mod cat_tempt_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use std::sync::Arc;
/// `CatTemptGoal` overrides `canUse` with `!cat.isTame()`, and inherits
/// `TemptGoal.canContinueToUse`, which calls `canUse()` again: taming the
/// cat mid-tempt ends the goal. Horizon's `CatTempt::can_continue` ran the
/// base tempt continue, which never asked whether the cat was tamed.
#[test]
fn a_tamed_cat_stops_being_tempted() {
let mut outcomes = None;
let ran = with_test_world(&[], |w, types| {
let kind = Arc::new(types.types["minecraft:cat"].clone());
let food = kind.food.clone();
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut world = w.clone();
let mut p = test_player(Vec3::new(2.5, 64.0, 0.5));
p.main_hand = Some("minecraft:cod".into());
world.player = Some(p);
m.ai.goals.add(
4,
CatTempt(super::super::goals::Tempt::new(0.6, food, true), None),
);
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
let started = goals.is_running("CatTempt");
m.extra.tame = true;
goals.tick(&mut m, &mut cx);
outcomes = Some((started, goals.is_running("CatTempt")));
m.ai.goals = goals;
});
if !ran {
return;
}
assert_eq!(
outcomes,
Some((true, false)),
"a wild cat is tempted; once tamed the same goal must stop"
);
}
}
+64 -2
View File
@@ -180,8 +180,19 @@ impl Goal for TradeWithPlayer {
fn flags(&self) -> u8 {
MOVE | JUMP
}
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
m.extra.flag2 && m.alive() && !m.fluids.in_water && m.on_ground
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `TradeWithPlayerGoal.canUse`: alive, dry, on the ground, not hurt
// recently, and within four blocks of the trading player.
m.extra.flag2
&& m.alive()
&& m.hurt_time <= 0
&& !m.fluids.in_water
&& m.on_ground
&& cx
.w
.player
.as_ref()
.is_some_and(|p| m.distance_sqr(p.position) <= 16.0)
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
m.nav_stop();
@@ -393,3 +404,54 @@ mod tests {
});
}
}
#[cfg(test)]
mod trade_start_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use std::sync::Arc;
/// `TradeWithPlayerGoal.canUse`: alive, dry, on the ground, not hurt
/// recently and within four blocks of the trading player. A hurt trader,
/// or one whose customer walked away, must not start the stand-still goal.
#[test]
fn a_trader_stops_trading_when_hurt_or_out_of_reach() {
let mut outcomes = Vec::new();
let ran = with_test_world(&[], |w, types| {
let kind = Arc::new(types.types["minecraft:wandering_trader"].clone());
for (label, hurt_time, distance) in [
("trading", 0, 2.0),
("hurt", 5, 2.0),
("out of reach", 0, 5.0),
] {
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.flag2 = true;
m.on_ground = true;
m.hurt_time = hurt_time;
m.ai.goals.add(1, TradeWithPlayer);
let mut world = w.clone();
world.player = Some(test_player(Vec3::new(0.5 + distance, 64.0, 0.5)));
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
outcomes.push((label, goals.is_running("TradeWithPlayer")));
m.ai.goals = goals;
}
});
if !ran {
return;
}
assert_eq!(
outcomes,
vec![("trading", true), ("hurt", false), ("out of reach", false)],
"TradeWithPlayerGoal starts only for an unhurt trader within four blocks"
);
}
}
+64 -1
View File
@@ -324,7 +324,11 @@ impl Goal for DrownedTridentAttack {
self.0.can_use(m, cx) && m.extra.main_hand.as_deref() == Some("minecraft:trident")
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_continue(m, cx)
// `RangedAttackGoal.canContinueToUse` asks `canUse()` again: the
// trident test is re-checked, and the goal only falls back to a target
// it is still walking towards once the trident is gone.
self.0.can_use(m, cx)
&& (m.extra.main_hand.as_deref() == Some("minecraft:trident") || !m.ai.nav.is_done())
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.start(m, cx);
@@ -1612,3 +1616,62 @@ mod wither_arrow_tests {
});
}
}
#[cfg(test)]
mod trident_continue_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// `DrownedTridentAttackGoal` overrides `canUse` with the trident test and
/// inherits `RangedAttackGoal.canContinueToUse`, which calls `canUse()`
/// again. Losing the trident mid-attack must therefore stop the goal;
/// Horizon's continue asked only the base ranged goal.
#[test]
fn a_drowned_that_loses_its_trident_stops_the_trident_attack() {
let mut outcomes = None;
let ran = with_test_world(&[], |w, types| {
let kind = Arc::new(types.types["minecraft:drowned"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.set_main_hand(Some("minecraft:trident".into()));
m.ai.target = Some(Who::Player);
let mut world = w.clone();
world.player = Some(test_player(Vec3::new(3.5, 64.0, 0.5)));
m.ai.goals.add(
2,
DrownedTridentAttack(crate::mobs::witch::RangedAttack::new(
1.0,
40,
40,
10.0,
drowned_throw,
)),
);
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
let started = goals.is_running("DrownedTridentAttackGoal");
// No path left to follow: only the trident used to keep it going.
m.nav_stop();
m.extra.set_main_hand(None);
goals.tick(&mut m, &mut cx);
outcomes = Some((started, goals.is_running("DrownedTridentAttackGoal")));
m.ai.goals = goals;
});
if !ran {
return;
}
assert_eq!(
outcomes,
Some((true, false)),
"a trident attack starts for a drowned holding one and stops when it is gone"
);
}
}
+38 -4
View File
@@ -171,6 +171,25 @@ impl MobWorld<'_> {
self.clear_between_for(None, from, to, fluids_block)
}
/// The same for an entity whose `getY()` is `feet_y`: `ClipContext` hands
#[inline]
fn cached_block<'b>(
&'b self,
cached: &mut Option<((i32, i32), Option<&'b crate::chunk::Chunk>)>,
x: i32,
y: i32,
z: i32,
) -> BlockStateId {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match *cached {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
*cached = Some(((cx, cz), c));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
}
/// its `CollisionContext.of(entity)` to every block, so scaffolding is
/// solid for a viewer above it and open for one below.
fn clear_between_for(
@@ -186,8 +205,9 @@ impl MobWorld<'_> {
binding: self.collision,
subject: None,
};
let mut cached = None;
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
if fluids_block && self.facts.get(state).fluid != Fluid::None {
return Some(());
}
@@ -222,7 +242,19 @@ impl MobWorld<'_> {
binding: self.collision,
subject: None,
};
let state = |x, y, z| self.world.block(x, y, z);
let cached = std::cell::Cell::new(None);
let state = |x, y, z| {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match cached.get() {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
cached.set(Some(((cx, cz), c)));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
};
visual_clip(self.facts, self.collision, &mut scene, &state, from, to)
}
/// Level.clip with COLLIDER shapes and no fluids: where the segment
@@ -233,8 +265,9 @@ impl MobWorld<'_> {
binding: self.collision,
subject: None,
};
let mut cached = None;
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
if self.facts.is_air(state) {
return None;
}
@@ -254,8 +287,9 @@ impl MobWorld<'_> {
binding: self.collision,
subject: None,
};
let mut cached = None;
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
if self.facts.is_air(state) {
return None;
}
+2
View File
@@ -41,6 +41,8 @@ pub use crate::game::{
NativeStructureTemplate, NativeWorldDraw,
};
pub use crate::gamerules::Value as GameRuleValue;
/// What a mod asks of the terrain's picture each frame (`native_world_hold`). API @API:world_hold@.
pub use crate::gfx::WorldHold;
pub use crate::gfx::native_render::{
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
wgpu,
+130 -100
View File
@@ -1693,7 +1693,6 @@ fn decode_column(
}
}
let air = registry.default_state("minecraft:air")?.0;
let mut cache: HashMap<String, u32> = HashMap::new();
let Some(Tag::List(sections)) = tag.get("sections") else {
return Some(chunk);
};
@@ -1709,30 +1708,7 @@ fn decode_column(
{
let palette: Vec<u32> = palette
.iter()
.map(|p| {
let state = BlockState::from_nbt(p).ok();
let key = state
.as_ref()
.map(|s| format!("{}{:?}", s.name, s.properties))
.unwrap_or_default();
*cache.entry(key).or_insert_with(|| {
state
.and_then(|s| {
registry.id(&s).or_else(|| {
// Missing properties take the block's defaults.
let default = registry.default_state(&s.name)?;
let mut full = registry.state(default)?.clone();
for (k, v) in &s.properties {
if full.properties.contains_key(k) {
full.properties.insert(k.clone(), v.clone());
}
}
registry.id(&full).or(Some(default))
})
})
.map_or(air, |id| id.0)
})
})
.map(|p| registry.saved_state_id(p).map_or(air, |id| id.0))
.collect();
let target = Arc::make_mut(&mut chunk.sections[si]);
let data = match states.get("data") {
@@ -1934,70 +1910,91 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
};
layers[si] = (layer("SkyLight"), layer("BlockLight"));
}
// Uniform saved layers need no expanded allocation. Both nibbles of
// each byte must match, not merely the bytes across the layer.
// A saved layer is already the resident `Light::Sky`/`Light::Block`
// layer: byte `i` holds block `2i` in its low nibble and `2i + 1` in its
// high one. Both nibbles of each byte must match for a layer to be
// uniform; only then is the whole section one value.
let uniform_layer = |layer: &[u8]| {
let first = layer[0];
(first & 15 == first >> 4 && layer.iter().all(|&v| v == first)).then_some(first & 15)
};
let copy_layer = |layer: &[u8]| {
let mut out = Box::new([0u8; 2048]);
out.copy_from_slice(layer);
out
};
let mut above = [if sky_light { 15 } else { 0 }; 256];
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
let sky = sky.filter(|_| sky_light);
let sky_uniform = sky.map_or_else(
|| above.iter().all(|&v| v == above[0]).then_some(above[0]),
uniform_layer,
);
let block_uniform = block.map_or(Some(0), uniform_layer);
let sky_uniform = sky.and_then(uniform_layer);
let sky_varies = match sky {
Some(_) => sky_uniform.is_none(),
None => above.iter().any(|&v| v != above[0]),
};
let block_uniform = block.and_then(uniform_layer);
let block_varies = block.is_some() && block_uniform.is_none();
let sky_first = sky.map_or(above[0], |layer| layer[0] & 15);
let block_first = block.map_or(0, |layer| layer[0] & 15);
let section = Arc::make_mut(&mut chunk.sections[si]);
if let (Some(s), Some(b)) = (sky_uniform, block_uniform) {
section.light = Light::Uniform(s << 4 | b);
above.fill(s);
} else {
let mut data = Box::new([0u8; 4096]);
match (sky, block) {
(Some(sky), Some(block)) => {
for ((pair, &s), &b) in
data.as_chunks_mut::<2>().0.iter_mut().zip(sky).zip(block)
{
pair[0] = s << 4 | (b & 15);
pair[1] = (s & 0xf0) | (b >> 4);
section.compact();
// The saved layer decides the resident variant directly: expand
// only when both nibbles vary (which keeps `Light::Full`).
section.light = match (sky_varies, block_varies) {
(false, false) => Light::Uniform(sky_first << 4 | block_first),
(false, true) => Light::Block(copy_layer(block.expect("block layer")), sky_first),
(true, false) => {
let layer = match sky {
Some(layer) => copy_layer(layer),
// No stored sky: the same 256-byte bottom row over and
// over, which is exactly `Light::packed`'s layer here.
None => {
let mut layer = Box::new([0u8; 2048]);
for row in layer.as_chunks_mut::<128>().0 {
for (out, v) in
row.iter_mut().zip(above.as_chunks::<2>().0.iter().cycle())
{
*out = v[0] | v[1] << 4;
}
}
layer
}
}
(Some(sky), None) => {
for (pair, &s) in data.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
pair[0] = s << 4;
pair[1] = s & 0xf0;
}
}
(None, Some(block)) => {
for (i, (pair, &b)) in data
.as_chunks_mut::<2>()
.0
.iter_mut()
.zip(block)
.enumerate()
{
pair[0] = above[(i * 2) & 255] << 4 | (b & 15);
pair[1] = above[(i * 2 + 1) & 255] << 4 | (b >> 4);
}
}
(None, None) => {
for layer in data.as_chunks_mut::<256>().0 {
for (v, &s) in layer.iter_mut().zip(&above) {
*v = s << 4;
};
Light::Sky(layer, block_first)
}
(true, true) => {
let mut data = Box::new([0u8; 4096]);
match (sky, block) {
(Some(sky), Some(block)) => {
for ((pair, &s), &b) in
data.as_chunks_mut::<2>().0.iter_mut().zip(sky).zip(block)
{
pair[0] = s << 4 | (b & 15);
pair[1] = (s & 0xf0) | (b >> 4);
}
}
(None, Some(block)) => {
for (i, (pair, &b)) in data
.as_chunks_mut::<2>()
.0
.iter_mut()
.zip(block)
.enumerate()
{
pair[0] = above[(i * 2) & 255] << 4 | (b & 15);
pair[1] = above[(i * 2 + 1) & 255] << 4 | (b >> 4);
}
}
_ => unreachable!("both light kinds vary"),
}
Light::Full(data)
}
if let Some(sky) = sky {
for (pair, &s) in above.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
pair[0] = s & 15;
pair[1] = s >> 4;
}
};
if let Some(sky) = sky {
for (pair, &s) in above.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
pair[0] = s & 15;
pair[1] = s >> 4;
}
section.light = Light::Full(data);
}
section.compact();
}
chunk.lit = true;
chunk.light_loaded = true;
@@ -6959,6 +6956,34 @@ mod tests {
assert_eq!(nibbles(&light, false)[0], 0x23);
assert_eq!(nibbles(&light, true)[0], 0x5F);
}
/// The independent reference: parse the saved layers and let
/// `crate::light::light_from_layers` expand and repack them.
fn reference(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return;
}
let Some(Tag::List(sections)) = tag.get("sections") else {
return;
};
let mut layers: Vec<(Option<&[u8]>, Option<&[u8]>)> = vec![(None, None); SECTIONS];
for section in sections {
let Some(y) = number(section.get("Y")) else {
continue;
};
let si = y as i32 - (MIN_Y >> 4);
if !(0..SECTIONS as i32).contains(&si) {
continue;
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(&a[..]),
_ => None,
};
layers[si as usize] = (layer("SkyLight"), layer("BlockLight"));
}
let above = [if sky_light { 15 } else { 0 }; 256];
crate::light::light_from_layers(chunk, &layers, above, sky_light);
}
#[test]
fn stored_light_loads_with_vanilla_defaults() {
let mut sky = vec![0u8; 2048];
@@ -7007,32 +7032,6 @@ mod tests {
}
#[test]
fn saved_light_matches_reference_for_layers_and_metadata() {
fn reference(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return;
}
let Some(Tag::List(sections)) = tag.get("sections") else {
return;
};
let mut layers: Vec<(Option<&[u8]>, Option<&[u8]>)> = vec![(None, None); SECTIONS];
for section in sections {
let Some(y) = number(section.get("Y")) else {
continue;
};
let si = y as i32 - (MIN_Y >> 4);
if !(0..SECTIONS as i32).contains(&si) {
continue;
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(&a[..]),
_ => None,
};
layers[si as usize] = (layer("SkyLight"), layer("BlockLight"));
}
let above = [if sky_light { 15 } else { 0 }; 256];
crate::light::light_from_layers(chunk, &layers, above, sky_light);
}
let section = |y: Tag, sky: Option<Tag>, block: Option<Tag>| {
let mut entries = vec![("Y", y)];
if let Some(v) = sky {
@@ -7150,6 +7149,37 @@ mod tests {
section(Tag::Byte(0), None, Some(Tag::ByteArray(varied))),
]));
}
/// A saved sky layer is the resident `Light::Sky` layer byte for byte,
/// so a section whose block light is uniformly zero can adopt it.
#[test]
fn a_varying_sky_layer_is_adopted_without_expansion() {
let saved: Vec<u8> = (0..2048).map(|i| (i as u8).wrapping_mul(29)).collect();
let tag = Tag::compound([
("isLightOn", Tag::Byte(1)),
(
"sections",
Tag::List(vec![Tag::compound([
("Y", Tag::Byte(3)),
("SkyLight", Tag::ByteArray(saved.clone())),
])]),
),
]);
let mut chunk = Chunk::empty(0, 0);
let mut expected = chunk.clone();
load_light(&tag, &mut chunk, true);
reference(&tag, &mut expected, true);
for i in 0..4096 {
assert_eq!(chunk.sections[7].light(i), expected.sections[7].light(i));
}
let Light::Sky(layer, 0) = &chunk.sections[7].light else {
panic!("varying sky with no block light is Light::Sky(_, 0)");
};
assert_eq!(
&layer[..],
&saved[..],
"resident layer equals the saved one"
);
}
/// #2571: the difficulty is a property of the world: the level data
/// holds it, so a world that was set to hard is hard when it is opened
/// again, and a world that was downloaded keeps the difficulty it was
+1 -1
View File
@@ -830,7 +830,7 @@ impl Title {
seed: String::new(),
creative: false,
focus: 0,
options: WorldOptions::default(),
options: WorldOptions::for_new_world(),
}
}
/// WorldSelectionList.handleNewLevels: a singleplayer list that comes
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more