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
Kacper 4df2329711 Merge pull request #11942 from glitchsnake/fix/11804-block-outline-entity-pick
client/render: omit block outline when an entity is targeted (fixes #11804)
2026-11-08 00:37:36 +01:00
Kacper 7ca3fdeedd Merge pull request #11900 from TheMrZZ/fix/h175-frozen-first-tick-water
Register a newly loaded chunk's tick queues before commands reach it
2026-11-08 00:37:29 +01:00
Kacper e4ca9aba95 Merge pull request #11899 from CocofireHD/fix/8390-some-block-entities-render-too-dark-or-have
Block entities: shade by the face normal so shader packs don't darken them twice
2026-11-08 00:37:22 +01:00
Kacper 9848261b44 Merge pull request #11897 from abit-foggy/codex/studio-b2-camera-path
Studio: free camera and camera paths
2026-11-08 00:37:15 +01:00
Kacper 7e66da154b Merge pull request #11896 from NikitaMGrimm/hdr-hud-layer
HDR float frame: HUD and menus record how much of the world they cover
2026-11-08 00:37:08 +01:00
Kacper 3b03674d14 Merge pull request #11893 from CocofireHD/fix/8875-resource-packs-doesn-t-work-well
Fonts: read a pack's missing font files from the game below the overlay
2026-11-08 00:37:01 +01:00
Kacper 3fe5ad0d19 Merge pull request #11892 from TheMrZZ/fix/h173-live-dungeon-spawner
Create missing live dungeon spawner block entities
2026-11-08 00:36:54 +01:00
Kacper a1926a4901 Merge pull request #11882 from CocofireHD/fix/10228-server-specific-resource-pack-not-loading-pr
HUD fonts: pack a bitmap sheet from any texture path and read it from the atlas
2026-11-08 00:36:47 +01:00
Kacper 8dd178353a Merge pull request #11878 from CocofireHD/fix/10095-crash-when-enabling-a-shader-pack-on-opengl
OpenGL: report the context's texture units and refuse a bad pack instead of crashing
2026-11-08 00:36:40 +01:00
Kacper 0845a33141 Merge pull request #11864 from TheMrZZ/fix/h150-direct-passenger-relations
fix: select direct mob passengers for execute relations
2026-11-08 00:36:33 +01:00
Kacper e05360eb74 Merge pull request #11863 from senjinthedragon/glow-ink-text-tier
HDR and shader packs: glowing sign text shines
2026-11-08 00:36:27 +01:00
Kacper b967051f4f Merge pull request #11861 from Joansitoh/fix/11849-home-tab-strip
fix(launcher): wrap Home's tab strip on narrow windows instead of scrolling it sideways
2026-11-08 00:36:20 +01:00
Kacper 6c723039a7 Merge pull request #11858 from TheMrZZ/fix/h170-place-feature-inline
Place valid inline features with /place feature
2026-11-08 00:36:13 +01:00
Kacper a2c4c2d4fc Merge pull request #11856 from Ragbie/fix/infested-hurt-draw-order
Infested draws at the hurt, before an enderman's or a shulker's own roll
2026-11-08 00:36:06 +01:00
Kacper 3b5b0d8a3c Merge pull request #11850 from GIGABAIT93/l10n/uk-nightly-1107
Ukrainian: the 445 strings uk_ua.json lacked (PvP & HUD, Studio, mods, cosmetics)
2026-11-08 00:35:59 +01:00
Kacper 15ebef6a69 Merge pull request #11847 from TheMrZZ/fix/h204-depleted-absorption
Remove a mob's Absorption effect once the absorption is used up
2026-11-08 00:35:52 +01:00
Kacper 40630164be Merge pull request #11839 from vnsmoreira/packs/polytone-legacy
Polytone: legacy math expressions, color and distance functions
2026-11-08 00:35:45 +01:00
Kacper c6a2fd5291 Merge pull request #11838 from vnsmoreira/packs/polytone-groups-fix
Polytone: leave keys beside the attribute groups alone
2026-11-08 00:35:37 +01:00
Kacper 29d41a899b Merge pull request #11836 from yahya-nusseibeh/fix/remote-view-distance-overflow
Multiplayer: a server's view distance and chunk centre can't overflow
2026-11-08 00:35:31 +01:00
Kacper 61d5d3bd8a Merge pull request #11835 from yahya-nusseibeh/fix/portmap-zero-lifetime
Play with Friends: refuse a port mapping granted no lifetime
2026-11-08 00:35:24 +01:00
Kacper 99bf985e0a Merge pull request #11834 from yahya-nusseibeh/fix/guest-tunnel-token
Play with Friends: the guest's loopback tunnel takes only its own client
2026-11-08 00:35:17 +01:00
Kacper dc098b606b Merge pull request #11833 from yahya-nusseibeh/fix/pack-overlay-prune
Resource packs: delete overlays no selection has used lately
2026-11-08 00:35:11 +01:00
Kacper 51f73ac425 Merge pull request #11832 from yahya-nusseibeh/fix/pack-overlay-budget
Resource packs: cap what one overlay build may unpack
2026-11-08 00:35:04 +01:00
Kacper ac9bd0527a Merge pull request #11827 from TheMrZZ/fix/h168-mob-fall-flying
Keep a mob's FallFlying flag and wear its glider as vanilla does
2026-11-08 00:34:57 +01:00
Kacper 8715111af1 Merge pull request #11825 from vnsmoreira/packs/polytone-expressions
Polytone: the scripting expression language
2026-11-08 00:34:50 +01:00
Kacper 790efa9cb7 Merge pull request #11818 from NikitaMGrimm/hdr-note-hud-first
HDR design note: the HUD's layer and the tone curve before light is written
2026-11-08 00:34:43 +01:00
Kacper ae46423a77 Merge pull request #11795 from Hiuoy/port-pack/renamed-tooltip-anvil
Port pack: RenderTooltipEvent.Color and AnvilRepairEvent under their 26.1 names
2026-11-08 00:34:36 +01:00
Kacper 9da1e6f55f Merge pull request #11794 from doodlyyousuf/251107-fix-entity-air-drag
fix: widen entity air and fluid drags from vanilla floats
2026-11-08 00:34:29 +01:00
Kacper e27e97e58b Merge pull request #11793 from TheMrZZ/fix/h149-local-coordinate-math-current
fix: match exact local command rotation arithmetic
2026-11-08 00:34:22 +01:00
Kacper 02b75ab1f3 Merge pull request #11788 from Teiwazik/render/taa-motion-clip
TAA upscaling smears the world under motion
2026-11-08 00:34:15 +01:00
Kacper 3be40a13a3 Merge pull request #11786 from Teiwazik/render/upscaling-sharpness
Upscaling Sharpness: sharpen the TAA-upscaled world
2026-11-08 00:34:07 +01:00
Kacper c69d499386 Merge pull request #11778 from Teiwazik/fix/framed-maps-slots
Framed maps: no slot taken from a map drawn this frame
2026-11-08 00:33:59 +01:00
Kacper 1a83468f0d Merge pull request #11776 from MSedgeMC/fix/frog-panic-speed
Frogs panic at AnimalPanic's 2.0 speed multiplier
2026-11-08 00:33:51 +01:00
Kacper 1abb582ab4 Merge pull request #11769 from MSedgeMC/port_map/stale-batch-two
port map: a row that survived a rename
2026-11-08 00:33:45 +01:00
Kacper d44210f9b4 Merge pull request #11765 from MSedgeMC/test/enchant-durability
test: compare enchantment durability effects
2026-11-08 00:33:38 +01:00
Kacper 3cefb44bba Merge pull request #11740 from Detimes/fix-11721-explode-radius
Older clients: explosions no longer fail for 1.21.8 and older; real radius for 1.21.1
2026-11-08 00:33:25 +01:00
Kacper 5c28d7f248 Merge pull request #11723 from TheMrZZ/fix/loaded-arrow-shot-game-event
Dispatch ordinary arrow shoot events on their first tick
2026-11-08 00:33:18 +01:00
Kacper b1dae52509 Merge pull request #11711 from MSedgeMC/test/falling-block
test: compare falling block landings
2026-11-08 00:33:11 +01:00
Kacper 341bd0ba5b Merge pull request #11709 from TheMrZZ/fix/connected-guest-attribute-commands
Route attribute commands through the selected guest body
2026-11-08 00:33:04 +01:00
Kacper 6e423b9caa Merge pull request #11701 from Alysia42/random-source
Parity: entity and RandomSource.create() randoms draw from the level's random or are hand-seeded
2026-11-08 00:32:57 +01:00
Kacper 604c3a6654 Merge pull request #11694 from TheMrZZ/fix/h289-mob-equipment-custom-data
fix: match typed custom data in mob equipment predicates
2026-11-08 00:32:51 +01:00
Kacper b229c37e45 Merge pull request #11691 from TheMrZZ/fix/warden-loaded-memory-slots
Bind loaded Warden memories to their runtime slots
2026-11-08 00:32:44 +01:00
Kacper dcb6409ebd Merge pull request #11681 from MSedgeMC/test/nether-portal-shape
test: compare nether portal frame search
2026-11-08 00:32:37 +01:00
Kacper 217b5e97f7 Merge pull request #11672 from TheMrZZ/fix/loot-kill-sheep-state
Bind actual sheep target state in loot-kill commands
2026-11-08 00:32:31 +01:00
Kacper 4fe3ed58b4 Merge pull request #11666 from Raydnde/fix/copper-chest-wax-and-viewers-11378
Waxing a double copper chest reaches both halves; a golem's open chest doesn't weather
2026-11-08 00:32:24 +01:00
Kacper c15fa6c55c Merge pull request #11659 from TheMrZZ/fix/camel-inherited-saved-fields
Save Camel inherited horse fields from modeled state
2026-11-08 00:32:17 +01:00
Kacper d6d3042086 Merge pull request #11656 from TheMrZZ/fix/h212-crafter-drop-name
fix: keep vanilla crafter drops unnamed
2026-11-08 00:32:10 +01:00
Kacper c929d96fa5 Merge pull request #11652 from ilguerro/perf/11126-structure-starts-jigsaw
Read saved jigsaw structure starts instead of regenerating them
2026-11-08 00:32:03 +01:00
Kacper 5555cd8cda Merge pull request #11634 from k3yh/cushion-selectors
Selectors find cushions
2026-11-08 00:31:56 +01:00
Kacper f85f824a27 Merge pull request #11633 from MSedgeMC/fix/old-stats-file
Move an old name-based stats file to the player's uuid
2026-11-08 00:31:49 +01:00
Kacper e1a8d50c79 Merge pull request #11625 from TheMrZZ/fix/loaded-sleeper-bed-position
Refresh loaded sleeper position at the bed on its first tick
2026-11-08 00:31:42 +01:00
Kacper b69935ff31 Merge pull request #11624 from TheMrZZ/fix/h290-pot-template-compounds
fix: save pot sherd string templates as item compounds
2026-11-08 00:31:35 +01:00
Kacper 900ce5e15b Merge pull request #11612 from k3yh/ride-vehicles
/ride takes armor stands, displays, interactions and markers as vehicles
2026-11-08 00:31:28 +01:00
Kacper 7435df4fae Merge pull request #11610 from TheMrZZ/fix/world-pack-enchantment-definitions
Load active world-pack enchantment definitions
2026-11-08 00:31:21 +01:00
Kacper afce5629b7 Merge pull request #11590 from TheMrZZ/fix/h145-required-function-tags
fix: reject function tags with unresolved required references
2026-11-08 00:31:14 +01:00
Kacper 7f22f76867 Merge pull request #11576 from TheMrZZ/fix/folder-datapack-metadata
Require folder data-pack metadata sections before discovery
2026-11-08 00:31:06 +01:00
Kacper 08cb78f385 Merge pull request #11573 from vnsmoreira/attributes/modifiers
Environment attributes: the registry and modifiers, checked against vanilla
2026-11-08 00:30:59 +01:00
Kacper ebaea75be6 Merge pull request #11565 from TheMrZZ/fix/function-command-admission
Reject functions containing invalid ordinary commands
2026-11-08 00:30:52 +01:00
Kacper ad2aabd6cf Merge pull request #11558 from Vaspyyy/perf/vault-players-lazy
Vaults: list the players only when a vault looks for them (−12.6% tick_block_entities)
2026-11-08 00:30:46 +01:00
Kacper 534feb4833 Merge pull request #11544 from voidsipp/fix/11328-trade-modifiers
Fix trade modifiers and potion payment matching
2026-11-08 00:30:39 +01:00
Kacper b7d134d2ef Merge pull request #11540 from voidsipp/fix/11330-explosion-item-health
Spill contents when explosions destroy dropped containers
2026-11-08 00:30:32 +01:00
Kacper 921f2fa118 Merge pull request #11533 from TheMrZZ/fix/loot-mine-executor-presence
Carry actual loot-mine executor presence into block drops
2026-11-08 00:30:25 +01:00
Kacper 0429cd882f Merge pull request #11526 from Vaspyyy/perf/cart-push-loot
Minecarts: keep the loot roll out of the copies a cart push makes (−52% tick_carts)
2026-11-08 00:30:17 +01:00
Kacper b68f5b5327 Merge pull request #11509 from voidsipp/fix/smithing-transform-11307
Fix smithing transform optional inputs and result templates
2026-11-08 00:30:10 +01:00
Kacper d21170f02d Merge pull request #11508 from TheMrZZ/fix/camel-loaded-pose-timestamp
Preserve loaded Camel pose timestamp boundaries
2026-11-08 00:30:03 +01:00
Kacper 6488524356 Merge pull request #11498 from TheMrZZ/fix/effective-item-component-validation
Reject stackable damageable item command inputs
2026-11-08 00:29:56 +01:00
Kacper c9e8e4e8e0 Merge pull request #11497 from voidsipp/fix/11316-stack-tool-mining
Respect stack tool mining rules and actual eye immersion
2026-11-08 00:29:49 +01:00
Kacper b0abfbd196 Merge pull request #11491 from DUzzL/fix/10041-adventure-placed-entities
Adventure mode: honor can_place_on for placed entities and fireworks
2026-11-08 00:29:42 +01:00
Kacper 2c0564db8c Merge pull request #11485 from voidsipp/fix/11317-block-crack-feedback
Match player mining crack stages and broadcast distance
2026-11-08 00:29:34 +01:00
Kacper 41a021365e Merge pull request #11483 from TheMrZZ/fix/llama-saved-temper
Preserve signed equine temper across load and save
2026-11-08 00:29:28 +01:00
Kacper ef36a05d90 Merge pull request #11480 from brasga-a/fix/11314-player-food-predicate
Player predicates test food level and saturation
2026-11-08 00:29:21 +01:00
Kacper ab4a0fd56e Merge pull request #11478 from DUzzL/fix/11379-copper-golem-name
Keep the copper golem's name on its statue
2026-11-08 00:29:14 +01:00
Kacper 0e12cc64de Merge pull request #11474 from voidsipp/fix/11319-gateway-item-motion
Preserve item motion through End gateways
2026-11-08 00:29:07 +01:00
Kacper 4a6da129ce Merge pull request #11467 from voidsipp/fix/round5-5-11321
Respect world borders for pistons and natural spawning
2026-11-08 00:29:00 +01:00
Kacper 29f5ef342c Merge pull request #11466 from MSedgeMC/fix/region-external
Region: a chunk's external stream reads even when its stub length disagrees or its .mcc is missing
2026-11-08 00:28:53 +01:00
Kacper 672c9a7974 Merge pull request #11465 from TheMrZZ/fix/h097-loaded-air-first-tick
Discard loaded air falling blocks on their first tick
2026-11-08 00:28:46 +01:00
Kacper 3d275f03bf Merge pull request #11459 from DUzzL/fix/11387-beacon-effect-overlap
Beacon: select players by bounding-box overlap
2026-11-08 00:28:38 +01:00
Kacper 1dd7423d00 Merge pull request #11457 from voidsipp/fix/round5-1-11333
Respect Silent on explosions and potion impacts
2026-11-08 00:28:31 +01: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
Kacper 9c0c0c7210 Merge pull request #11456 from MrCloudy2/fix/dropper-container-entities
Droppers insert into chest boats and container minecarts
2026-11-08 00:28:23 +01:00
Kacper 671a85a7f2 Merge pull request #11455 from NikitaMGrimm/frame-identity-no-fall
Frame identity: keep the player on a block while the scenes are built
2026-11-08 00:28:15 +01:00
Kacper 63a8290dcb Merge pull request #11445 from MSedgeMC/test/furnace-tick
furnace: direct block-entity tick parity (burn table, XP, mid-cook reset)
2026-11-08 00:28:08 +01:00
Kacper fbd9b2ce23 Merge pull request #11434 from DUzzL/fix/11306-anvil-grindstone
Read stack names and durability components in anvil and grindstone
2026-11-08 00:28:00 +01:00
Kacper e290e2c196 Merge pull request #11433 from TheMrZZ/fix/falling-live-data-adoption
Adopt live FallingBlock data updates
2026-11-08 00:27:53 +01:00
Kacper 5671fc9619 Merge pull request #11432 from MSedgeMC/fix/phantom-anchor-save
Save a phantom's circling anchor with its chunk
2026-11-08 00:27:46 +01:00
Kacper 436e839ebf Merge pull request #11425 from TheMrZZ/fix/h051-kill-vehicle-contents
Spill explicit vehicle container contents on kill removal
2026-11-08 00:27:39 +01:00
Kacper 860f3f05d5 Merge pull request #11423 from DUzzL/fix/11311-scaffold-water
Read scaffolding as its stable shape in fluid wall tests
2026-11-08 00:27:33 +01:00
Kacper 88d7c0eb2c Merge pull request #11422 from voidsipp/fix/round5-6-11320
Preserve comparator output and hopper cooldown across saves
2026-11-08 00:27:26 +01:00
Kacper 2cf505bbd7 Merge pull request #11421 from DUzzL/fix/11324-spawn-heightmap
Fix natural spawn height bound off by one
2026-11-08 00:27:19 +01:00
Kacper cd218ea58e Merge pull request #11420 from TheMrZZ/fix/falling-block-state-default
Use modern falling-block state decoding and sand fallback
2026-11-08 00:27:12 +01:00
Kacper 161c4e5677 Merge pull request #11414 from voidsipp/fix/round5-3-11315
Honor block drops and RNG order for mining XP
2026-11-08 00:27:05 +01:00
Kacper a6dc594cdf Merge pull request #11413 from ilguerro/fix/w149
A server player's glide ends on a climbable (as_guest copies fall_flying back)
2026-11-08 00:26:58 +01:00
Kacper 013c3952a7 Merge pull request #11411 from ilguerro/fix/w167
Wait for the client's load after the End credits respawn
2026-11-08 00:26:51 +01:00
Kacper 5365f2be63 Merge pull request #11407 from ilguerro/fix/w157
Drop a converting piglin's inventory like vanilla
2026-11-08 00:26:44 +01:00
Kacper 0f23639844 Merge pull request #11400 from ilguerro/fix/w144
Search neighboring columns for dolphin breathing like vanilla
2026-11-08 00:26:38 +01:00
Kacper 997fe4515d Merge pull request #11390 from UMCEKO/dims/dragon-facts
The dragon fight follows has_ender_dragon_fight (#11282, 5/9)
2026-11-08 00:26:30 +01:00
Kacper 5d16154c0b Merge pull request #11366 from UMCEKO/dims/mob-facts
Mobs and spawn rules read the dimension type (#11282, 4/9)
2026-11-08 00:26:24 +01:00
Kacper 47e562e8b9 Merge pull request #11350 from AlfaRojo/fix/sky-darken-truncates
Sky darkening truncates the light level, as updateSkyBrightness does
2026-11-08 00:26:16 +01:00
Kacper 74b8550421 Merge pull request #11344 from UMCEKO/dims/gameplay-facts
Gameplay reads the dimension type, not the dimension (#11282, 3/9)
2026-11-08 00:26:09 +01: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
Kacper 54c3a21b29 Merge pull request #11116 from Delete228/content-context-geometry-upstream
Native content: resolve block geometry by entity context
2026-11-08 00:26:02 +01:00
Kacper d6f0b3042f Merge pull request #11098 from Delete228/native-state-entity-factory-upstream
Native mods: create block entities from the actual state
2026-11-08 00:25:55 +01:00
Kacper 0422dbc79f Merge pull request #10559 from rison1337/perf/cache-trim-preparation
Reuse unchanged armor-trim preparation across frames
2026-11-08 00:25:48 +01:00
Kacper 914c1e9a3b Merge pull request #10552 from ilguerro/perf/9996-surface
Write surface rule runs of one block at once
2026-11-08 00:25:41 +01:00
Kacper 62dd244cb6 Merge pull request #10511 from TheMrZZ/fix/noai-nautilus-air
Keep superclass air refill for NoAI Nautilus
2026-11-08 00:25:34 +01:00
Kacper 0e446e4336 Merge pull request #10242 from TheMrZZ/fix/h297-potion-effect-icon
Save default icon flags in custom potion effects
2026-11-08 00:25:27 +01:00
Kacper 48344d0a6b Merge pull request #10210 from TheMrZZ/fix/h103-trade-reputation-gate
Defer villager trade reputation until its AI phase
2026-11-08 00:25:20 +01:00
Kacper 87d4a87960 Merge pull request #10201 from DUzzL/fix/10147-projectile-catchup-stutter
Unstick arrows and tridents the server sets moving
2026-11-08 00:25:13 +01: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
VaspyyyandClaude Opus 5.5 1ba320ca73 Vaults: list the players only when a vault looks for them
with_vault built the list of every player (lan_present: eye heights, effects,
omens) for every loaded vault every tick. VaultBlockEntity.Server.tick reads the
players only when the vault's state may change (stateUpdatingResumesAt, once a
second), so the list is now built at the first ask, from the same game state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:01:12 +01: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
Nikita Maximilian GrimmandClaude Opus 5.5 80e3532c4a docs: HDR design note: step 4 writes light a whole frame at a time
A blended draw over a surface of the other kind leaves one mark for both, so a frame that mixes
marked and written light would clip the written light and lift it again, or leave the marked part
unlifted. Step 4 now goes path by path: the frame-case rebuild first, then every marking shader on
the default path in one pull request, then Improved Transparency and the visibility buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:06:12 +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
Horizon agent dd0050e268 Merge remote-tracking branch 'origin/main' into cg65v-10559
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 19:56:17 +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 56a578f0a6 Merge origin/main into fix/h170-place-feature-inline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:50:54 +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
abit-foggyandCodex GPT-6 e5fe17622c chore(studio): merge main into B2 branch
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 08:20:11 -10: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
abit-foggyandCodex GPT-6 114b09babe feat(studio): autocomplete studio commands
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 07:37:39 -10:00
LokiandAntigravity 0a85491338 client/render: omit block outline when an entity is targeted (fixes #11804)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 02:31:51 +09:00
Florian ErnstandClaude Opus 5.5 5f2e3cb14d Register a newly loaded chunk's tick queues before commands reach it
In a frozen level, water a command placed in newly forceloaded chunks
before the next world tick never flowed: the chunks' tick containers
were only registered by tick_blocks (a running world tick) or by
postprocessing, so the water's flow tick found no container and was
dropped. Vanilla registers them when the chunk becomes a full chunk
(LevelChunk.registerTickContainerInLevel), before any command sees it.

update_world now runs the existing sync_tick_chunks once the streamed
chunks are resident and the unloaded ones are saved with their ticks,
so registration comes before the tick's commands and world tick. Only
an authoritative game (not a server's client) registers.

Fixes #11873

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:06:05 +01:00
CocofireHDandopencode a61d272adc Block entities: shade by the face normal so packs don't darken them twice
A block entity's faces were baked with `minecraft_mix_light` and their
normal left zero. A shader pack's `gbuffers_block` (or its fallback)
receives the unshaded colour and the face normal from Iris, then applies
its own lighting, so Horizon's baked shade was applied a second time:
chests, shulkers, banners and the rest came out darker than the terrain
around them (#8390).

`draw_part` now emits the tint alone and the face's world normal
(`normal.w` = 127), and the entity shader applies `minecraft_mix_light`
from it, as it already does for mobs. Lights that are not the level's (an
item preview's `Lighting.Entry`, which the entity shader cannot know)
stay baked into the colour with `normal.w` 0, which is the path
`game/item_draw` gives block-entity items in inventories and hands.

The parity harness compares the tint and the face normal; the loom's GUI
preview keeps its baked `ITEMS_FLAT` colour. The chest cache test now
checks the nether flag reaches the shader instead of changing a baked
colour, so the cached geometry serves both dimensions.

Fixes #8390

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 18:05:25 +01:00
abit-foggyandCodex GPT-6 d607c2f137 feat(studio): add freecam and camera paths
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 07:04:37 -10:00
Delete228andCodex e54dcc07cf Merge Horizon main into the stateful block entity PR
Co-authored-by: Codex <[email protected]>
2026-11-07 20:00:52 +03:00
Florian ERNSTandCodex 6079903ffc Create missing live dungeon spawner block entities
Carry the already selected mob through an optional creation-only level hook. Preserve existing entries and the generation initializer and RNG order.

Co-Authored-By: Codex <[email protected]>
2026-11-07 17:54:52 +01:00
CocofireHDandopencode 3a7f276eab Fonts: read a pack font file the overlay lacks from the game
A pack that adds font providers over the game's own can leave the overlay without the vanilla font sheets its merged font/default.json names. Every glyph then became the missing box. Read a bitmap sheet, TrueType file or Unihex file the overlay lacks from the game's own root, as vanilla's resource stack keeps the game below every pack. Font definitions stay the overlay's, so a pack filter can still hide one.

Fixes #8875

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:54:37 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 beaad0e79d HUD coverage: drop a pack's colour outputs the HUD has no target for
A pack text shader may declare colour outputs past location 0. The HUD's
pipelines have one target, so they went nowhere, but the coverage wrapper kept
them, and one at location 1 collided with the coverage and dropped a shader
that works today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:50:19 +01:00
Horizon agentandClaude Opus 5.5 ab37cfb5aa Merge origin/main into #11497
items.rs tests: keeps this branch's tool-rules test and main's command
loot zero-count test, which both landed at the same place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:48:34 +01:00
Horizon agentandClaude Opus 5.5 92b2977e3b Merge origin/main into #11474
end.rs: keeps this branch's live gateway items; main's only change there
(item_entity_to_nbt_with_items) was inside the saved-item path this
branch removes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:46:53 +01:00
CocofireHDandopencode f178cbe003 HUD atlas: pack a bitmap font's sheet from any texture path
A bitmap provider's file may be any texture, and HUD packs keep sheets outside textures/font. HudAtlas already received the sheet from the font commit, but only if the texture was packed: WALKED_TEXTURE_FOLDERS did not include custom paths such as textures/lumiere/hud, so those sheets still fell back to per-glyph run-time copies. Collect every bitmap provider's file while building the atlas, deduplicated against the walked textures, so its glyphs use the sheet's atlas rect.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:35:29 +01:00
CocofireHDandopencode 0478b4deec Fonts: use an atlas sheet outside textures/font for server HUDs
A bitmap provider may name any texture, and server HUD packs keep their glyph sheets under their own namespace or a path such as textures/lumiere/hud. The HUD atlas already holds those textures, but Font only looked up sheets under font/, so every glyph was decoded from disk and copied into the 512-row run-time region one by one. When that region filled, a glyph became the 6-pixel missing glyph, shifting the rest of the HUD line.

Look the sheet up by its atlas name for every namespace and keep every atlas sprite available to the font. Glyphs now use the sheet's atlas rect, so their advances come from the original texels and the run-time region is not involved.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:35:29 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b5a1d40dc6 HDR coverage view: magenta at paper white, not PQ's 10 000 nits
In PQ output the debug view mixed toward PQ (1, 0, 1), so any covered pixel
blew out. Mix toward BT.709 magenta at the paper white instead, encoded like
the picture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:32:22 +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
Florian ErnstandClaude Opus 5.5 1a9c87059b Merge origin/main into fix/connected-guest-attribute-commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:15:12 +01:00
Florian ErnstandClaude Opus 5.5 4d8e70da2c Merge origin/main into fix/h297-potion-effect-icon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:59 +01:00
Florian ErnstandClaude Opus 5.5 8efbe1c75e Merge origin/main into fix/h051-kill-vehicle-contents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:58 +01:00
Florian ErnstandClaude Opus 5.5 df25b701c5 Merge origin/main into fix/loaded-sleeper-bed-position
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:54 +01:00
TheMrZZandCodex gpt-6.1-sol 3890e57c13 fix: select direct mob passengers for execute relations
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 17:14:06 +01:00
Florian ErnstandClaude Opus 5.5 2282116773 Place valid inline features with /place feature
/place feature refused every inline feature value with "An unexpected
error occurred", although vanilla 26.3 places it like a named one
(PlaceCommand.placeFeature over a ResourceOrHolderArgument value) and
reports commands.place.feature.success.inline.

The inline value is read with the existing SNBT-to-JSON pair, decoded
by the same feature codec as a named feature (feature_with_blocks,
uncached), and placed through the same Feature.place path with the
level random. A feature that does not place reports
commands.place.feature.failed; a value that does not decode keeps the
plain failure.

Fixes #11843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:54 +01:00
c74ff0070b HDR float frame: every HUD and menu draw writes its coverage of the world
With HORIZON_HDR_FRAME=float, HDR on and no shader pack, on devices with independent blending, each
HUD and menu pipeline gets a variant with a second output into an R8 target: how much of the world
behind it the draw hides (docs/design/hdr-linear-light.md, The HUD and menus). The fragment shader
is wrapped as it is, so the primary colour, its blend and alpha are unchanged, and the coverage
follows the draw's final colour and blend: layering by its alpha, additive draws not at all,
darkening ones by how much they darken, inverting and replacing ones fully. HORIZON_HDR_DEBUG=coverage
tints it magenta in the last pass; nothing else reads it yet. A resource pack text shader without a
colour output is dropped as an unusable one is, instead of panicking.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:05:50 +01:00
JoansitohandClaude Sonnet 5.5 af13271b86 fix(launcher): wrap Home's tab strip on narrow windows instead of scrolling it sideways
Fixes #11849

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 16:58:59 +01:00
GIGABAIT93andClaude Opus 5.5 0df4792709 Ukrainian: the 445 strings uk_ua.json lacked (PvP & HUD, Studio, mods, cosmetics)
PvP & HUD, Replay Studio, the music page, Browse Mods, the resource pack
browser, cosmetics and emotes, moderation and tempban, the controller page,
graphics tooltips and recipe transfer. Placeholders, leading and trailing
spaces match en_us; durations use abbreviations (хв, год, дн.) so one form
reads right for every number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:56:20 +02:00
Evrim CicekandClaude Opus 5.5 51eae8d435 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:50:56 +03:00
CocofireHDandopencode 7142128a7a wgpu-hal GL: report the context's texture units per stage
The GL backend reported MAX_TEXTURE_SLOTS (16, WebGL2's minimum) as max_sampled_textures_per_shader_stage even on desktop GL, where drivers usually offer 32. A native shader pack's sky pipeline layout declares the terrain's 10 sampled textures and the frame's 9, so it was refused at 16 although the driver accepts it (#10095).

Query GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS and GL_MAX_TEXTURE_IMAGE_UNITS, take the lower, and cap at the arrays' new 32 slots (MAX_SAMPLERS likewise). On WebGL2 the query still yields 16.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 16:49:30 +01:00
CocofireHDandopencode 81df2a1864 Renderer: refuse a native shader pack instead of panicking
A layout or pipeline the backend cannot take (a stage's bindings past its sampled-texture limit, say) reaches the device's error scope. A native pack built its frame, stages and passes outside one, so wgpu's validation error hit the default handler and crashed the game (#10095). Build the pack in a scope and return the first error, which the shader pack screen already shows as a refusal.

The test asks for OpenGL's minimum 16 sampled textures per stage and builds a sky-only pack whose layout needs 19: the pack is an error, not a validation panic.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 16:49:30 +01:00
SenjinandClaude Opus 5.5 edb550ac32 HDR and shader packs: glowing sign text shines
Glow ink text was full-bright in the lightmap only: HDR output and shader packs that light
with their own GI (and read the HDR tier, horizonTier) showed it as dark as plain text.
Its glyphs now carry the low HDR tier, like other things that glow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:48:02 +01:00
yahya-nusseibehandClaude Opus 5.5 f2e081520f Resource packs: take the unpack budget without fetch_update
CI's stable Rust deprecates AtomicU64::fetch_update in favour of
try_update, which the workspace's minimum Rust (1.95) doesn't have, so
clippy -D warnings failed. A compare_exchange_weak loop does the same
on every version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 17:47:21 +02:00
Florian ErnstandClaude Opus 5.5 f1ea540f08 Remove a mob's Absorption effect once the absorption is used up
AbsorptionMobEffect.applyEffectTick, asked on every tick, answers false
when the absorption amount is 0, so MobEffectInstance.tickServer drops
the effect before its duration counts down, finite or infinite. A mob's
effect tick had no Absorption case: a used-up Absorption stayed until it
expired. It now ends through the existing removal path; other effects,
infinite ones included, are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:34:19 +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
Vinícius MoreiraandClaude Opus 5.5 12dd25d421 Polytone: legacy math expressions, color and distance functions
Older packs write expressions in the legacy syntax: uppercase variables
(AGE, DISTANCE_SQUARED, RAIN...), pi and e, and bare rand(), gaussian()
and noise(). Each variable now runs as the scripting call the wiki maps
it to, and a legacy expression keeps the old argument order of lerp,
smoothstep and color. The color functions (red, color, withAlpha,
lerpColor, hsb, lerpHsb...) follow vanilla's ARGB channel rules, and
dist/distSquare join the math table. All expressions of Visual
Effects+ now compile.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:25:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 c6c0a97e57 Polytone: the scripting expression language
Expressions such as `o.age() * 0.1`, ternaries, `&&`/`||`, strings,
local variables, `if` blocks and calls on context objects are parsed
once when the pack loads, with every name resolved, so a typo is a load
error. A Host answers the context objects (o, p, c, g, r, v), their
methods and the game's global functions; the math functions follow
Java's Math. 1461 of the 1520 distinct expressions of Visual Effects+
compile; the rest use the legacy uppercase names, which come next.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:22:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 edab7b313f Polytone: leave keys beside the attribute groups alone
An attributes_modifiers object with a group (`base`...) no longer refuses
the file over a key that is none of the modifier's groups: it is left
alone, so a pack Polytone loads isn't rejected over an entry neither reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:21:34 -03: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
Florian ErnstandClaude Opus 5.5 4924374fa5 Keep a mob's FallFlying flag and wear its glider as vanilla does
A mob loaded or summoned with FallFlying:1b kept the flag even when it
could not glide, and a gliding mob's elytra never wore. The flag was
only the raw tag's value: the loader did not read it, the save only
supplied a default, and the mob tick had no updateFallFlying step.

- mob_from_nbt reads FallFlying into the mob, and mob_to_nbt writes the
  mob's current flag (LivingEntity.read/addAdditionalSaveData).
- The mob's aiStep, with or without AI, runs updateFallFlying while the
  flag is set: canGlide (ground, vehicle, levitation, a glider whose
  next damage does not break it) clears the flag; every 20 glide ticks
  Util.getRandom picks a glider from the mob's own random.
- The game applies that wear with the existing durability path
  (Unbreaking from the level's random) to the mob's kept stack.
- LivingEntity.tick counts fallFlyTicks after aiStep.

Glide travel, checkFallDistanceAccumulation and the ELYTRA_GLIDE game
event are not part of this change.

Fixes #11809

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:14:52 +01: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
Horizon agentandClaude Opus 5.5 b86fa679b9 function_tags harness: run without --work-dir
tools/parity.py runs every harness with no arguments, so the harness's
required --work-dir made it fail on every CI run before reaching the
oracle. Without the flag it now captures into a fresh
.local/function-tags-parity; an explicit --work-dir must still be new.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:05:53 +01:00
Horizon agentandClaude Opus 5.5 45da47c08b Merge origin/main into #11666
game.rs: keeps this branch's wax_on_sound (honeycomb) beside main's
transform_copper_chest_neighbour (axe/hoe/shovel transformers, #33ec06ab12).
test_use_item_on_parity.py: keeps both the honeycomb double-chest cases
and main's block transformer cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:03:22 +01:00
yahya-nusseibehandClaude Opus 5.5 04449d8b73 Multiplayer: a server's view distance and chunk centre can't overflow
view_distance and chunk_center come straight from Login,
SetChunkCacheRadius and SetChunkCacheCenter, any i32 a server sends.
remote_chunk_in_range added 3 to the radius and subtracted the centre,
and the streaming radius and sent_radius added 1: a server sending
i32::MAX or i32::MIN panicked a debug build, and in release (no
overflow checks) the wrapped range dropped every loaded chunk.

The storage range is now in_storage_range: the same ClientChunkCache
rule (radius at least 2, plus 3), with a saturating add and abs_diff,
so nothing changes for any radius a real server sends. The +1s
saturate too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 17:00:32 +02:00
Horizon agentandClaude Opus 5.5 da0b4899ff Merge origin/main into #11480
Keeps both imports in command_exec_corpus.groups(): this branch's
player_food_predicate_corpus and main's treasure_map_copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:00:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 50b1d6232e docs: HDR design note: marks turned into light once in step 3, tests for mixed light and TAA
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:52:07 +01:00
yahya-nusseibehandClaude Opus 5.5 706cf3b3fa Resource packs: delete overlays no selection has used lately
Each distinct pack selection gets its own overlay folder under
.horizon-cache/resourcepacks, holding a full unpacked copy of its zip
packs, and nothing removed old ones. Every server pack, and every new
version of one, left a permanent copy the player can't see: ten
servers with 100 MiB packs kept about a gigabyte.

resource_root now marks the overlay it returns as used by this process
(a shared lock on <overlay>.overlay-cache.use, held until the process
exits, whose time is the last use), then keeps the 8 most recently used
overlays in the folder and deletes the rest. An overlay another
process uses or is building is skipped, so two games sharing a game
folder never pull one out from under each other; the marker goes before
the folder, so a half-deleted overlay is never reused. The lock files
stay, as build() already requires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:45:03 +02:00
yahya-nusseibehandClaude Opus 5.5 439b609a94 Play with Friends: the guest's loopback tunnel takes only its own client
After the encrypted join, the guest offered the session on a random
127.0.0.1 port for 30 s and handed it to the first loopback connection.
Any other local user or process that found the port first got the
authenticated session to the host's server, and a web page aiming
fetch at local ports could send HTTP into the tunnel and make the
player's own join fail.

serve_loopback now returns a Loopback with the port, a random 16-byte
token and the thread. A connection must send the token within a second
of connecting (compared in constant time); anything else is dropped and
the port keeps waiting until its deadline. The client's join_friend
passes the token on, and run() writes it raw before the handshake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:42:17 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 537291b26d docs: HDR design note: the HUD's layer and the tone curve before light is written
Light written into the frame can't be taken back out from under the HUD, and without the tone curve it clips at
the peak, so step 3 now holds the HUD's own layer and the tone curve, and step 4 the shader groups. The look
target names the differences allowed (rounding, light behaving as light under glass, water and anti-aliasing)
and the artifacts that aren't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:33 +01: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
Horizon agentandClaude Opus 5.5 5ee3b9da9a Run the NoAI nautilus harness without --binary, as parity.py does
parity.py and parity-main call tools/test_*_parity.py without --binary or --work-dir, so the harness failed in 0 s there. Use harness_args.ensure_work_dir, as a98ba0273 did for the dolphin and spell-ticks harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:01 +01:00
Horizon agentandClaude Opus 5.5 307d82bb63 Merge main into #10511
Conflicts kept from both sides: crates/horizon-client/src/mobs/ai.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:01 +01:00
yahya-nusseibehandClaude Opus 5.5 1e38170d49 Play with Friends: refuse a port mapping granted no lifetime
In PCP and NAT-PMP a lifetime of 0 means the mapping is deleted, but a
success reply carrying 0 was taken as a mapping. Mapping::due() was
then always true, so the host renewed on every 100 ms wake-up of its
loop (a UDP exchange each time, under the reach mutex) while
advertising a direct address that no longer existed.

pcp_map and natpmp_map now refuse a zero lifetime when they asked for
one (a delete, which asks for zero, still expects zero), and due()
never fires sooner than 30 s after the last renewal, so a router that
grants a few seconds isn't asked again ten times a second either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:36:25 +02:00
yahya-nusseibehandClaude Opus 5.5 b0600cec3c Resource packs: cap what one overlay build may unpack
Applying packs builds an on-disk overlay: every file under assets/ in
every zip pack was inflated (up to 1 GiB each, whole in memory) and
written out, on every worker thread at once. A gigabyte of zeros
deflates to about a megabyte, so a server pack at the 250 MiB download
limit could write some 240 GiB to the player's disk and hold a 1 GiB
buffer per thread, once the player accepted the pack prompt. Vanilla
never unpacks a pack.

Zip::copy_to now streams an entry to its file 64 KiB at a time, taking
each piece from a byte budget shared by the whole build: 16 times the
zips' size on disk, at least 64 MiB, at most 8 GiB. Past it the build
fails with FileTooLarge (so the reload drops the server packs, as for
any failed build) and the half-written overlay folder is removed. An
unreadable entry still costs only that file. Memory per thread is now
the compressed entry plus one piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:31:41 +02:00
Evrim CicekandClaude Opus 5.5 c8753a2ea4 Infested's throw in floats as JOML does; the unit test hurts through Mob::damage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:31:04 +03: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
Evrim CicekandClaude Opus 5.5 3fc5044868 Infested draws at the hurt, before an enderman's or a shulker's own roll
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:21:38 +03:00
Evrim CicekandClaude Opus 5.5 e44fc5af01 mob AI parity: an effect flag, an infested enderman in the rain and a poisoned shulker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:21:38 +03:00
Teiwazik 1eb3b7f6c3 Merge remote-tracking branch 'origin/main' into tmp-seat/follow-plugin-seat-chains 2026-11-07 15:12:38 +01:00
TeiwazikandClaude Opus 5.5 43e106cece Upscaling Sharpness: English texts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:08:56 +01:00
umcekoandClaude Opus 5.5 1c8113ce32 Merge origin/main into #11344
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:05:06 +03:00
TeiwazikandClaude Opus 5.5 4b5b667560 Upscaling Sharpness: German and Russian texts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:54:09 +01:00
HiuoyandClaude Opus 5.5 f898655ae6 Port pack: RenderTooltipEvent.Color and AnvilRepairEvent under their 26.1 names
NeoForge 26.1 has neither. A tooltip is drawn from a style's textures, picked
in RenderTooltipEvent.Texture, and the anvil take is AnvilCraftEvent, whose
.Pre comes before the inputs are used and the anvil is damaged (no break
chance). `events` now looks the 1.21.1 names up under those, so ATM10's 6
Color and 4 AnvilRepair users are answered by the hooks that exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:54:03 +04:00
73fa431ac5 fix: widen entity air and fluid drags from vanilla floats
Vanilla Entity.getAirDrag is 0.98F. PrimedTnt, FallingBlockEntity and
ItemEntity scaled motion by the f64 0.98, so after four ticks a TNT's
Motion x was 0.092236816 instead of 0.09223682318…. Item underwater
and lava drags, LlamaSpit air drag, and hurting-projectile inertia
were the same class of float-widened constants.

Fixes #6814

Co-Authored-By: OpenCode <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
2026-11-07 13:53:30 +00:00
TheMrZZandCodex gpt-6.1-sol e135804a52 fix: match exact local command rotation arithmetic
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 14:46:33 +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 Opus 5.5 6689a656a0 TAA: clip history to the neighbourhood spread, weigh new frames more under motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:39:08 +01:00
TeiwazikandClaude Opus 5.5 fc64f0ee27 Upscaling Sharpness: CAS sharpen after TAA upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:38:31 +01: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 4a20af007c Framed maps: no slot taken from a map drawn this frame
The atlas holds 256 map pictures. Every framed map in range claimed one, seen or not, so past 256 the maps took slots from each other within one frame and showed each other's pictures. Now a frame's outline is culled before its map claims a slot, the nearest claim first, and a slot shown this frame is never given away: past the slots, the far maps go undrawn.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 14:33:28 +01:00
MSedgeMC aadec58092 port_map: the FrogAi.initCoreActivity panic gap is closed 2026-11-07 13:32:24 +00:00
MSedgeMC a90c5f8179 Test the panic speed a hurt frog's move control reads
Drives the real goal selector on a built test world: hurt the frog with a
PANIC_CAUSES damage, tick the goals once, and assert the panic goal started
and left vanilla's 2.0 in `Navigation.speed_modifier`, the value
`SmoothSwimmingMoveControl` multiplies. Reverting the constant fails it with
1.25 against 2.0.
2026-11-07 13:32:24 +00:00
MSedgeMC c1e649f61b Frogs panic at AnimalPanic's 2.0 speed multiplier
`FrogAi.initCoreActivity` builds the brain's core activity with
`AnimalPanic(2.0F)`, but the goal list used 1.25.

`PanicGoal.start` hands the multiplier to `PathNavigation.moveTo`, and
`SmoothSwimmingMoveControl.tick` computes
`speedModifier * getAttributeValue(MOVEMENT_SPEED)` and then multiplies by
0.1 on land or 0.02 in water. A frog's movement speed is 1.0, so a fleeing
frog should move 0.2 blocks a tick on land and 0.04 in water; at 1.25 it
moved 0.125 and 0.025, 60% slower.
2026-11-07 13:32:24 +00: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
VaspyyyandClaude Opus 5.5 eff292b1e2 Minecarts: keep the rolled loot out of the copies a cart push makes
push_from_ticked_cart cloned the ticked cart into the list and back each tick,
and with it a chest cart's rolled loot: its whole Items list as NBT. The stored
cart already goes without the loot for the tick (tick_carts_now takes it), so
the copies do too and the loot stays with the ticked cart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:20:35 +01:00
Horizon agent a6b863754b Merge origin/main into PR #11672
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-07 14:19:04 +01:00
Horizon agent 04b8ed64ca Merge origin/main into PR #11625
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-07 14:17:57 +01:00
Horizon agent 1c81831b12 Merge origin/main into PR #11533
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
2026-11-07 14:17:31 +01:00
Horizon agent 547c7decbc Merge origin/main into PR #11508
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-07 14:16:30 +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
Horizon agentandClaude Opus 5.5 a52b9e48f3 Merge #11366 (main merged) into #11390
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:37 +01:00
Horizon agentandClaude Opus 5.5 21a27ca73e Merge origin/main into #11433 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:29 +01:00
Horizon agentandClaude Opus 5.5 17e7655cdf Merge origin/main into #11366 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:29 +01:00
Horizon agentandClaude Opus 5.5 f8ab91d422 Merge origin/main into #11445 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 6b72630759 Merge origin/main into #11116 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 d4515b62ec Merge origin/main into #11425 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
MSedgeMC 4583362634 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:26 +00:00
MSedgeMC d00cf662b1 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:20 +00:00
MSedgeMC 6deb10741a port_map: client-007's prepareQuadFlat row cites the renamed helper 2026-11-07 13:07:59 +00:00
Horizon agentandClaude Opus 5.5 7770124ea0 Merge #11366's fix into #11390
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:06:02 +01:00
Horizon agentandClaude Opus 5.5 78ab0c3b39 living.rs landing test: MobWorld's level and dimension_type in place of nether/end (main's #10445 test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:06:02 +01:00
Horizon agentandClaude Opus 5.5 7c7d13c458 Merge origin/main into #11390 (through #11366's resolved main merge)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:01:51 +01:00
Horizon agentandClaude Opus 5.5 ed7114b4a0 Merge origin/main into #11366 (spawn_rules_fixture: main's spawn_selection imports, without the Dimension this PR removes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:01:39 +01:00
Horizon agentandClaude Opus 5.5 acbb98aa5c Merge origin/main into #11433 (keep both data.rs test modules)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:23 +01:00
Horizon agentandClaude Opus 5.5 385877d1fc Merge origin/main into #11445 (keep both probe fixtures)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:13 +01:00
Horizon agentandClaude Opus 5.5 771ccd721f Merge origin/main into #11425 (keep both commands.rs test modules and command_exec imports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:55 +01:00
Horizon agentandClaude Opus 5.5 b9e272b1ab Merge origin/main into #11116 (keep both native-mods.md paragraphs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:18 +01:00
MSedgeMC 8fe6762146 test: compare enchantment durability effects
Add a direct Unbreaking and Mending durability comparison over the real
enchantment registry: item_damage remove_binomial (small and large paths)
and repair_with_xp, with --no-mending reproducing the Unbreaking corpus.
2026-11-07 12:50:41 +00: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
Horizon agentandClaude Opus 5.5 40dd7109bb Merge origin/main into random-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:09:37 +01:00
Horizon agentandClaude Opus 5.5 1db1b34dcb Merge origin/main into perf/11126-structure-starts-jigsaw
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:08:04 +01:00
Horizon agentandClaude Opus 5.5 d30b514728 Merge origin/main into fix/old-stats-file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:06:46 +01:00
DetimesandClaude Opus 5.5 6993f213b6 step_1_21_1: drop the import the explode rewrite no longer uses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:05:41 +00:00
Horizon agentandClaude Opus 5.5 511771b9f9 Merge origin/main into fix/11317-block-crack-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:03:03 +01:00
Horizon agentandClaude Opus 5.5 344382682e Merge origin/main into fix/11314-player-food-predicate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:02:36 +01:00
Detimes bb22bf6570 Merge remote-tracking branch 'upstream/main' into fix-11722-old-pick-item 2026-11-07 11:58:27 +00:00
Detimes edef044a68 Merge remote-tracking branch 'upstream/main' into fix-11721-explode-radius 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
DetimesandClaude Opus 5.5 47fd58c339 Older clients: real explosion radius for 1.21.1, explosions no longer fail for 1.21.8 and older
The 1.21.8 and 1.21.1 explode steps re-read a payload the renumbering had already put in the client's ids with
the pinned codec, so the particle's layout was chosen by the wrong id and sending any explosion failed. They now
read and write through the client's ids. 1.21.1's step read the knockback as three doubles where 1.21.4's step
leaves a double and two floats. 1.21.1's strength is 26.3's radius instead of a fixed 3.0.

Fixes #11721

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:54:53 +00:00
Horizon agent 88a75f983b Merge remote-tracking branch 'origin/main' into cg65v-10210 2026-11-07 12:54:24 +01:00
Horizon agent dbd5775947 Merge remote-tracking branch 'origin/main' into cg65v-10210
# Conflicts:
#	crates/horizon-client/src/game/command_harness.rs
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java
#	tools/test_command_exec_parity.py
2026-11-07 12:39:25 +01: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
Horizon agentandClaude Opus 5.5 5a65831d4a Merge #11483's head (main with the clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:53:46 +01:00
Horizon agentandClaude Opus 5.5 64f9bb1a61 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:53:08 +01:00
Horizon agentandClaude Opus 5.5 991983679c Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:52:41 +01:00
Florian ERNSTandCodex c2b59b6f99 Dispatch ordinary arrow shoot events on their first tick
Co-Authored-By: Codex <[email protected]>
2026-11-07 11:52:23 +01:00
Horizon agentandClaude Opus 5.5 b844b69826 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:51:48 +01:00
Horizon agentandClaude Opus 5.5 c2386a4616 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:50:06 +01:00
Horizon agentandClaude Opus 5.5 c524b40874 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:49:40 +01:00
Horizon agentandClaude Opus 5.5 d53411a24e Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:49:15 +01:00
Horizon agentandClaude Opus 5.5 db503217e3 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:48:22 +01:00
Horizon agentandClaude Opus 5.5 dddf61b000 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:28 +01:00
Horizon agentandClaude Opus 5.5 48986d7fd8 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:46:34 +01:00
exampleandClaude Opus 5.5 7746c0ef78 Merge main: picks up #11716's lint fix for verify
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:49 +01:00
Horizon agentandClaude Opus 5.5 fbbe20dc9c Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:40 +01:00
Horizon agentandClaude Opus 5.5 b9928751e4 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:12 +01:00
Horizon agentandClaude Opus 5.5 ed98ebc8a1 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:44:46 +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
Horizon agentandClaude Opus 5.5 c0ca0cce68 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:43:51 +01:00
Horizon agentandClaude Opus 5.5 984f2d9f06 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:43:23 +01:00
Horizon agentandClaude Opus 5.5 3ed97c7339 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:39:07 +01:00
Riccardo GuerrieroandClaude Opus 5.5 3f1b851c07 Merge origin/main into #11400 (main's 1.95 lint fix for interpolation.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:37:51 +01:00
Horizon agentandClaude Opus 5.5 aa7516532a Merge main; drop run logs and the unrun diagnostic
tools/test_warden_memory_full_diagnostic.py is not a test_*_parity.py harness, so nothing runs it, and it is 816/839 by design. The canonical owned-memories harness stays. Drops the docs/parity run logs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:37:36 +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
Horizon agentandClaude Opus 5.5 43c987818b Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:32:52 +01:00
Horizon agentandClaude Opus 5.5 a94924cd72 Merge origin/main into fix/loot-kill-sheep-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:32:52 +01:00
Horizon agentandClaude Opus 5.5 fa9500a04d Stop pinning camel temper to variant3 in the pose test
The pose test asserted that Temper lands in the legacy variant3 byte. #11483/#11659 move every equine's temper to the signed equine_temper field; the assertion was outside this test's subject (LastPoseTick), so drop it rather than pin the storage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:49 +01:00
Horizon agentandClaude Opus 5.5 81a719f832 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:20 +01:00
Horizon agentandClaude Opus 5.5 029358df0d Merge #11483 (signed equine temper) into fix/camel-inherited-saved-fields
Both PRs added a signed AbstractHorse temper: #11483 for the seven horse and llama species, this one for camels. Keep one field, equine_temper, for every equine: the loader reads Temper unclamped for all of them, temper()/modify_temper() use it for camels too, and the camel saver writes it. The camel_temper field and the camel byte-clamp path are gone; tests follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:16 +01:00
Horizon agentandClaude Opus 5.5 ec62d53993 Merge origin/main into fix/camel-inherited-saved-fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:28:36 +01:00
Horizon agentandClaude Opus 5.5 23b6a5da91 crack feedback tests: give the test guest main's new Joined.configured
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:26 +01:00
Horizon agentandClaude Opus 5.5 1e1e48831a Merge origin/main into fix/11317-block-crack-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:20 +01:00
Horizon agentandClaude Opus 5.5 8a38d8831f Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:21:39 +01:00
Horizon agentandClaude Opus 5.5 4595cfca2f Merge origin/main into fix/loaded-sleeper-bed-position
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:21:38 +01:00
Horizon agentandClaude Opus 5.5 d7889ba66c Merge origin/main into fix/h290-pot-template-compounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:20:30 +01:00
Horizon agentandClaude Opus 5.5 78634140b3 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:19:26 +01:00
Horizon agentandClaude Opus 5.5 5ebfcbdf7f Merge origin/main into fix/world-pack-enchantment-definitions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:19:26 +01:00
Horizon agentandClaude Opus 5.5 c3083e75cb Fog test: clone the start state, FogState is no longer Copy
FogState now holds the torch flicker's LegacyRandom, so main's rain fog
test (settled_rain_fog_is_its_target_and_easing_is_unchanged) moved
`start` twice; verify failed on E0382.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:18:11 +01:00
Horizon agent 67728a8568 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 11:17:34 +01:00
Horizon agentandClaude Opus 5.5 9a1da6539a Merge origin/main into fix/h145-required-function-tags
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:15:52 +01:00
Horizon agentandClaude Opus 5.5 c9af605b1f Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:33 +01:00
Horizon agentandClaude Opus 5.5 b7772e4a84 Merge origin/main into fix/folder-datapack-metadata
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:32 +01:00
Horizon agentandClaude Opus 5.5 199eaad7da Merge #11631 into #11652: one RENAMES table
#11631 adds saved_piece_id (PiecesContainer.load's RENAMES) to the same file; read_jigsaw_start now asks it instead of its own copy of the four old ids. The upper-case id case is dropped from the test: Identifier.parse refuses upper case, so vanilla never reads 'MINECRAFT:JIGSAW' as a jigsaw piece. Land #11631 first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:04 +01:00
Horizon agentandClaude Opus 5.5 2d05c9517f Merge origin/main into #11480
command_exec_corpus.groups: keep main's mob_name_predicate_corpus import beside this branch's player_food_predicate_corpus one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:11:02 +01:00
Horizon agentandClaude Opus 5.5 e3e18fc523 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:09 +01:00
Horizon agentandClaude Opus 5.5 c082361b4d Merge origin/main into fix/function-command-admission
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:09 +01:00
Horizon agentandClaude Opus 5.5 2860d47ffd Merge origin/main into #11666
Main's #11661 added LevelAccess::container_open and its test for the open copper chest; this branch keeps that name and its test and drops its own container_has_viewers, folding the copper golems' chests into GameLevel::container_open. The recheck_container_openers doc comments #11661 left stranded above container_open are back on their function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:07 +01:00
Horizon agentandClaude Opus 5.5 a1ec90f7fc Stats: a joining player's old name-based file moves too
load_guest_records read stats/<uuid>.json directly, and on the server thread (the default single player) and on LAN and dedicated servers every player joins through it, so locateStatsFile only reached the in-frame path. The guest's own name goes to locate_player_json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:59 +01:00
Horizon agentandClaude Opus 5.5 4d9d2cee62 Saved pieces: read an id without its namespace; move tag_json's doc back
Identifier.parse reads 'nvi' as minecraft:nvi, so RENAMES applies to a bare id too. The new saved_piece_id had been inserted between tag_json and its doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:17 +01:00
Horizon agentandClaude Opus 5.5 ed53fe24ad Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:06:33 +01:00
Horizon agentandClaude Opus 5.5 9fc3a4f0fc Merge origin/main into fix/loot-mine-executor-presence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:06:32 +01:00
MSedgeMC c19b0817a0 test: compare falling block landings
Add a direct parity suite for the landing branch of
FallingBlockEntity.tick: real falling entities into air, solid blocks,
water, plants, slabs, cobwebs and moving pistons, with concrete powder's
water contact and the anvil landing event behind --no-contact.
2026-11-07 10:05:19 +00:00
Horizon agentandClaude Opus 5.5 3cb902bb21 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:04:50 +01:00
Horizon agentandClaude Opus 5.5 9f8ce3033d Merge origin/main into fix/camel-loaded-pose-timestamp
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:04:49 +01:00
Horizon agentandClaude Opus 5.5 7cad6a50ff Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:03:19 +01:00
Horizon agentandClaude Opus 5.5 9712ebdde7 Merge origin/main into fix/effective-item-component-validation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:03:18 +01:00
Florian ERNSTandCodex 70cedfb11a Route attribute commands through the selected guest body
Co-Authored-By: Codex <[email protected]>
2026-11-07 11:02:58 +01:00
Horizon agentandClaude Opus 5.5 63d0b51776 Drop the docs/parity run log
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:00:08 +01:00
Horizon agentandClaude Opus 5.5 8917e8a9cb Merge origin/main into fix/llama-saved-temper
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:00:07 +01:00
Horizon agentandClaude Opus 5.5 354ba4a38a Merge origin/main into fix/h097-loaded-air-first-tick
Keep main's fall_step(entities) signature with the air guard first;
keep main's vibration_tests module beside the new test module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:57:58 +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
Alysia Zappavigna 4bb27d7c90 Merge remote-tracking branch 'upstream/main' into pr-11701 2026-11-07 20:48:42 +11:00
Ludovic MarieandClaude Opus 5.5 5e90d9f10b Merge origin/main into #11411; the credits test's Joined takes main's configured field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:18:16 +01:00
Horizon agentandClaude Opus 5.5 faa8a4a52a Merge origin/main into #11413; the glide test's Joined and LanHost take main's new fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:17:40 +01:00
Ludovic Marie 6c0de3509d Merge remote-tracking branch 'origin/main' into w-11413 2026-11-07 10:17:26 +01:00
Horizon agentandClaude Opus 5.5 70ac0a01b8 Merge origin/main into #11457; the silent TNT test passes main's entity boxes (none)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:16:57 +01:00
Ludovic Marie 85a5c9f340 Merge remote-tracking branch 'origin/main' into w-11457 2026-11-07 10:16:57 +01:00
Horizon agentandClaude Opus 5.5 4aaf71dc39 Merge origin/main into #11411; main's spectator portal trips are not a credits respawn
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:10:54 +01:00
Ludovic Marie 6a3359d943 Merge remote-tracking branch 'origin/main' into w-11411 2026-11-07 10:10:44 +01:00
Horizon agentandClaude Opus 5.5 6397e50017 Merge origin/main into #11116; main's block_meets_player names its entity a Player
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:10:20 +01:00
Ludovic Marie fe36131256 Merge remote-tracking branch 'origin/main' into w-11116 2026-11-07 10:10:11 +01:00
TheMrZZandCodex gpt-6.1-sol 50467de40e fix: match typed custom data in mob equipment predicates
Project only typed custom-data snapshots from existing mob equipment
stacks and evaluate the admitted SNBT subset predicate. Preserve prior
view fields and unsupported unprojected-consumer behavior.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 10:05:55 +01:00
Horizon agentandClaude Opus 5.5 a7f5e08990 rustfmt the merge resolution
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:05:52 +01:00
Florian ERNSTandCodex d4d2214cfb Bind loaded Warden memories to their runtime slots
Co-Authored-By: Codex <[email protected]>
2026-11-07 09:59:36 +01:00
Horizon agentandClaude Opus 5.5 8368732e4e Merge origin/main into #11425
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:50:55 +01:00
Horizon agentandClaude Opus 5.5 e8edf93a48 Merge origin/main into #11457
splash takes both the PR's silent flag and main's hit motion (#11388).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:49:08 +01:00
Horizon agentandClaude Opus 5.5 dce6093276 Merge origin/main into #11414
Keep the mining experience cases alongside main's survival-only switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:36:43 +01:00
Horizon agentandClaude Opus 5.5 b8a6663a3a Drop the run report from docs/
The evidence stays in the PR description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:31:02 +01:00
Horizon agentandClaude Opus 5.5 05a81f37b5 Drop the run report from docs/
The evidence stays in the PR description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:30:32 +01:00
MSedgeMC 1bc23d7a17 test: compare nether portal frame search
The portal parity suite only sent entities through portals: the PortalShape
search that turns an obsidian frame into portal blocks was never exercised.
Add a shape family over real frames and a --no-shape switch that reproduces
the previous corpus byte for byte.
2026-11-07 08:16:39 +00:00
Alysia Zappavigna e297f7245b ambience tests: import SoundEngine 2026-11-07 19:09:01 +11:00
Alysia Zappavigna 640dd0bcf5 ambience tests: import SoundEngine 2026-11-07 19:05:56 +11:00
Alysia Zappavigna 175fbe9e52 Merge commit '37d8dc0e5' into random-source
# Conflicts:
#	crates/horizon-client/src/game/lightning.rs
2026-11-07 18:56:19 +11:00
Alysia Zappavigna fde182860f random_source: repeat under HORIZON_STILL; tidy imports 2026-11-07 18:56:10 +11:00
Alysia Zappavigna 3fc0eb473e random_source: one RandomSource.create(); entity and level randoms as vanilla 2026-11-07 18:46:43 +11:00
Florian ERNSTandCodex f9eb279790 Bind actual mob target state in loot-kill predicates
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:46:18 +01:00
exampleandClaude Opus 5.5 69ca4f94b7 Waxing a double copper chest reaches both halves; an open one doesn't weather
HoneycombItem.useOn gives a double chest's other half its game event and
particles and plays a second sound from the clicked block, on the server and
in the client's prediction. The wax sound leaves out the acting player and
goes out in the blocks source.

WeatheringCopperChestBlock.randomTick skips the attempt, and its draw, while
getEntitiesWithContainerOpen has anyone: a player's menu, or a copper golem
(CopperGolem.hasContainerOpen), a double chest counting for both halves.

Fixes #11378

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:24:54 +01:00
Florian ERNSTandCodex b322275555 Save Camel inherited horse fields from modeled state
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:11:46 +01:00
TheMrZZandCodex gpt-6.1-sol b8d3a3b132 fix: keep vanilla crafter drops unnamed
Exclude actual crafters from automatic dropped-item name capture while
preserving chest/shulker names, contents and the other copy policies.
Cover the real destruction route against a frozen fresh Java reference.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 07:53:50 +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
MSedgeMC 03c5e6b091 port_map: PlayerList.locateStatsFile is ported
The row said the name-based stats file is not moved to the uuid file. It names the function that
does it, and the segment rule that decides whether vanilla would look for it at all.
2026-11-07 05:49:55 +00:00
MSedgeMC f4cdc0873c test: pin the old stats file moving to the uuid name
The test works in a temporary world folder: nothing is written when there is nothing to move, the
`players/stats/<name>.json` file lands on the uuid name and is not left behind, a uuid file that
already exists wins and leaves the old file alone, and the segment check refuses capitals and
separators while accepting a lowercase name with dashes, dots and digits.
2026-11-07 05:49:55 +00:00
MSedgeMC f802d6cc2c fix: move an old name-based stats file to the player's uuid
PlayerList.locateStatsFile: the stats file is `stats/<uuid>.json`, but a version that wrote
`stats/<player name>.json` left its numbers under the old name. When the uuid file does not exist
yet and the name is one vanilla would write as a path segment
(FileUtil.isValidPathSegment: `[-._a-z0-9]`, and not `.` or `..` — the check is on
`<name>.json`, so a capital letter in the player's name refuses it), the old file is moved to the
uuid name and that is the file that is read; a failed move leaves the old one in place.

Horizon always resolved the uuid path, so a world opened after such a version started with empty
stats. The 26.3 advancements file has no name-based form (`PlayerList.getPlayerAdvancements`
resolves the uuid directory directly), so only stats move.
2026-11-07 05:49:55 +00:00
k3yhandClaude Opus 5.5 29a511747d Selectors find cushions
A cushion is an ordinary entity in vanilla (a BlockAttachedEntity), but
Horizon had no command target for it: @e never matched one, so execute
if entity, tag, data get, tp and kill missed every cushion. Target::Cushion
joins the level's candidates like a painting; /kill removes it with no
drop and lets its rider go, /tp moves it with its block, data get writes
it through cushion_to_nbt.

Fixes #11626.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 00:44:33 -05:00
MSedgeMC 3afa8e234b port_map: the two saved-start rows say which piece ids are read
PiecesContainer.load now names saved_piece_id, where the rename table lives,
and StructureStart.loadStaticStart spells out that a jigsaw start (the renamed
old ids included) comes back from its saved pieces while another structure's
start comes back from the generator's rebuild of its chunk.
2026-11-07 05:41:18 +00:00
MSedgeMC efe0b28747 test: the four old jigsaw piece ids are read, other piece types are not
A piece tag named minecraft:nvi, pcp, bastionremnant or runtime comes back as
jigsaw, as does minecraft:jigsaw itself; a monument, stronghold or jungle
temple piece (OMB, SHCC, TeJP) comes back as nothing so the start keeps the
generator's rebuild, and so does a tag with no id or one that is not a
compound. With the table narrowed to minecraft:jigsaw alone the first four
fail.
2026-11-07 05:41:18 +00:00
MSedgeMC c7d9d1aedd fix: a saved start's pieces use the ids an old save wrote for jigsaw
PiecesContainer.load maps four piece ids - minecraft:nvi, minecraft:pcp,
minecraft:bastionremnant and minecraft:runtime - to minecraft:jigsaw before it
looks the type up: a save from before those pieces were jigsaw wrote them under
the older names with the same pool-element tag. The saved-start reader only
took the literal minecraft:jigsaw id, so such a start fell through to the
generator's rebuild of its chunk, and where that rebuild makes a different
layout (a world whose structure data or version moved on) the start came back
with no pieces at all: nothing placed, nothing bearding the terrain.

saved_piece_id now holds that rename table for the reader; a piece of another
type (a monument's or a stronghold's, whose NBT is not a pool piece's) still
sends the start to the rebuild.
2026-11-07 05:41:18 +00:00
Florian ERNSTandCodex 6c6efb8d74 Refresh loaded sleeper position from bed geometry on first tick
Co-Authored-By: Codex <[email protected]>
2026-11-07 06:31:19 +01:00
TheMrZZandCodex gpt-6.1-sol fc711c3f6d fix: save pot sherd string templates as item compounds
Encode bare item templates using the primary record form while preserving
compound counts and components. Cover actual canonical load/save and the
real command route against a frozen fresh Java reference.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 06:14:22 +01:00
k3yhandClaude Opus 5.5 945f40e8ff /ride takes armor stands, displays, interactions and markers as vehicles
RideCommand.mount forces the ride (startRiding(vehicle, true, true)), so
any entity takes a rider. Horizon only took boats, carts and mobs. A rider
now sits at the vehicle's default passenger attachment (its box height,
less the rider's own vehicle attachment) and gets off on sneak on top of
it, as Entity does for a vehicle that overrides neither.

Fixes #11603.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:40:02 -05:00
Florian ERNSTandCodex 4aeb14b3ba Load active world-pack enchantment definitions
Co-Authored-By: Codex <[email protected]>
2026-11-07 05:35:26 +01:00
k3yhandClaude Opus 5.5 b9854b1d82 /ride seats and unseats any server player, whoever runs it
RideCommand.mount and dismount work on any ServerPlayer in vanilla. Horizon's
/ride reached only the player in place, so the console, a function, a command
block or another player could not seat a guest (every player of a server or a
LAN world, and in single player too): the guest's turn now runs through
for_player, as native_mount does. A guest as the vehicle answers
cant_ride_players, as any player does.

Fixes #11602.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:16:44 -05:00
TheMrZZandCodex gpt-6.1-sol f3fcb0ca25 fix: reject function tags with unresolved required references
Propagate required dependency failures before expanding function tags so
invalid tags cannot run partial members. Skip whole invalid optional children
while preserving the existing ordered, deduplicated expansion.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 04:38:01 +01:00
Florian ERNSTandCodex 84f6959921 fix: require folder data-pack metadata sections
Reject folders without parseable metadata, an object pack section and a non-null description before exposing their contents. Preserve existing parser and format behavior.

Co-Authored-By: Codex <[email protected]>
2026-11-07 03:35:13 +01:00
Vinícius MoreiraandClaude Opus 5.5 f2c9af4827 Environment attributes: the registry and modifiers, checked against vanilla
Ports vanilla 26.3's environment attribute registry (EnvironmentAttributes:
types, defaults, ranges) and one EnvironmentAttributeMap entry: a value or
a {modifier, argument} object, parsed as its codec does (hex, list and int
colors, FloatWithAlpha, BlendToGray) and applied as AttributeModifier.apply
with float math in Java's order (ARGB.alphaBlend, addRgb, subtractRgb,
multiply, greyscale, scaleRGB, srgbLerp; Math.min/max). The layer stack
that sums these comes next; nothing uses the module yet.

The new harness environment_attribute_modifier compares 8,835 cases with
the pinned client, the registry included.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:07:36 -03:00
Florian ERNSTandCodex 0de6eeb024 fix: reject functions containing invalid ordinary commands
Validate candidate functions at actual compilation permission before exposing them, using refreshed world content and candidate registry IDs.

Co-Authored-By: Codex <[email protected]>
2026-11-07 02:55:05 +01:00
Kevin RamirezandClaude Fable 5.1 3aadba6aba CI: run verify again after a wall-clock test flaked on the slow runner
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 19:43:57 -06:00
voidsip 8c991c7472 Isolate trade parity runs and format modifiers 2026-11-06 19:39:51 -06:00
Florian ERNSTandCodex 593e9e05cc Carry actual loot-mine executor presence into block drops
Co-Authored-By: Codex <[email protected]>
2026-11-07 02:20:33 +01:00
voidsip e03f69d607 fix: spill contents of items destroyed by explosions 2026-11-06 19:16:27 -06:00
Horizon agent eadca0ce30 Merge main into #10242
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-07 02:01:07 +01:00
Horizon agent 3e88f5909b Merge main into #10210
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-07 02:00:23 +01:00
Horizon agent d64be662fe Merge main into #10559 2026-11-07 01:53:52 +01:00
Horizon agent 3b3f6aa00f Merge main into #10552 2026-11-07 01:53:46 +01:00
Horizon agent e2268eb854 Merge main into #10201 2026-11-07 01:53:35 +01:00
Florian ERNSTandCodex 8b9e8417af Preserve loaded Camel pose timestamp boundaries
Co-Authored-By: Codex <[email protected]>
2026-11-07 01:42:37 +01:00
Kevin RamirezandClaude Fable 5.1 1426129516 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:33:12 -06:00
voidsip a932bc6c86 Create recipe directory for isolated smithing menu fixture 2026-11-06 18:28:12 -06:00
Florian ERNSTandCodex 2aa9308e47 Reject stackable damageable item command inputs
Co-Authored-By: Codex <[email protected]>
2026-11-07 01:04:42 +01:00
Kevin RamirezandClaude Fable 5.1 23c572b374 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:03:09 -06:00
voidsip 8158bcae73 Use the item codec crate in the mining parity probe 2026-11-06 17:45:50 -06:00
voidsip 62724ffccf Record scoped mining parity source and artifact hashes 2026-11-06 17:41:16 -06:00
DUzzL 52105d2c9b Adventure mode: gate placed entities and firework on can_place_on 2026-11-07 00:38:11 +01:00
voidsip 9782763e82 Cover mining component admission and explicit break slots 2026-11-06 17:35:52 -06:00
voidsip 2eb6044dfc Keep remote mining fixture compatible with speed cache 2026-11-06 17:34:56 -06:00
Florian ERNSTandCodex dd47001112 Record signed equine temper proof and scope
Co-Authored-By: Codex <[email protected]>
2026-11-07 00:32:23 +01:00
Kevin RamirezandClaude Fable 5.1 6564896c45 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 17:23:11 -06:00
Florian ERNSTandCodex b775c66123 Preserve signed equine temper independently of gameplay limits
Co-Authored-By: Codex <[email protected]>
2026-11-07 00:10:40 +01:00
voidsip e3295a9974 Trim gateway fixture trailing blank line 2026-11-06 17:10:23 -06:00
voidsip 8554155932 Remove obsolete saved gateway arrival helper 2026-11-06 17:00:00 -06:00
MSedgeMC d114d2ef89 test: a stub with inline data or no .mcc still reads the .mcc
Without the change the first reads `Err(InvalidData, "external chunk stub has
inline data")` and the second `Err(Os { code: 2, kind: NotFound })` instead of
the 4000 bytes in `c.-32.0.mcc` and an absent chunk.
2026-11-06 22:56:48 +00:00
MSedgeMC 30a3b019ff fix: read a chunk's external stream the way RegionFile reads it
`RegionFile.getChunkDataInputStream` never trusts the stub's length once the
external flag is set: `streamLength != 0` only logs "Chunk has both internal and
external streams" and the stream comes from the `.mcc`. When the `.mcc` is not a
regular file, `createExternalChunkInputStream` answers `null`, so the chunk is
absent. Horizon refused both: a stub that also declared inline data failed with
"external chunk stub has inline data", and a stub whose `.mcc` was gone failed
with a `NotFound` io error, which aborts a reader where vanilla skips the chunk.
2026-11-06 22:56:47 +00:00
voidsip 4807f13288 Format world border regression assertions 2026-11-06 16:56:09 -06:00
TheMrZZandCodex gpt-6.1-sol 1d72cf2ed8 Discard loaded falling air before age and physics
Use the existing air predicate and Gone result at the start of the actual falling-block tick. Cover admitted command actors with a real Game tick regression and frozen Java parity controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 23:53:32 +01:00
brasgaandClaude Opus 5.5 5b9fe567ec Player predicates test food level and saturation (#11314)
PlayerPredicate.matches calls FoodPredicate.matches on the player's
FoodData: the food level in `level`, then the saturation, a float
compared as a double, in `saturation`. The player facts carried no food
and the matcher never read `food`, so any food bounds passed.

PlayerFacts now holds the food, always filled (two numbers), for the
player in place and for a server's other players, and player_facts tests
it after the level, as vanilla does.

The command_exec harness gets a group that fills and drains the player's
food in player loops and tests named food predicates, a non-player, and
a tick advancement whose player condition asks only for food.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:53:29 -03:00
DUzzL 9558ecf848 Keep the copper golem's name on its statue 2026-11-06 23:49:15 +01:00
Kevin RamirezandClaude Fable 5.1 d6802414fa CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 16:43:06 -06:00
DUzzL a7d2edbfb9 Beacon: select players by bounding-box overlap with the effect box 2026-11-06 23:37:57 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 5d44519d5b Frame identity: keep the player on a block while the scenes are built
Left in the air at y 163, the player fell into the sea below while prepare() ran, which unlocked the boat
recipes: their toast showed over the first shots at a different moment each run, so main against itself
failed in the top right corner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:27:38 +01:00
voidsip 7914cfe741 Keep saved-creeper radius test compatible with silence metadata 2026-11-06 16:23:43 -06:00
MSedgeMC c3326aed5e test: direct furnace block-entity tick parity
The command-execution furnace family could not give a checkable gap control
on this host, so this is a direct AbstractFurnaceBlockEntity.serverTick
harness instead: a real RecipeManager and a furnace, blast furnace or smoker
in a block-update level, compared with FurnaceCooking::tick. It pins the
cooking_fuel burn table for every fuel item, the 200/100/100 cook times and
speed multiplier, fuel consumption and LIT state, RecipesUsed experience, a
full output slot, timer decay, and an input changed mid-cook. --no-change
reproduces the corpus before that family.

757/757 cases match the pinned client.
2026-11-06 22:20:04 +00:00
voidsip 088b9e37e5 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11330-explosion-item-health 2026-11-06 16:13:47 -06:00
voidsip 98cfa7ef55 Merge current upstream main into stack tool mining fix 2026-11-06 16:13:44 -06:00
voidsip a79aa4f977 test: add headless dropped-item explosion regression 2026-11-06 16:13:38 -06:00
voidsip 55d7ad61f1 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11328-trade-modifiers 2026-11-06 16:13:33 -06:00
voidsip 2b839bce3d Keep smithing regression compatible with baseline APIs 2026-11-06 16:13:32 -06:00
voidsip 929ad64e2a Match trade enchantment modifiers and provider costs 2026-11-06 16:13:26 -06:00
voidsip 5345f12b9d Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/smithing-transform-11307 2026-11-06 16:12:56 -06:00
voidsip b1e79509ac Fix smithing transform optional inputs and result templates 2026-11-06 16:12:45 -06:00
voidsip bf40027865 Fix stack tool mining rules and immersion checks 2026-11-06 16:12:44 -06:00
voidsip 0289686b53 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11317-block-crack-feedback 2026-11-06 16:12:21 -06:00
voidsip eeb3c04215 Match server crack stages and observer range 2026-11-06 16:12:13 -06:00
voidsip 9af63f81d3 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11319-gateway-item-motion 2026-11-06 16:12:06 -06:00
voidsip 4fd7c5c271 Keep dropped items live through End gateways 2026-11-06 16:11:57 -06:00
voidsip c0aef4ac41 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/round5-5-11321 2026-11-06 16:09:25 -06:00
voidsip 9e11182d53 Respect world borders for pistons and natural spawning 2026-11-06 16:09:24 -06:00
voidsip 854e12bf62 Merge upstream main for source-silence validation 2026-11-06 16:08:31 -06:00
MrCloudy2andClaude Opus 5.5 858582b279 Droppers insert into chest boats and container minecarts
DropperBlock.dispenseFrom finds its target with HopperBlockEntity.getContainerAt:
the block's container, else a container entity (chest boat or raft, chest or
hopper minecart) whose box meets the space in front, picked at random. Horizon's
dropper only asked for a block container, so items fell out next to a chest boat
instead of going in. It now falls back to the entity lookup hoppers already use.

The dispenser harness gains 48 dropper cases with chest boats, rafts and chest
and hopper minecarts centred in front, reaching in from beyond, just past the
space, two at once, behind a chest block and non-container vehicles. The oracle
and the fixture print the container entity's items (Tinv lines).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:06:16 +01:00
voidsip c06d1d81c4 Respect Silent on explosions and potion impacts 2026-11-06 16:06:09 -06:00
Florian ERNSTandCodex a44cdec48e Adopt live falling-block data into the resident entity
Co-Authored-By: Codex <[email protected]>
2026-11-06 23:03:23 +01:00
Kevin RamirezandClaude Fable 5.1 03e72bc0b0 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 16:03:08 -06:00
MSedgeMC 197ad9fcc5 port_map: Phantom's anchor_pos is read and written
`tools/port_map/game2-002.tsv` recorded `gap: minor: anchor_pos is not read` and
`not written` for `Phantom.readAdditionalSaveData` and
`Phantom.addAdditionalSaveData`. Both now carry the anchor and say how: the key
only for a phantom that has an anchor, absent or unreadable as no anchor.
2026-11-06 22:00:10 +00:00
MSedgeMC 58feda2a09 test: a phantom's circling anchor through the chunk save
`mob_from_nbt` and `mob_to_nbt` are the pair `Game::load_mob` and
`Game::mob_tree_nbt` go through, so the test loads and saves a phantom by its
compound. A phantom at (0.5, 75, 0.5) whose save names `anchor_pos: [100, 64,
-40]` loads with that anchor, circles to (109.66, 62, -37.41) - the anchor,
`distance*cos(angle)` across and `-4 + height` up - and a phantom that moved its
anchor to (9, 70, -3) saves that position, not the one its compound came in
with. A phantom with no anchor loads with none and saves no `anchor_pos`.

Without reading the anchor the load gives the phantom (0, 80, 0), its own
position five blocks up, and the circle point (9.66, 78, 2.59); without writing
it the save keeps the stale (100, 64, -40).
2026-11-06 22:00:10 +00:00
MSedgeMC c02c215c62 fix: save a phantom's circling anchor with its chunk
`Phantom.addAdditionalSaveData` writes the anchor the phantom circles around
beside its size, and `readAdditionalSaveData` takes it back:

    protected void readAdditionalSaveData(final ValueInput input) {
       super.readAdditionalSaveData(input);
       this.anchorPoint = input.<BlockPos>read("anchor_pos", BlockPos.CODEC).orElse(null);
       this.setPhantomSize(input.getIntOr("size", 0));
    }

    protected void addAdditionalSaveData(final ValueOutput output) {
       super.addAdditionalSaveData(output);
       output.storeNullable("anchor_pos", BlockPos.CODEC, this.anchorPoint);
       output.putInt("size", this.getPhantomSize());
    }

Horizon models that anchor in `extra.home`, which
`PhantomCircleAroundAnchorGoal.selectNext` centres the circle on
(`anchor + (distance*cos(angle), -4 + height, distance*sin(angle))`), but
`mob_to_nbt` wrote only `size` and `mob_from_nbt` read only `size`. A phantom
saved and reloaded therefore kept the anchor `register` gives every new phantom
- its own position five blocks up, vanilla's `finalizeSpawn` value - instead of
the one in its save: a phantom at (0.5, 75, 0.5) loaded with
`anchor_pos: [100, 64, -40]` came back anchored at (0, 80, 0), and its first
circle point was (9.66, 78, 2.59) rather than (109.66, 62, -37.41).

`TagValueOutput.storeNullable` writes the key only for a non-null value, and
`read(...).orElse(null)` gives null for a save without one, so a phantom with no
anchor now writes no `anchor_pos` and loads back with none - the circle then
fills it with its own `blockPosition()`, as vanilla's does.
2026-11-06 22:00:10 +00:00
TheMrZZandCodex gpt-6.1-sol 008aba983e Spill explicit vehicle container contents on kill removal
Write back live menu slots before spilling chest boats, chest rafts and
container carts. Reuse the container split/motion algorithm through its
actual double-origin path, preserving integer callers and empty-slot draws.
Keep ordinary hurt, hull, lead and pending-loot policies separate.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 22:45:56 +01:00
Delete228andCodex 0c66644118 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
Kevin RamirezandClaude Fable 5.1 fdc26c707e CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:43:30 -06:00
Riccardo Guerriero 01b3be1502 Merge remote-tracking branch 'origin/main' into perf/11126-structure-starts-jigsaw 2026-11-06 22:42:17 +01:00
voidsip 9c292718aa Persist comparator output and hopper transfer cooldown 2026-11-06 15:40:57 -06:00
Florian ERNSTandCodex 7b53924423 Use modern falling-block state decoding and sand fallback
Co-Authored-By: Codex <[email protected]>
2026-11-06 22:36:12 +01:00
Kevin RamirezandClaude Fable 5.1 d4984f2ca0 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:36:08 -06:00
DUzzL dd2d21f0f9 Fix natural spawn height bound off by one
NaturalSpawner.getRandomPosWithin samples up to chunk.getHeight(WORLD_SURFACE)+1,
and ChunkAccess.getHeight is firstAvailable-1, so the bound is firstAvailable.
Horizon already stored firstAvailable and added one more. Also correct the
oracle's fake LevelChunk.getHeight.
2026-11-06 22:30:06 +01:00
umcekoandClaude Sonnet 5.5 5fa72fe13a Gameplay reads the dimension type for lava, water, beds and anchors (#11282, 3/9)
Fast lava, water evaporation, bed and anchor rules, the raid-bright check, the
day timeline, ambient light and the default clock of /time read the level's
dimension type facts instead of matching on the level, as vanilla reads
DimensionType and its attributes. No behaviour change for the three built-in
levels.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
umcekoandClaude Sonnet 5.5 0b23212f49 The dragon fight follows has_ender_dragon_fight (#11282, 5/9)
The fight tick, end crystal fire and respawn, the boss bar, the fight dragon
check, the exit portal and the DRAGON chunk ticket read the dimension type's
has_ender_dragon_fight, and the End flashes read its End skybox. No behaviour
change for the three built-in levels.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
umcekoandClaude Sonnet 5.5 5db025666c Mobs and spawn rules read the dimension type (#11282, 4/9)
MobWorld carries the level key and its dimension type facts instead of nether
and end flags, and the spawn light test samples the type's
monster_spawn_light_level as vanilla's DimensionType.MonsterSettings does.
Fast lava, snow golem melting, piglin zombification, fixed-time and day
timeline checks read the type's facts. No behaviour change for the three
built-in levels; random draws are unchanged.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
voidsip c558b29549 Honor block drops and RNG order for mining XP 2026-11-06 15:19:27 -06:00
DUzzL 37f8ebb618 Read scaffolding as its stable shape in fluid wall tests 2026-11-06 22:15:14 +01:00
Kevin RamirezandClaude Fable 5.1 ab98869192 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:14:22 -06:00
DUzzL bedf076c56 Read stack components in anvil and grindstone results 2026-11-06 22:10:39 +01:00
Kevin RamirezandClaude Fable 5.1 7416b4a5f6 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:57:15 -06:00
26210e2da4 Read saved jigsaw structure starts before regenerating them
Part of #11126.

Load complete jigsaw starts from the owning chunk's structures.starts into
its shared generator cache, retaining regeneration for unsupported or
undecodable starts. Read pending saves in every dimension and preserve
reference counts alongside the original piece layout.

Cover resource-free round trips, generated layouts across two seeds and
pending-record loading from a different referencing chunk.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 21:50:30 +01:00
Kevin RamirezandClaude Fable 5.1 b97af5d3da CI: run verify again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:28:11 -06:00
rdbtCVSandClaude Opus 5.5 5c05eacc6d Let the remote motion handler reset a trident's stuck timer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:07 +00:00
rdbtCVSandClaude Opus 5.5 ed8eaed845 Merge origin/main into PR #10201
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:11 +00:00
rdbtCVSandClaude Opus 5.5 0a6d5eb0a7 Merge origin/main into PR #10511
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:09:33 +00:00
Kevin RamirezandClaude Fable 5.1 ef6f7a2170 Sky darkening truncates the light level, as updateSkyBrightness does (#11323)
Level.updateSkyBrightness is `(int)(15.0F - skyLightLevel)`: the float
truncated. Horizon's environment::sky_darken (and the End copy in
Game::sky_darken) rounded, stepping the darkening up half a level early
at dusk and late at dawn, which moves bed eligibility and the light-
dependent spawns with it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:00:13 -06:00
Riccardo Guerriero 425b65a99f Merge remote-tracking branch 'origin/main' into fix/w149 2026-11-06 20:45:56 +01:00
a222a7e47a End a server player's glide on a climbable like vanilla
LivingEntity.travelFallFlying stops the glide on a climbable, and a
server simulates its players' travel (AUTHORITATIVE_SIDE_AND_SERVER).
Horizon's server travel cleared the flag only on the guest's temporary
body; as_guest now copies it back, so the glide ends on the server and
the client, which takes the flag from the server, stops gliding too.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:45:15 +01:00
c4dcf2d2c8 Wait for the client's load after the End credits respawn
handleClientCommand(PERFORM_RESPAWN) for a player who won the game
calls restartClientLoadTimerAfterRespawn, as the death respawn does: the
player's movement and actions wait, and isInvulnerableTo spares it, until
its client reports the level loaded or 60 ticks pass. Horizon restarted
the timer only after a death.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:41:08 +01:00
be7591c70b Drop a converting piglin's inventory like vanilla
Piglin.finishConversion cancels the admire, then drops every non-empty
stack of its inventory with spawnAtLocation (whole stacks, components
kept), then converts; the converted sound comes before all of it. Horizon
dropped only the admired off-hand item, before the sound, and the
inventory was lost with the piglin.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:57 +01:00
Delete228andCodex be3fbc2d2b Supply entity kinds in collision probe fixtures
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
Delete228andCodex 7af3e1ec44 Test stateful mod block entity lookup in the tooling fixture
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
Delete228andCodex 79fe067a7e Declare the state factory with the GameLevel hook points
Co-authored-by: Codex <[email protected]>
2026-11-06 16:41:39 +03:00
cb62d074db Search neighboring columns for dolphin breathing like vanilla
Match vanilla's column order and fallback when choosing a breathing destination.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
Delete228andCodex 796db06bf8 Resolve mod block shapes from entity context
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:32:57 +03:00
Delete228andCodex 177548d416 Create native block entities from their actual block state
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:47 +03:00
Horizon agentandClaude Opus 5.5 5ca811b47a Merge main into #10210; drop the packet trace and receipts
The merchant fixture compares strict command and menu records, so the raw
packet trace (net/command_trace.rs in the client receive loop, the Java
trace reader, the oracle's serialized channel) and the per-operation
receipts were diagnostics only. The production client no longer writes or
joins anything for them; tools/test_merchant_gate_parity.py is a thin
wrapper like the other command_exec fixtures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:04:15 +01:00
Horizon agent 7c32d1c4d6 Merge main into #10242
# Conflicts:
#	crates/horizon-items/src/item_data.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 13:01:20 +01:00
Horizon agentandClaude Opus 5.5 2ce4e8da11 Reset a remote trident's life on motion; cover lerpMotion in the remote motion test
ThrownTrident inherits AbstractArrow.lerpMotion, which restarts life even
when the motion is zero. The remote motion test compiles remote_motion out
of remote.rs, so its fixture needs the arrow fields; it now also checks
that zero motion keeps a stuck arrow in place and non-zero motion frees it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:38:30 +01:00
Horizon agent 3f1af338cb Merge main into #10201 2026-11-06 12:37:53 +01:00
rison1337andCodex GPT-6 786bd54096 Merge main to pick up mob_to_nbt build fixes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:33:17 +05:00
Riccardo Guerriero 16cdc3afd9 Merge remote-tracking branch 'origin/main' into perf/9996-surface 2026-11-06 10:27:43 +01:00
rison1337andCodex GPT-6 69d388deb0 Reuse completed armor-trim preparation between frames
Keep the completed ordered request list for the current atlas so an
unchanged overflow does not regenerate and upload every trim each frame.
Preserve final LRU residents, retry failures, and invalidate the cache
when equipment, atlas contents or the graphics device changes.

Fixes #10539

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 14:01:59 +05:00
Riccardo GuerrieroandClaude Opus 5.5 0855321d41 A surface run above the minimum surface level stops there
Below the level the band takes its other variant, which may give another
block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 09:50:34 +01:00
Riccardo GuerrieroandClaude Opus 5.5 db6a8b7f44 Write surface rule runs of one block at once
`build_surface_shared` applies the compiled rule at every solid block of a
column. In the Overworld most bands specialise to "the ore veins, then one
block" (deepslate), so 1.7M rule applications per 64 chunks produce ~1M block
writes, each walking the rule tree from the top and going through
`set_block`/`set_block_worldgen_column`.

A band variant that is a block rule, or ore veins followed by one, gives that
block at every position where no vein places one. The walk now writes the whole
run of solid blocks at once: the top y through the single-block path, the rest
through `TerrainChunk::fill_solid_run`, stopping at the first y where a vein
can place a block (each vein's first test, its density), at the band's bottom
and at the write bounds. Only blocks that are opaque for the stage's heightmaps
and carry no fluid take this path, so one update at the run's top leaves the
maps as a write per block does.

`CompiledRule::apply` on a sequence also tests its conditional children in the
loop instead of through a recursive call.

Instructions (callgrind, proto phase, 64 chunks, seed 12345): Overworld
6.49 G -> 6.03 G (-7.0%), surface stage 1.98 G -> 1.53 G (-23%), rule
applications 599 M -> 329 M; Nether 2.97 G -> 2.73 G (-8.1%).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:50:34 +01:00
Florian ERNSTandCodex 3443abbde5 Keep superclass air refill for NoAI Nautilus
Skip only the Nautilus custom air handler under NoAI, preserving the existing Breath/drown path and normal custom behavior. Cover actual Game and full Mob ticks against fresh Java.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:59:32 +01:00
TheMrZZandCodex gpt-6.1-sol 93365662aa Save default icon flags in custom potion effects
Resolve an absent saved show_icon from show_particles while preserving
explicit icon bytes and the remaining potion fields. Keep this in the
saved item encoder, without changing the general component view.

Add resource-free decoded/modelled codec coverage and a guarded,
zero-tick chest command regression against the pinned Java behavior.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 21:52:58 +01:00
TheMrZZandCodex gpt-6.1-sol 751423b897 Defer villager trade reputation to the eligible AI phase
Retain the real last trader on the villager when a trade is accepted,
then deliver the coalesced reputation event after Brain.tick. Keep it
across menu close and live NoAI edits, and refresh current offer prices
when the event actually runs.

Add an actual-owner merchant regression with complete raw capture and
separate strict-all byte-order diagnostics.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 20:39:57 +01:00
DUzzL 219a56f260 Unstick arrows and tridents the server sets moving (AbstractArrow.lerpMotion) 2026-11-05 20:27:46 +01:00
614 changed files with 40812 additions and 2977 deletions

No files matched your search

+5 -2
View File
@@ -46,8 +46,11 @@ original classes, game shaders, textures or prepared resources are distributed h
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
(`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
+2 -2
View File
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
fn joml_sin(rad: f32) -> f32 {
pub fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
Some(Tag::compound(out))
}
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
/// Encoding uses the primary record form, including for a bare item id.
fn template(tag: &Tag) -> Option<Tag> {
match tag {
Tag::String(_) => Some(tag.clone()),
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
Tag::Compound(_) => {
let mut out = vec![("id", string(&text(tag.get("id"))?))];
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
@@ -1011,3 +1012,69 @@ mod tests {
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
}
}
#[cfg(test)]
mod pot_template_tests {
use super::*;
use horizon_world::nbt::parse_snbt;
#[test]
fn decorated_pot_string_template_load_save_uses_item_compound() {
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
let input = |front: &str| {
let mut tag = parse(
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
);
let Tag::Compound(root) = &mut tag else {
unreachable!()
};
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
unreachable!()
};
sides.insert("front".into(), parse(front));
tag
};
let other_fields = |loaded: &Tag, saved: &Tag| {
assert_eq!(saved.get("id"), loaded.get("id"));
for key in ["x", "y", "z", "item"] {
assert_eq!(saved.get(key), loaded.get(key), "{key}");
}
let loaded_sides = loaded.get("sherds").unwrap();
let saved_sides = saved.get("sherds").unwrap();
for side in ["back", "right"] {
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
}
assert_eq!(saved_sides.get("left"), None);
};
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
// Actual canonical load/save entry, not the private template helper.
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
let loaded = input(front);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(expected))
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
assert_eq!(loaded, original, "read-only canonicalization");
}
println!(
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
);
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(loaded, original, "read-only canonicalization");
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(plain)),
"string template must save the primary item compound",
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
}
}
+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()
@@ -245,6 +245,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.fxaa,
settings.taa,
settings.upscale,
0.85,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
+259 -5
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
@@ -1071,12 +1161,13 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
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")]
@@ -1232,12 +1323,13 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
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).
@@ -1482,7 +1618,7 @@ impl Audio {
"minecraft:ui.button.click",
1.0,
0.25,
SoundEngine::unseeded(),
crate::random_source::create(),
),
false,
);
@@ -1624,7 +1760,7 @@ impl Audio {
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
@@ -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
@@ -77,6 +77,9 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// State-aware native factory ownership. Shared across level snapshots;
/// an empty slice means the declarations have not yet been compiled.
stateful_entities: Arc<[bool]>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
@@ -209,6 +212,21 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
self.stateful_entities = states.into();
}
pub fn has_stateful_entity_table(&self) -> bool {
!self.stateful_entities.is_empty()
}
pub fn stateful_entities(&self) -> Arc<[bool]> {
self.stateful_entities.clone()
}
pub fn stateful_entity(&self, state: u32) -> bool {
self.stateful_entities
.get(state as usize)
.copied()
.unwrap_or(false)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
@@ -3,20 +3,22 @@
//! decorated pots, conduits, enchanting-table and lectern books and copper
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
//! vanilla renderer. Faces carry their tint and world normal: the entity
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
//! vanilla's does.
use crate::{
Result,
bidi::visual,
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};
@@ -128,6 +130,14 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
}
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
/// the entity shader and a shader pack both shade by it. Block-entity models
/// carry no normals of their own (vanilla computes each face's from its
/// polygon, then `minecraft_mix_light` uses it in the shader).
fn pack_normal(normal: Vec3) -> [i8; 4] {
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
[c(normal.x), c(normal.y), c(normal.z), 127]
}
/// Everything a draw call needs besides the geometry.
#[derive(Clone, Copy)]
@@ -165,6 +175,7 @@ pub fn draw_part(
light,
lights,
glow,
..
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -177,11 +188,21 @@ pub fn draw_part(
} else {
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
// The colour is the tint alone when the caller shades by the level's
// lights: the entity shader applies `minecraft_mix_light` from the
// normal, as vanilla does, so a shader pack receives the unshaded
// colour Iris gives it (a baked shade would be applied a second time
// by the pack's lighting). Other lights (an item preview's
// `Lighting.Entry`) are not the shader's to know, so those stay baked
// into the colour, as before.
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
let color = pack_color(tint, 255, brightness);
let normal = if baked { [0; 4] } else { pack_normal(normal) };
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
normal,
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
@@ -600,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
@@ -641,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,
@@ -653,27 +670,43 @@ fn sign_text_calls(
let line_height = data.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let dark = 0xFF00_0000 | text.dark_color();
let (color, outline, light) = if text.glowing {
let (color, outline, light, tier) = if text.glowing {
let c = text.text_color();
(
0xFF00_0000 | c,
if c == 0 || near { dark } else { 0 },
240 | 240 << 8,
crate::mesher::LOW,
)
} else {
(dark, 0, vertex_light(packed))
(dark, 0, vertex_light(packed), 0)
};
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 advance = |c: char| (scene.glyph)(c).advance;
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();
@@ -683,19 +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,
}),
}
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.
@@ -704,15 +740,28 @@ fn sign_text_calls(
x,
y,
text: String::new(),
style: Style::default(),
color,
outline,
light,
tier,
});
}
}
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;
@@ -726,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: 0,
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
@@ -824,12 +932,16 @@ 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.
pub outline: u32,
/// Horizon packed light (sky*16 | block*16 << 8).
pub light: u32,
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
pub tier: u8,
}
/// Inputs from the game for one frame of block-entity rendering.
@@ -868,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,
@@ -1029,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 {
@@ -2335,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(
@@ -2352,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;
@@ -2373,6 +2493,7 @@ impl BlockEntityRenderer {
&mut vertices,
);
vacant.insert(SignTextEntry {
animated: sign_text_animated(data),
state,
data: data.clone(),
packed,
@@ -3612,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,
@@ -3774,6 +3896,105 @@ mod tests {
// Straight down: ambient only.
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
}
/// A face's colour carries its tint alone and its normal carries the
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
/// shader pack's lighting does, so a baked shade would be applied twice.
#[test]
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
let quad = |y: f32, z0: f32, z1: f32| {
[
[0.0, y, z0, 0.0, 0.0],
[0.0, y, z1, 0.0, 1.0],
[16.0, y, z1, 1.0, 1.0],
[16.0, y, z0, 1.0, 0.0],
]
};
// Opposite windings: the first faces up, the second down.
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
children: vec![],
};
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x123456,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
assert_eq!(out.len(), 8);
// The same tint on both faces: no directional shade baked in (`pack_color`
// stores the tint in the vertex colour's BGR byte order).
let tint = pack_color(0x12_3456, 255, 1.0);
assert!(out.iter().all(|v| v.color == tint));
let (up, down) = (&out[0], &out[4]);
assert_eq!(up.normal, [0, 127, 0, 127]);
assert_eq!(down.normal, [0, -127, 0, 127]);
// The shader still gets the difference the old baked colours held.
let lights = diffuse_lights(false);
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
}
/// Lights the entity shader does not know (an item preview's
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
/// the shader to keep it: `game/item_draw` passes those for block-entity
/// items in inventories and hands.
#[test]
fn lights_other_than_the_levels_stay_baked() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
]],
children: vec![],
};
let lights = (Vec3::X, Vec3::Z);
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x12_3456,
light: 0,
lights,
glow: None,
},
&mut out,
);
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
assert!(out.iter().all(|v| v.color == baked));
assert!(out.iter().all(|v| v.normal == [0; 4]));
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
}
#[test]
fn part_poses_compose_like_model_part() {
let part = ModelPart {
@@ -3907,6 +4128,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut call = TextCall {
@@ -3914,9 +4136,11 @@ mod tests {
x: 0.0,
y: 0.0,
text: "Hi".into(),
style: Style::default(),
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -3980,6 +4204,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
font: None,
dynamic_visible: None,
};
let call = TextCall {
@@ -3987,9 +4212,11 @@ mod tests {
x: 10.0,
y: 2.0,
text: "ab".into(),
style: Style::default(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -4009,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);
@@ -4037,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)
@@ -4176,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,
@@ -4219,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| {
@@ -4262,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);
@@ -4269,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
@@ -4377,6 +4714,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
font: None,
dynamic_visible: None,
};
let mut verts = Vec::new();
@@ -4463,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)];
@@ -993,7 +1001,9 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
scene.nether = true;
let nether_closed = render_cached(&scene);
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
assert_vertices_differ(&closed_again, &nether_closed);
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
assert_same_vertices(&closed_again, &nether_closed);
}
#[test]
@@ -1058,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];
@@ -1147,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)];
@@ -1223,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)];
@@ -1302,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)];
@@ -1369,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];
@@ -1417,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 {
@@ -1523,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 {
@@ -620,6 +620,14 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The original `structures.starts`, including starts with unknown pieces.
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
match structures.get("starts")? {
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
_ => None,
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
@@ -59,6 +59,9 @@ pub enum BlockEntity {
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
/// custom name of the golem it was (`createStatue`).
CopperGolemStatue(Compound),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
+23 -2
View File
@@ -208,7 +208,7 @@ where
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -279,7 +279,7 @@ impl SceneCollisions<'_, '_> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -300,6 +300,7 @@ impl SceneCollisions<'_, '_> {
CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
kind: horizon_world::collision_context::EntityContextKind::Living,
y,
descending: false,
main_hand: Some(CollisionItem::EMPTY),
@@ -335,6 +336,26 @@ where
{
no_collision_in(world, bounds, true)
}
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
pub fn no_collision_with_context<W, E>(
world: &mut W,
bounds: Aabb,
context: CollisionContext<'_>,
) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
@@ -395,6 +395,9 @@ impl BlockEntity {
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -460,6 +463,7 @@ impl BlockEntity {
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
@@ -510,6 +514,7 @@ impl BlockEntity {
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
@@ -558,7 +563,7 @@ impl BlockEntity {
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
@@ -575,6 +580,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -609,6 +615,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -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,
] {
+245 -15
View File
@@ -141,7 +141,8 @@ impl DataPack {
pub fn discover(world: &Path) -> Vec<DataPack> {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
/// folder or a world's data-pack directory, with required folder metadata.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
@@ -150,7 +151,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let path = e.path();
if path.is_dir() && path.join("pack.mcmeta").exists() {
if path.is_dir() && folder_metadata_present(&path) {
packs.push(DataPack {
id: format!("file/{name}"),
source: Source::Dir(path),
@@ -168,6 +169,22 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
/// and description. Keep this folder gate separate from format compatibility
/// and the existing interpretation of the description component.
fn folder_metadata_present(path: &Path) -> bool {
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
.ok()
.and_then(|bytes| crate::packs::parse_meta(&bytes))
else {
return false;
};
meta.as_object()
.and_then(|root| root.get("pack"))
.and_then(serde_json::Value::as_object)
.and_then(|pack| pack.get("description"))
.is_some_and(|description| !description.is_null())
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
@@ -261,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> {
@@ -608,7 +662,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -617,7 +671,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -625,7 +679,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -659,6 +713,7 @@ impl DataStore {
"damage_type",
"data_maps",
"dialog",
"enchantment",
"function",
"item_modifier",
"loot_table",
@@ -714,15 +769,55 @@ impl DataStore {
}
s
}
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
/// included, each function once).
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
/// (nested tags included, each function once).
pub fn tag_functions(&self, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen_fns = std::collections::HashSet::new();
let mut seen_tags = std::collections::HashSet::new();
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
let invalid = self.invalid_function_tags(id);
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
out
}
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
/// required parents. Optional invalid children contribute nothing.
/// Resolve against the current accepted function map, without caching a
/// candidate store or changing the existing expansion's cycle handling.
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
let mut invalid = std::collections::HashSet::new();
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new();
let mut pending = vec![id];
while let Some(tag) = pending.pop() {
if !seen.insert(tag) {
continue;
}
let Some(entries) = self.function_tags.get(tag) else {
invalid.insert(tag);
continue;
};
for (entry, required) in entries {
if let Some(child) = entry.strip_prefix('#') {
pending.push(child);
if *required {
parents.entry(child).or_default().push(tag);
}
} else if *required && !self.functions.contains_key(entry) {
invalid.insert(tag);
}
}
}
pending.extend(invalid.iter().copied());
while let Some(child) = pending.pop() {
for &parent in parents.get(child).into_iter().flatten() {
if invalid.insert(parent) {
pending.push(parent);
}
}
}
invalid
}
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
/// once, so reference cycles end and nesting depth is not capped.
fn expand<'a>(
@@ -731,18 +826,19 @@ impl DataStore {
out: &mut Vec<String>,
seen_fns: &mut std::collections::HashSet<&'a str>,
seen_tags: &mut std::collections::HashSet<&'a str>,
invalid: &std::collections::HashSet<&'a str>,
) {
if !seen_tags.insert(id) {
if invalid.contains(id) || !seen_tags.insert(id) {
return;
}
let Some(entries) = self.function_tags.get(id) else {
return;
};
for (e, required) in entries {
for (e, _) in entries {
match e.strip_prefix('#') {
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
None => {
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
if self.functions.contains_key(e) && seen_fns.insert(e) {
out.push(e.clone());
}
}
@@ -924,6 +1020,97 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn folder_metadata_is_required_before_contents_are_discovered() {
let root =
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for (name, metadata) in [
("broken", Some("{")),
("root_empty", Some("{}")),
("root_array", Some("[]")),
("pack_empty", Some(r#"{"pack":{}}"#)),
("pack_string", Some(r#"{"pack":"bad"}"#)),
("missing", None),
(
"full",
Some(
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
),
),
(
"description",
Some(r#"{"pack":{"description":"description only"}}"#),
),
("numeric", Some(r#"{"pack":{"description":42}}"#)),
("null", Some(r#"{"pack":{"description":null}}"#)),
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
(
"comment",
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
),
] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
std::fs::write(
dir.join(format!("data/h146/function/{name}.mcfunction")),
"scoreboard players set admitted h146 1\n",
)
.unwrap();
if let Some(metadata) = metadata {
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
}
}
let packs = discover_folder(&root);
assert_eq!(
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
[
"file/bom",
"file/comment",
"file/description",
"file/full",
"file/numeric"
]
);
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
assert_eq!(
store
.functions
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[
"h146:bom",
"h146:comment",
"h146:description",
"h146:full",
"h146:numeric"
]
);
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
let raw: serde_json::Value =
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
assert_eq!(raw["pack"]["description"], 42);
// Builtin and feature construction still have their independent paths.
let resources = root.join("resources");
let feature = resources.join("data/minecraft/datapacks/feature");
std::fs::create_dir_all(&feature).unwrap();
std::fs::write(
feature.join("pack.mcmeta"),
r#"{"pack":{"description":"feature"}}"#,
)
.unwrap();
assert_eq!(
feature_packs(&resources)
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["feature"]
);
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
@@ -951,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()));
@@ -961,7 +1177,7 @@ mod tests {
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
@@ -1132,6 +1348,20 @@ mod tests {
#[test]
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
let mut store = DataStore::default();
// Every referenced function exists; missing required ones invalidate a tag.
for id in (0..25_000)
.map(|i| format!("t:f{i}"))
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
{
store.functions.insert(
id.clone(),
Function {
id,
lines: Vec::new(),
parameters: Vec::new(),
},
);
}
let big: Vec<(String, bool)> = (0..50_000)
.map(|i| (format!("t:f{}", i % 25_000), true))
.collect();
@@ -1216,13 +1446,13 @@ mod tests {
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
@@ -399,8 +399,11 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
let mut sky = environment.sample(day_time, None, None);
apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
(15.0 - 15.0 * sky.sky_light_level).round() as i32
// Level.updateSkyBrightness: `(int)(15.0F - skyLightLevel)`, the float
// truncated, not rounded (#11323): the darkening steps up only once the
// light has fallen a whole level. `sky_light_level` is the attribute as a
// fraction of 15, so the product is its raw value in `f32`.
(15.0 - 15.0 * sky.sky_light_level) as i32
}
#[cfg(test)]
+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));
}
}
+20 -15
View File
@@ -9,6 +9,7 @@ use crate::{
environment::Sky,
world::World,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::path::Path;
/// `FogType` of the camera position.
@@ -64,7 +65,7 @@ impl FogTables {
}
/// Fog state that evolves over time.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug)]
pub struct FogState {
/// `LocalPlayer.waterVisionTime` (0..600).
pub water_vision_time: i32,
@@ -72,24 +73,28 @@ pub struct FogState {
pub rain_fog_multiplier: f32,
/// `LightmapRenderStateExtractor.blockLightFlicker`.
pub block_light_flicker: f32,
flicker_seed: u64,
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
flicker_random: LegacyRandom,
}
impl Default for FogState {
fn default() -> Self {
Self {
water_vision_time: 0,
rain_fog_multiplier: 0.0,
block_light_flicker: 0.0,
flicker_random: crate::random_source::create(),
}
}
}
impl FogState {
fn next_float(&mut self) -> f32 {
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.flicker_seed;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
}
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
pub fn tick_flicker(&mut self) {
let r = &mut self.flicker_random;
let (a, b, c, d) = (
self.next_float(),
self.next_float(),
self.next_float(),
self.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
);
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
@@ -750,7 +755,7 @@ mod tests {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
let mut settled = start.clone();
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
+135 -32
View File
@@ -126,11 +126,16 @@ struct Cache {
random: horizon_core::random::LegacyRandom,
}
pub struct Font {
/// The atlas rects of the sheets under `textures/font`.
/// The atlas rects of every texture, by resource name: a bitmap provider's
/// `file` may name any texture, not only one under `textures/font`.
sheets: HashMap<String, Rect>,
missing: Glyph,
white: Rect,
root: PathBuf,
/// The game's own resources, below a pack overlay: a font file the overlay
/// lacks (a pack adds providers over vanilla's, or the overlay left a
/// directory out) is read from here, as vanilla's resource stack does.
fallback: Option<PathBuf>,
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
@@ -199,6 +204,13 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
out.insert(codepoint, HexGlyph { rows, left, right });
}
}
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
/// the game's own resources below (`defaults`).
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(path))
.ok()
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
}
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
}
@@ -281,6 +293,7 @@ fn hex_advance(h: &HexGlyph) -> f32 {
/// pixels, copied into the atlas when first drawn.
fn load_bitmap(
root: &Path,
defaults: Option<&Path>,
p: &serde_json::Value,
sheets: &HashMap<String, Rect>,
pixels: &[u8],
@@ -290,22 +303,36 @@ fn load_bitmap(
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
let in_atlas = (ns == "minecraft")
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
.flatten();
let name = name.trim_end_matches(".png");
// The atlas names the default namespace's textures by their path and every
// other namespace's as `namespace:path` (HudAtlas::build).
let atlas_name = if ns == "minecraft" {
name.to_owned()
} else {
format!("{ns}:{name}")
};
let in_atlas = sheets.get(&atlas_name).copied();
// The sheet: its size, and its pixels when they are not in the atlas.
let (sw, sh, sheet) = match in_atlas {
Some(rect) => {
let (w, h) = rect.texels(atlas);
(w, h, None)
}
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
None => {
let path = resource_path(file, "textures", "");
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
defaults
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
.ok_or(first)
});
match sheet {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
}
}
},
}
};
let rows: Vec<Vec<char>> = p["chars"]
.as_array()
@@ -382,11 +409,13 @@ fn load_bitmap(
}
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
/// `font/`, its Unicode characters but the `skip` ones.
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
let file = p["file"].as_str()?;
let data = std::fs::read(root.join(resource_path(file, "font", "")))
.map_err(|e| log::warn!("font file {file}: {e}"))
.ok()?;
let path = resource_path(file, "font", "");
let Some(data) = read_resource(root, defaults, &path) else {
log::warn!("font file {file} could not be read");
return None;
};
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
if data.starts_with(b"OTTO") {
log::warn!("font file {file} is not in TTF format");
@@ -486,13 +515,16 @@ impl TrueType {
)
}
}
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
let mut glyphs = HashMap::new();
let Some(file) = p["hex_file"].as_str() else {
return glyphs;
};
let path = root.join(resource_path(file, "", "").replace("//", "/"));
let Ok(zip) = Zip::open(&path) else {
let path = resource_path(file, "", "").replace("//", "/");
let Some(zip) = Zip::open(&root.join(&path))
.ok()
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
else {
return glyphs;
};
let names: Vec<String> = zip
@@ -603,7 +635,24 @@ impl Font {
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, sprites, pixels, atlas, region, white, false)
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
}
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
/// file the overlay lacks is read from `defaults`, the game's own
/// resources, as vanilla's resource stack does. (Definitions stay the
/// overlay's: a pack's filter may hide one from a lower pack.)
#[allow(clippy::too_many_arguments)]
pub fn load_over(
root: &Path,
defaults: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
let fallback = (root != defaults).then_some(defaults);
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
@@ -616,10 +665,21 @@ impl Font {
w: 1,
h: 1,
};
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
Self::load_with(
root,
None,
&HashMap::new(),
&[],
(1, 1),
(0, 0),
white,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn load_with(
root: &Path,
fallback: Option<&Path>,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
@@ -627,11 +687,7 @@ impl Font {
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites
.iter()
.filter(|(k, _)| k.starts_with("font/"))
.map(|(k, v)| (k.clone(), *v))
.collect();
let sheets = sprites.clone();
let font = Self {
sheets,
missing: Glyph {
@@ -645,6 +701,7 @@ impl Font {
},
white,
root: root.to_owned(),
fallback: fallback.map(|p| p.to_owned()),
atlas,
region,
metrics_only,
@@ -656,11 +713,8 @@ impl Font {
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64),
),
// `Font.random`: `RandomSource.create()`.
random: crate::random_source::create(),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
@@ -698,6 +752,7 @@ impl Font {
Filter::of(p),
load_bitmap(
&self.root,
self.fallback.as_deref(),
p,
&self.sheets,
pixels,
@@ -708,7 +763,7 @@ impl Font {
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
Filter::of(p),
Box::new(load_true_type(&self.root, p)?),
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
)),
_ => None,
})
@@ -868,7 +923,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
@@ -1026,7 +1082,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
@@ -1512,6 +1569,52 @@ mod tests {
assert!(texels.iter().any(|t| t[0] == 255));
}
/// A bitmap provider's `file` can name any texture. When the atlas has that
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
/// glyphs are read from it instead of being copied into the small run-time
/// region one by one; the region then cannot fill up and turn later glyphs
/// into the missing one, whose advance shifts the rest of a HUD line.
#[test]
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 8,
};
for (ns, file, key) in [
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
(
"hudengine",
"hudengine:font/sheet.png",
"hudengine:font/sheet",
),
] {
let def = format!(
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
);
// The image is only in the atlas: the pack's copy is not on disk.
let root = root(
ns,
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let sprites = HashMap::from([(key.to_string(), rect)]);
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
assert!(font.glyph('a').rect.is_some(), "{ns}");
assert!(
font.take_patches().is_empty(),
"{ns}: the atlas sheet is not copied into the run-time region"
);
}
}
#[test]
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
+400 -47
View File
@@ -171,7 +171,10 @@ mod gpu_culling;
mod hdr;
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;
@@ -444,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 {
@@ -1171,6 +1220,7 @@ pub struct Renderer {
debug_lines_opaque: wgpu::RenderPipeline,
debug_lines: wgpu::RenderPipeline,
debug_lines_on_top: wgpu::RenderPipeline,
debug_crosshair: Option<wgpu::RenderPipeline>,
debug_line_buffer: wgpu::Buffer,
debug_line_capacity: u64,
/// The terrain resources with the debug crosshair's projection
@@ -1191,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,
@@ -1328,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>,
@@ -1424,6 +1479,8 @@ pub struct Renderer {
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
hud_coverage: bool,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
@@ -2450,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(),
@@ -2693,6 +2758,13 @@ impl Renderer {
config.format,
linear,
);
let hud_coverage = hud_coverage::supported(
linear,
hdr_output.is_some(),
device.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2843,8 +2915,9 @@ impl Renderer {
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
blend: Option<wgpu::BlendState>,
coverage: bool| {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
@@ -2885,7 +2958,17 @@ impl Renderer {
}),
multiview_mask: None,
cache: None,
})
};
if coverage {
hud_coverage::pipeline(
&device,
&desc,
wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
)
.expect("the debug crosshair's coverage")
} else {
kept.create_render_pipeline(&desc)
}
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
@@ -2909,6 +2992,7 @@ impl Renderer {
depth_write,
depth_test,
blend,
false,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
@@ -2931,6 +3015,7 @@ impl Renderer {
true,
true,
None,
false,
)
};
SolidPipeline {
@@ -3175,6 +3260,22 @@ impl Renderer {
false,
Some(alpha_blend),
);
let debug_crosshair = hud_coverage.then(|| {
pipeline_with(
&sprite_constants,
&layout,
"3d crosshair coverage",
"vs_debug_line",
"fs_debug_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
false,
Some(alpha_blend),
true,
)
});
drop(terrain_step);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon globals"),
@@ -3528,6 +3629,7 @@ impl Renderer {
&atlas_view,
&glint_view,
&glint_sampler,
hud_coverage,
)?;
hud.atlas_texture = Some(atlas.clone());
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
@@ -3966,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,
);
@@ -4048,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",
@@ -4506,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
@@ -4746,6 +4868,7 @@ impl Renderer {
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
debug_crosshair,
debug_line_buffer,
debug_line_capacity: 4096,
crosshair_group,
@@ -4757,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,
@@ -4860,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,
@@ -4905,6 +5030,7 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
hud_coverage,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
@@ -4963,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);
}
}
@@ -5454,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();
@@ -5478,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;
@@ -5487,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);
}
@@ -5506,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
@@ -5536,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;
@@ -5828,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()
@@ -5871,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
@@ -5895,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,
@@ -6068,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();
@@ -6138,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) {
@@ -6245,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.
@@ -6497,8 +6764,9 @@ impl Renderer {
DisplayWatch::default()
};
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
self.hdr = output.map(|output| {
hdr::Hdr::new(&self.device, &self.queue, format, output, self.hud_coverage)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
@@ -6590,7 +6858,16 @@ impl Renderer {
self.frame_gen.on = on;
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
/// `sharpness`: how much TAA's upscaled picture is sharpened, 0..=1.
pub fn set_world_post(
&mut self,
scale: f32,
antialias: bool,
taa: bool,
upscale: f32,
sharpness: f32,
) {
self.world_post.set_sharpness(&self.device, sharpness);
let taa = taa && self.world_post.supports_taa();
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias && !taa;
@@ -10285,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, &[]);
@@ -10747,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)
@@ -11097,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
@@ -11138,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, _) => {}
@@ -11329,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 {
@@ -11369,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 {
@@ -11401,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 {
@@ -11538,11 +11845,25 @@ 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);
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if overlay.crosshair > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: hud_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -11550,6 +11871,9 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = &coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
@@ -11564,7 +11888,11 @@ impl Renderer {
}),
..Default::default()
});
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_pipeline(if coverage.is_some() {
self.debug_crosshair.as_ref().unwrap()
} else {
&self.debug_lines_on_top
});
pass.set_bind_group(0, &self.crosshair_group, &[]);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
let first = overlay.first_crosshair;
@@ -11576,7 +11904,8 @@ 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,
[self.config.width, self.config.height],
@@ -11587,24 +11916,48 @@ 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);
}
} else if let Some(blur) = &mut self.menu_blur {
blur.release();
}
self.hud.draw_range(
encoder,
view_target,
hud_target,
coverage.as_ref(),
&self.depth,
frame.hud,
[self.config.width, self.config.height],
+632 -99
View File
@@ -72,8 +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
@@ -155,6 +176,7 @@ pub struct Hdr {
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
layered: Option<Layered>,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
@@ -181,28 +203,67 @@ impl Hdr {
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
) -> Self {
let debug = std::env::var("HORIZON_HDR_DEBUG").ok();
Self::with_debug(device, queue, format, output, coverage, debug.as_deref())
}
fn with_debug(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
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 = format!(
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) 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#" {
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.
let paper = vec3(params.paper, 0.0, params.paper);
let magenta = select(paper, pq(BT709_TO_BT2020 * paper * 80.0), params.pq > 0.5);
return vec4(mix(picture.rgb, magenta, c), picture.a);
}
"#,
);
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
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 layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
// 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::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
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 {
@@ -211,17 +272,22 @@ impl Hdr {
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline_for = |format| {
});
}
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,
@@ -234,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,
@@ -246,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),
@@ -288,27 +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: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
debug: debug == Some("1"),
layered,
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
@@ -324,7 +407,7 @@ impl Hdr {
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -335,52 +418,185 @@ impl Hdr {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
format,
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
};
let frame = texture(
"Horizon HDR frame",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture(
"Horizon HDR world",
self.format,
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
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,
)
});
// 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
.as_ref()?
.coverage
.as_ref()?
.create_view(&Default::default()),
)
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
@@ -394,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,
@@ -402,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(),
@@ -429,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());
@@ -466,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()))
}
@@ -494,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
@@ -517,38 +751,66 @@ impl Hdr {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR screenshot bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
let group = self.group(
device,
&t.frame,
&t.world,
t.coverage.as_ref(),
t.hud.as_ref(),
&self.capture_params,
);
queue.write_buffer(
&self.capture_params,
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)
}
fn group(
&self,
device: &wgpu::Device,
frame: &wgpu::Texture,
world: &wgpu::Texture,
coverage: Option<&wgpu::Texture>,
hud: Option<&wgpu::Texture>,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
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),
wgpu::BindGroupEntry {
binding: 2,
resource: params.as_entire_binding(),
},
texture(3, &self.logo),
];
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"),
layout: &self.layout,
entries: &entries,
})
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
@@ -577,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 {
@@ -595,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()
});
@@ -655,9 +918,279 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, named, pick};
use super::{Hdr, Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
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;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
for enabled in [false, true] {
for debug in [None, Some("coverage")] {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, enabled, debug);
hdr.frame(&device, [2, 1]);
assert_eq!(hdr.coverage_view().is_some(), enabled);
hdr.frame(&device, [4, 2]);
let t = hdr.targets.as_ref().unwrap();
if let Some(coverage) = &t.coverage {
assert_eq!(coverage.size(), t.frame.size());
}
let mut encoder = device.create_command_encoder(&Default::default());
let frame_view = t.frame.create_view(&Default::default());
let mut color = super::super::hud_coverage::attachment(&frame_view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::BLACK);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
hdr.keep_world(&mut encoder, None);
if let Some(view) = hdr.coverage_view() {
super::super::hud_coverage::clear(&mut encoder, &view);
let mut color = super::super::hud_coverage::attachment(&view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::WHITE);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
}
let capture = hdr.capture(&device, &queue, &mut encoder, false).unwrap();
let buffer = super::super::copy_frame(&device, &mut encoder, &capture, [4, 2]);
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();
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
let [r, g, b] = [0, 10, 20].map(|shift| (pixel >> shift) & 0x3ff);
if enabled && debug.is_some() {
// Fully covered: BT.709 magenta at paper white (200 nits here) in BT.2020
// PQ, not PQ's 10 000 nits (1023, 0, 1023).
let near = |got: u32, want: u32| got.abs_diff(want) <= 4;
assert!(near(r, 550) && near(g, 346) && near(b, 583), "{r} {g} {b}");
} else {
assert_eq!(pixel, 0xc0000000);
}
if let Some(view) = hdr.coverage_view() {
let mut encoder = device.create_command_encoder(&Default::default());
super::super::hud_coverage::clear(&mut encoder, &view);
let coverage = hdr.targets.as_ref().unwrap().coverage.as_ref().unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
coverage.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
coverage.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(&buffer.get_mapped_range(..).unwrap()[..4], &[0; 4]);
}
}
}
}
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)));
}
}
@@ -0,0 +1,485 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
pub(crate) fn supported(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
&& format
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
}
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
}
}
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
let mut color = attachment(view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD coverage reset"),
color_attachments: &[Some(color)],
..Default::default()
});
}
pub(crate) struct Pipeline {
plain: wgpu::RenderPipeline,
coverage: Option<wgpu::RenderPipeline>,
}
impl Pipeline {
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
/// pack's, which the caller then doesn't use).
pub(crate) fn new(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
enabled: bool,
) -> Option<Self> {
let coverage = if enabled {
Some(pipeline(device, desc, source)?)
} else {
None
};
Some(Self {
plain: device.create_render_pipeline(desc),
coverage,
})
}
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
if coverage {
self.coverage.as_ref().expect("HUD coverage pipeline")
} else {
&self.plain
}
}
}
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
) -> Option<wgpu::RenderPipeline> {
let mut desc = desc.clone();
let fragment = desc.fragment.as_mut()?;
let mut module = match source {
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let primary = fragment.targets.first()?.clone()?;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
(
wgpu::BlendFactor::One
| wgpu::BlendFactor::Src
| wgpu::BlendFactor::SrcAlpha
| wgpu::BlendFactor::Zero,
wgpu::BlendFactor::One
)
) && blend.operation == wgpu::BlendOperation::Add;
wrap(&mut module, fragment.entry_point?, blend)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: desc.label,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let over = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
};
let targets = [
Some(primary),
Some(wgpu::ColorTargetState {
format: FORMAT,
blend: Some(wgpu::BlendState {
color: over,
alpha: over,
}),
write_mask: if additive {
wgpu::ColorWrites::empty()
} else {
wgpu::ColorWrites::RED
},
}),
];
fragment.module = &shader;
fragment.targets = &targets;
Some(device.create_render_pipeline(&desc))
}
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
use naga::{Expression as E, Statement as S, TypeInner as T};
use wgpu::BlendFactor as B;
let span = naga::Span::UNDEFINED;
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
// Checked before anything is taken apart: a resource pack's shader may have no colour.
let result = entry.function.result.clone()?;
if let T::Struct { members, .. } = &module.types[result.ty].inner {
members
.iter()
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
} else if !matches!(
result.binding,
Some(naga::Binding::Location { location: 0, .. })
) {
return None;
}
let mut original = std::mem::take(&mut entry.function);
entry.function.arguments = original.arguments.clone();
for arg in &mut original.arguments {
arg.binding = None;
}
original.result.as_mut().unwrap().binding = None;
original.name = Some("hud_color".into());
let function = module.functions.append(original, span);
let f = &mut entry.function;
let args = (0..f.arguments.len())
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
.collect();
let call = f.expressions.append(E::CallResult(function), span);
f.body.push(
S::Call {
function,
arguments: args,
result: Some(call),
},
span,
);
let one = f
.expressions
.append(E::Literal(naga::Literal::F32(1.0)), span);
let start = f.expressions.len();
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
T::Struct {
members,
span: size,
} => {
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
// left in, one at location 1 would collide with the coverage.
let (members, values): (Vec<_>, Vec<_>) = members
.iter()
.enumerate()
.filter(|(_, m)| {
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
})
.map(|(i, m)| {
let value = f.expressions.append(
E::AccessIndex {
base: call,
index: i as u32,
},
span,
);
(m.clone(), value)
})
.unzip();
let color = members
.iter()
.position(|m| {
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
})
.unwrap();
let color = values[color];
(members, values, color, *size)
}
_ => (
vec![naga::StructMember {
name: Some("color".into()),
ty: result.ty,
binding: result.binding,
offset: 0,
}],
vec![call],
call,
16,
),
};
let mut component = |index| {
f.expressions
.append(E::AccessIndex { base: color, index }, span)
};
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
let mut math = |fun, arg, arg1| {
f.expressions.append(
E::Math {
fun,
arg,
arg1: Some(arg1),
arg2: None,
arg3: None,
},
span,
)
};
let low = math(naga::MathFunction::Min, r, g);
let low = math(naga::MathFunction::Min, low, b);
let high = math(naga::MathFunction::Max, r, g);
let high = math(naga::MathFunction::Max, high, b);
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
// cover fully, so the later composite cannot leak world light through them.
let c = if blend.operation != wgpu::BlendOperation::Add {
one
} else {
match (blend.src_factor, blend.dst_factor) {
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
(B::Zero, B::OneMinusSrc) => high,
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
// Use the greatest channel darkening, as for the credits vignette above.
(B::Zero, B::Src)
| (B::Dst, B::Zero)
| (B::Dst, B::OneMinusSrcAlpha)
| (B::Dst, B::Src) => {
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
a
} else {
one
};
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Add,
left: low,
right: low,
},
span,
)
} else {
low
};
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Subtract,
left: top,
right: factor,
},
span,
)
}
_ => one,
}
};
let c = f.expressions.append(
E::Math {
fun: naga::MathFunction::Saturate,
arg: c,
arg1: None,
arg2: None,
arg3: None,
},
span,
);
let scalar = module.types.insert(
naga::Type {
name: None,
inner: T::Scalar(naga::Scalar::F32),
},
span,
);
members.push(naga::StructMember {
name: Some("coverage".into()),
ty: scalar,
binding: Some(naga::Binding::Location {
location: 1,
interpolation: None,
sampling: None,
blend_src: None,
per_primitive: false,
}),
offset,
});
let ty = module.types.insert(
naga::Type {
name: Some("HudCoverage".into()),
inner: T::Struct {
members,
span: (offset + 4).next_multiple_of(16),
},
},
span,
);
values.push(c);
let value = f.expressions.append(
E::Compose {
ty,
components: values,
},
span,
);
f.result = Some(naga::FunctionResult { ty, binding: None });
f.body.push(S::Emit(f.expressions.range_from(start)), span);
f.body.push(S::Return { value: Some(value) }, span);
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
flags: wgpu::TextureFormatFeatureFlags::empty(),
..format
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
..format
}
));
}
#[test]
fn disabled_coverage_builds_only_the_original_pipeline() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
),
});
let desc = wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
};
// The coverage source must not even be parsed outside the lane.
let pipeline = Pipeline::new(
&device,
&desc,
wgpu::ShaderSource::Wgsl("invalid".into()),
false,
)
.unwrap();
assert!(pipeline.coverage.is_none());
}
#[test]
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
wrap(
&mut module,
"fs_debug_line",
wgpu::BlendState::ALPHA_BLENDING.color,
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
let mut module = naga::front::wgsl::parse_str(
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
)
.unwrap();
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let result = module.entry_points[0].function.result.as_ref().unwrap();
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
panic!("the wrapped entry returns a struct");
};
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
assert_eq!(names, ["color", "coverage"]);
}
}
@@ -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();
@@ -90,6 +90,27 @@ impl NativePack {
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
// is refused with that error instead of a validation panic in the renderer.
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let built = Self::build(device, queue, terrain, report, constants, lights);
let refused = pollster::block_on(scope.pop());
match (built, refused) {
(Ok(built), None) => Ok(built),
(Ok(_), Some(e)) => Err(e.to_string()),
(Err(e), _) => Err(e),
}
}
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
@@ -278,9 +299,90 @@ impl NativePack {
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use super::{NativePack, TerrainShape, chosen_shadow};
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
/// a validation panic (#10095).
#[test]
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
use horizon_shader_api::{MANIFEST, Pack, check};
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let mut limits = adapter.limits();
limits.max_sampled_textures_per_shader_stage = 16;
let Ok((device, queue)) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
else {
return;
};
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
// 8 sprite arrays, the lightmap and the emission thresholds).
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
};
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)];
entries.extend((1..=10).map(|binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
}));
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let terrain = TerrainShape {
globals: &globals,
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
weather_groups: [&empty, &empty],
pbr: None,
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n";
let files = [
(MANIFEST, manifest),
(
"sky.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
assert!(built.is_err(), "the pack should be refused");
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
@@ -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),
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
@@ -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;
@@ -55,6 +55,13 @@ pub struct Post {
sampler: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
/// Contrast-adaptive sharpening of TAA's upscaled picture: the jittered frames are
/// spread over more output pixels, and the blend softens edges.
sharpen: wgpu::RenderPipeline,
/// The strength `sharpen` was built with (`set_sharpness`), and what rebuilding it takes.
sharpness: f32,
shader: wgpu::ShaderModule,
pipeline_layout: wgpu::PipelineLayout,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
@@ -232,8 +239,12 @@ impl Post {
..Default::default()
});
Self {
shader: shader.clone(),
pipeline_layout: pipeline_layout.clone(),
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
sharpen: sharpen_pipeline(device, &shader, &pipeline_layout, format, 0.85),
sharpness: 0.85,
layout,
sampler,
format,
@@ -474,6 +485,21 @@ impl Post {
}
}
/// The Upscaling Sharpness setting, 0..=1: rebuilds the sharpen pipeline when it changed.
pub fn set_sharpness(&mut self, device: &wgpu::Device, sharpness: f32) {
let sharpness = sharpness.clamp(0.0, 1.0);
if sharpness != self.sharpness {
self.sharpen = sharpen_pipeline(
device,
&self.shader,
&self.pipeline_layout,
self.format,
sharpness,
);
self.sharpness = sharpness;
}
}
/// The size of the world's own target, when it has one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
@@ -535,7 +561,12 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i], timing);
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -545,6 +576,23 @@ impl Post {
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let pipeline = if self.antialias {
&self.fxaa
} else {
&self.copy
};
self.draw_onto(encoder, screen, group, pipeline, timing);
}
/// Draw what `group` binds onto `screen` through `pipeline`.
fn draw_onto(
&self,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
pipeline: &wgpu::RenderPipeline,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -561,16 +609,51 @@ impl Post {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(if self.antialias {
&self.fxaa
} else {
&self.copy
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
/// `fs_sharpen` at `sharpness` (its override), drawing onto `format`.
fn sharpen_pipeline(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
format: wgpu::TextureFormat,
sharpness: f32,
) -> wgpu::RenderPipeline {
let constants = [("sharpness", f64::from(sharpness))];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon sharpen"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_sharpen"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
pub(super) fn reprojection(
+9 -1
View File
@@ -725,6 +725,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// How much the upscaled picture is sharpened, 0..=100 (percent of the most).
pub upscaling_sharpness: u32,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
@@ -813,6 +815,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
upscaling_sharpness: 85,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
@@ -935,6 +938,9 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"upscalingSharpness" => {
g.upscaling_sharpness = number(g.upscaling_sharpness).min(100)
}
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
@@ -980,7 +986,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1019,6 +1025,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.upscaling_sharpness,
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
@@ -1202,6 +1209,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
upscaling_sharpness: 40,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
@@ -97,6 +97,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
UpscalingSharpness,
ScreenshotQuality,
FrameGeneration,
Hdr,
@@ -372,6 +373,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::UpscalingSharpness,
Low,
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
),
h(
H::ScreenshotQuality,
None,
@@ -785,6 +791,7 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
@@ -804,6 +811,7 @@ impl H {
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
@@ -893,6 +901,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::UpscalingSharpness => {
labelled("Upscaling Sharpness", format!("{}%", g.upscaling_sharpness))
}
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
@@ -965,6 +976,7 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
@@ -3222,6 +3234,7 @@ mod tests {
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::UpscalingSharpness,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
+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)));
}
+157 -14
View File
@@ -531,6 +531,49 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
}
/// The texture of every `bitmap` font provider. A provider's `file` may name
/// any texture, so a pack's glyph sheet need not live under `textures/font`.
fn bitmap_font_textures(root: &Path) -> Vec<PathBuf> {
fn jsons(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
jsons(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(path);
}
}
}
let assets = root.join("assets");
let mut defs = Vec::new();
for ns in fs::read_dir(&assets).into_iter().flatten().flatten() {
jsons(&ns.path().join("font"), &mut defs);
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for def in defs {
let Ok(bytes) = fs::read(&def) else { continue };
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
continue;
};
for p in json["providers"].as_array().into_iter().flatten() {
if p["type"] != "bitmap" {
continue;
}
let Some(file) = p["file"].as_str() else {
continue;
};
let (ns, path) = file.split_once(':').unwrap_or(("minecraft", file));
let path = assets.join(ns).join("textures").join(path);
if path.is_file() && seen.insert(path.clone()) {
out.push(path);
}
}
}
out
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
@@ -1102,6 +1145,18 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// A bitmap provider's `file` may be outside the walked folders (HUD
// packs keep their glyph sheets under their own namespace or path).
// Pack those sheets too, so Font reads them from the atlas instead of
// copying every glyph into the small run-time region.
if folders.contains(&"font") {
let mut seen: HashSet<PathBuf> = files.iter().cloned().collect();
files.extend(
bitmap_font_textures(root)
.into_iter()
.filter(|p| seen.insert(p.clone())),
);
}
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
@@ -1465,8 +1520,9 @@ impl HudAtlas {
let white = sprites["horizon/white"];
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
let font = crate::font::Font::load(
let font = crate::font::Font::load_over(
root,
defaults,
&sprites,
&pixels,
(width, height),
@@ -2379,17 +2435,19 @@ impl HudUniform {
}
}
}
use crate::gfx::hud_coverage::{self, Pipeline};
pub struct HudRenderer {
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
/// The shared atlas texture, for run-time glyph uploads.
pub atlas_texture: Option<wgpu::Texture>,
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
/// `RenderPipelines.CROSSHAIR`.
invert_pipeline: wgpu::RenderPipeline,
additive_pipeline: wgpu::RenderPipeline,
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
invert_pipeline: Pipeline,
additive_pipeline: Pipeline,
depth_pipeline: Pipeline,
vignette_pipeline: Pipeline,
end_portal_pipeline: Pipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
@@ -2399,6 +2457,7 @@ pub struct HudRenderer {
buffer: wgpu::Buffer,
capacity: u64,
format: wgpu::TextureFormat,
coverage: bool,
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
@@ -2413,6 +2472,7 @@ impl HudRenderer {
view: &wgpu::TextureView,
glint_view: &wgpu::TextureView,
glint_sampler: &wgpu::Sampler,
coverage: bool,
) -> Result<Self> {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD"),
@@ -2525,7 +2585,7 @@ impl HudRenderer {
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let create = |label, blend, fragment_entry, depth: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -2559,7 +2619,9 @@ impl HudRenderer {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let depth_pipeline = create(
@@ -2650,6 +2712,7 @@ impl HudRenderer {
buffer,
capacity,
format,
coverage,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
@@ -2664,7 +2727,7 @@ impl HudRenderer {
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t, self.coverage)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -2674,7 +2737,7 @@ impl HudRenderer {
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
});
}
pub fn animate_sprites(
@@ -2823,6 +2886,7 @@ impl HudRenderer {
self.draw_range(
encoder,
target,
None,
depth,
batch,
size,
@@ -2836,6 +2900,7 @@ impl HudRenderer {
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
coverage: Option<&wgpu::TextureView>,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
@@ -2845,7 +2910,7 @@ impl HudRenderer {
if vertices.is_empty() || size.contains(&0) {
return;
}
let attachments = [Some(wgpu::RenderPassColorAttachment {
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
@@ -2854,6 +2919,9 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
@@ -2905,16 +2973,17 @@ impl HudRenderer {
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
cit.draw(&mut pass, *blend, range.clone(), coverage.is_some());
}
continue;
}
};
let pipeline = pipeline.get(coverage.is_some());
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
if text {
pack_text.draw(&mut pass, part);
pack_text.draw(&mut pass, part, coverage.is_some());
pass.set_bind_group(0, &self.group, &[]);
} else {
pass.set_pipeline(pipeline);
@@ -2933,6 +3002,10 @@ impl HudRenderer {
}
}
#[cfg(test)]
#[path = "hud/coverage_tests.rs"]
mod coverage_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -3891,6 +3964,30 @@ texture=wide",
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
/// A bitmap font's `file` may be any texture, not only one under
/// `textures/font`; the atlas packs it so the font can read its glyphs
/// from there instead of copying them into the run-time region.
#[test]
fn a_bitmap_font_sheet_outside_font_is_packed() {
let root = std::env::temp_dir().join(format!("horizon-font-pack-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
write_png(&root, "hud/sheet", 16, 8);
let def = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(def.parent().unwrap()).unwrap();
fs::write(
&def,
r#"{"providers":[{"type":"bitmap","file":"minecraft:hud/sheet.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas
.sprites
.get("hud/sheet")
.expect("the font sheet is packed");
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 8));
}
/// A content mod's GUI textures join the atlas under its namespace;
/// its other textures and vanilla's names stay as they were.
#[test]
@@ -4010,6 +4107,52 @@ texture=wide",
.unwrap();
root
}
/// A pack layer that adds a font provider over the game's own resources:
/// its `font/default.json` names `minecraft:font/test.png`, which only the
/// game's root holds (`HudAtlas::build_over`'s `defaults`).
fn pack_font_over_defaults(name: &str) -> (PathBuf, PathBuf) {
let base = std::env::temp_dir().join(format!(
"horizon-font-fallback-{name}-{}",
std::process::id()
));
let pack = base.join("pack");
let vanilla = base.join("vanilla");
fs::create_dir_all(pack.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
fs::write(
pack.join("assets/minecraft/font/default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let mut pixels = vec![0u8; 16 * 8 * 4];
for y in 0..8 {
for x in (0..3).chain(8..13) {
pixels[(y * 16 + x) * 4 + 3] = 255;
}
}
let mut encoder = png::Encoder::new(
fs::File::create(vanilla.join("assets/minecraft/textures/font/test.png")).unwrap(),
16,
8,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
(pack, vanilla)
}
#[test]
fn a_pack_overlay_reads_font_sheets_from_the_game_below_it() {
let (pack, vanilla) = pack_font_over_defaults("sheets");
let atlas = HudAtlas::build_over(&pack, &vanilla).unwrap();
let glyph = atlas.font.glyph('a');
let _ = fs::remove_dir_all(pack.parent().unwrap());
assert!(glyph.rect.is_some(), "text draws, not the missing glyph");
assert!(glyph.advance > 0.0);
}
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
let root = two_glyph_font("atlas");
@@ -5,6 +5,7 @@
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use crate::gfx::hud_coverage::Pipeline;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
@@ -230,7 +231,7 @@ pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
pipelines: Vec<Pipeline>,
}
impl CitGlintPass {
@@ -241,6 +242,7 @@ impl CitGlintPass {
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
coverage: bool,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
@@ -381,7 +383,7 @@ impl CitGlintPass {
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -409,7 +411,9 @@ impl CitGlintPass {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the CIT glint's coverage")
})
.collect();
Some(Self { group, pipelines })
@@ -421,9 +425,10 @@ impl CitGlintPass {
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
coverage: bool,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_pipeline(pipeline.get(coverage));
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
@@ -0,0 +1,311 @@
use super::*;
use crate::gfx::hud_coverage;
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
hud: HudRenderer,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
) {
eprintln!("skipping HUD coverage: adapter lacks independent blending or R8 rendering");
return None;
}
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).ok()?;
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[255, 255, 255, 255, 255, 255, 255, 0],
);
let view = atlas.create_view(&Default::default());
let hud = HudRenderer::new(
&device,
wgpu::TextureFormat::Rgba16Float,
&view,
&view,
&device.create_sampler(&Default::default()),
true,
)
.unwrap();
Some(Self { device, queue, hud })
}
fn texture(&self, format: wgpu::TextureFormat) -> wgpu::Texture {
self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
let mask = self.texture(hud_coverage::FORMAT);
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
let [frame_view, mask_view, depth_view] =
[&frame, &mask, &depth].map(|t| t.create_view(&Default::default()));
self.queue
.write_buffer(&self.hud.buffer, 0, bytemuck::cast_slice(&batch.vertices));
self.queue.write_buffer(
&self.hud.uniform,
0,
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
);
let mut encoder = self.device.create_command_encoder(&Default::default());
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
let mut attachment = hud_coverage::attachment(view);
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
r: value,
g: value,
b: value,
a: value,
});
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(attachment)],
..Default::default()
});
}
self.hud.draw_range(
&mut encoder,
&frame_view,
coverage.then_some(&mask_view),
&depth_view,
batch,
[4, 2],
0..batch.vertices.len() as u32,
None,
);
let buffers = [&frame, &mask].map(|t| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
t.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
t.size(),
);
buffer
});
self.queue.submit([encoder.finish()]);
let [color, mask] = buffers.map(|buffer| {
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
});
(
color
.chunks(256)
.flat_map(|r| r[..32].iter().copied())
.collect(),
mask.chunks(256)
.flat_map(|r| r[..4].iter().copied())
.collect(),
)
}
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
let (plain, _) = self.draw(batch, initial, false);
let (color, mask) = self.draw(batch, initial, true);
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
for (got, want) in mask.iter().zip(expected.repeat(2)) {
assert!(
got.abs_diff(want) <= 1,
"coverage {mask:?}, expected {expected:?}"
);
}
}
}
fn quad(batch: &mut HudBatch, blend: Blend, color: u32, x: f32, uv: f32) {
let start = batch.vertices.len();
batch.quad(
[[x, 0.0], [x + 2.0, 0.0], [x + 2.0, 2.0], [x, 2.0]],
[[uv, 0.5]; 4],
color,
);
batch.blend_since(start, blend);
}
#[test]
fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour() {
let Some(gpu) = Gpu::new() else {
return;
};
for (blend, color, initial, count, expected) in [
(Blend::Alpha, rgba(255, 255, 255, 128), 0.0, 2, 192),
(Blend::Additive, rgba(255, 255, 255, 128), 0.3, 1, 77),
(Blend::Invert, rgba(255, 255, 255, 32), 0.0, 1, 255),
(Blend::EndPortal, rgba(255, 255, 255, 255), 0.0, 1, 255),
(Blend::Vignette, rgba(102, 102, 102, 255), 0.0, 1, 102),
] {
let mut batch = HudBatch::new([4, 2]);
for _ in 0..count {
quad(&mut batch, blend, color, 0.0, 0.25);
}
// Crosshair fragments with zero texture alpha discard; EndPortal has no discard.
if blend == Blend::Invert {
quad(&mut batch, blend, color, 2.0, 0.75);
}
gpu.check(
&batch,
initial,
[
expected,
expected,
(initial * 255.0).round() as u8,
(initial * 255.0).round() as u8,
],
);
}
let mut batch = HudBatch::new([4, 2]);
for depth in [0.25, 0.75] {
quad(
&mut batch,
Blend::Alpha,
rgba(255, 255, 255, 128),
0.0,
0.25,
);
let start = batch.vertices.len() - 6;
for vertex in &mut batch.vertices[start..] {
vertex.depth = depth;
}
}
batch.blends.push((0..12, Blend::Depth([1, 0, 2, 2])));
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
}
#[test]
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
let Some(mut gpu) = Gpu::new() else {
return;
};
use horizon_optifine::cit::parse_blend;
for (name, expected) in [
("alpha", 128),
("add", 0),
("dodge", 0),
("glint", 0),
("burn", 102),
("multiply", 26),
("overlay", 51),
("screen", 255),
("subtract", 255),
("replace", 255),
("0 768", 153),
("774 0", 153),
("770 771 772 773", 128),
] {
let textures = cit_glint::GlintTextures {
width: 1,
height: 1,
pixels: vec![102, 102, 102, 128],
slots: vec![cit_glint::Slot {
rgba: [1.0; 4],
blur: false,
blend: 0,
}],
blends: vec![parse_blend(name).unwrap()],
..Default::default()
};
gpu.hud
.set_cit_glints(&gpu.device, &gpu.queue, Some(&textures));
let mut batch = HudBatch::new([4, 2]);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
0.0,
0.25,
);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
2.0,
0.75,
);
gpu.check(&batch, 0.0, [expected, expected, 0, 0]);
}
}
#[test]
fn coverage_uses_pack_texts_final_alpha_and_discard() {
let Some(mut gpu) = Gpu::new() else {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
let mut batch = HudBatch::new([4, 2]);
for x in [0.0, 2.0, 0.0, 2.0] {
quad(&mut batch, Blend::Alpha, rgba(255, 255, 255, 255), x, 0.25);
}
for v in &mut batch.vertices {
v.text = 1;
}
gpu.check(&batch, 0.0, [191, 191, 0, 0]);
}
#[test]
fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
}
@@ -8,6 +8,7 @@ use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
@@ -81,7 +82,7 @@ const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// The pipeline and its resources.
pub(super) struct PackText {
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
@@ -92,6 +93,7 @@ impl PackText {
format: wgpu::TextureFormat,
atlas: &wgpu::TextureView,
program: &CoreProgram,
coverage: bool,
) -> Option<Self> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = |label, module: &naga::Module| {
@@ -179,7 +181,7 @@ impl PackText {
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("Resource pack text"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -208,11 +210,17 @@ impl PackText {
}),
multiview_mask: None,
cache: None,
});
};
let source = wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment.clone()));
let pipeline = Pipeline::new(device, &desc, source, coverage);
if let Some(e) = pollster::block_on(scope.pop()) {
log::warn!("the resource pack's text shader is not used: {e}");
return None;
}
let Some(pipeline) = pipeline else {
log::warn!("the resource pack's text shader is not used: it has no colour output");
return None;
};
Some(Self {
pipeline,
group,
@@ -234,8 +242,13 @@ impl PackText {
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
/// pipeline and bind group again after.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
pass.set_pipeline(&self.pipeline);
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
range: std::ops::Range<u32>,
coverage: bool,
) {
pass.set_pipeline(self.pipeline.get(coverage));
pass.set_bind_group(0, &self.group, &[]);
pass.draw(range.clone(), range.start..range.start + 1);
}
+1
View File
@@ -94,6 +94,7 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+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);
+112 -7
View File
@@ -462,7 +462,7 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
@@ -837,24 +837,55 @@ fn build_overlay_uncached(
dependencies,
&mut extract,
)?;
// What the zips may unpack to, all threads together: a server's pack is a download the
// player only accepts, and a gigabyte of zeros deflates to a megabyte, so without it a pack
// at the download limit wrote hundreds of gigabytes here. Vanilla never unpacks a pack.
let budget = std::sync::atomic::AtomicU64::new(unpack_budget(packs));
// Inflating and writing a big pack's files one by one was most of a cold build; its
// folders all exist by now.
crate::par::par_map(&extract, |(owner, inner, target)| {
let extracted: io::Result<()> = crate::par::par_map(&extract, |(owner, inner, target)| {
let Some(Some(z)) = zips.get(*owner) else {
return Ok(());
};
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(target, bytes),
let copied = fs::File::create(target).and_then(|file| {
let mut file = io::BufWriter::new(file);
z.copy_to(inner, &mut file, &budget)?;
io::Write::flush(&mut file)
});
match copied {
Ok(()) => Ok(()),
// Past the budget the build fails, and the reload drops the server packs.
Err(e) if e.kind() == io::ErrorKind::FileTooLarge => Err(e),
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
Err(e) => {
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
let _ = fs::remove_file(target);
Ok(())
}
}
})
.into_iter()
.collect()
.collect();
if extracted.is_err() {
// What was unpacked before the budget ran out goes too, not only the marker.
let _ = fs::remove_dir_all(out);
}
extracted
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
fn unpack_budget(packs: &[Pack]) -> u64 {
packs
.iter()
.filter_map(|p| match &p.source {
Source::Zip(z) => fs::metadata(z).ok().map(|m| m.len()),
Source::Dir(_) => None,
})
.sum::<u64>()
.saturating_mul(16)
.clamp(64 << 20, 8 << 30)
}
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
/// than from the top pack only.
@@ -1000,7 +1031,10 @@ pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result
(h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
});
let out = cache.join(format!("{hash:016x}"));
cache::hold(&out)?;
build_overlay(vanilla, packs, &out)?;
// Old selections' overlays would otherwise stay for good, each a whole unpacked copy.
cache::prune(cache);
Ok(out)
}
@@ -1736,6 +1770,77 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_pack_that_unpacks_past_its_budget_fails_and_leaves_nothing() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-bomb-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/block/stone.png"),
b"vanilla",
)
.unwrap();
fs::create_dir_all(base.join("packs")).unwrap();
let pack = |name: &str, files: &[(&str, usize)]| {
let path = base.join("packs").join(name);
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
let deflated = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("pack.mcmeta", deflated).unwrap();
zip.write_all(br#"{"pack":{"description":"d","pack_format":97}}"#)
.unwrap();
let zeros = vec![0u8; 1 << 20];
for (file, mib) in files {
zip.start_file(*file, deflated).unwrap();
for _ in 0..*mib {
zip.write_all(&zeros).unwrap();
}
}
zip.finish().unwrap();
fs::metadata(&path).unwrap().len()
};
// Four 24 MiB files of zeros: well under the per-file limit, 96 MiB in all from a zip of
// a few hundred KiB, past the 64 MiB a small pack may unpack to.
let size = pack(
"bomb.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
("assets/minecraft/textures/block/c.png", 24),
("assets/minecraft/textures/block/d.png", 24),
],
);
assert!(size < 1 << 20, "the bomb is small: {size} bytes");
let packs = discover(&vanilla, &base.join("packs"));
assert_eq!(packs.len(), 1);
let out = base.join("overlay");
let error = build_overlay(&vanilla, &packs, &out).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::FileTooLarge, "{error}");
assert!(
!out.exists(),
"what was unpacked before the budget ran out stays"
);
// The same files within the budget unpack whole.
fs::remove_file(base.join("packs/bomb.zip")).unwrap();
pack(
"fine.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
],
);
let packs = discover(&vanilla, &base.join("packs"));
build_overlay(&vanilla, &packs, &out).unwrap();
assert_eq!(
fs::metadata(out.join("assets/minecraft/textures/block/b.png"))
.unwrap()
.len(),
24 << 20
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
@@ -297,6 +297,107 @@ fn lock_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("lock")
}
/// The overlays [`prune`] keeps in a cache folder, newest use first. Each holds a whole unpacked
/// copy of its zip packs, and every server pack, and every new version of one, makes another.
pub(super) const KEEP: usize = 8;
/// The file whose shared lock says a process uses `out`, and whose time is its last use.
fn use_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("use")
}
/// Marks `out` as used by this process until it exits ([`prune`] never deletes it), and as used
/// now. The lock is taken before the overlay is built or read, so a prune in another process
/// that took the folder first finishes deleting it before this one rebuilds it.
pub(super) fn hold(out: &Path) -> io::Result<()> {
use std::sync::{Mutex, PoisonError};
static HELD: Mutex<Vec<(PathBuf, fs::File)>> = Mutex::new(Vec::new());
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner);
let at = match held.iter().position(|(path, _)| path == out) {
Some(at) => at,
None => {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(use_path(out))?;
file.lock_shared()?;
held.push((out.into(), file));
held.len() - 1
}
};
// Only the order of the prune depends on it: a filesystem without the time loses nothing.
let _ = held[at].1.set_modified(std::time::SystemTime::now());
Ok(())
}
/// Deletes the overlays in `cache` past the [`KEEP`] most recently used, skipping any that a
/// process holds ([`hold`]) or builds. Only `<16 hex digits>` folders are overlays.
pub(super) fn prune(cache: &Path) {
let Ok(entries) = fs::read_dir(cache) else {
return;
};
let mut overlays: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.len() == 16 && n.bytes().all(|b| b.is_ascii_hexdigit()))
&& p.is_dir()
})
.map(|p| {
// An overlay built before use files existed goes by its marker or its folder.
let used = [use_path(&p), marker_path(&p), p.clone()]
.iter()
.find_map(|f| fs::metadata(f).and_then(|m| m.modified()).ok())
.unwrap_or(std::time::UNIX_EPOCH);
(used, p)
})
.collect();
overlays.sort_by_key(|(used, _)| std::cmp::Reverse(*used));
for (_, old) in overlays.into_iter().skip(KEEP) {
if let Err(e) = remove_unused(&old) {
log::warn!(
"Horizon: couldn't remove the old pack overlay {}: {e}",
old.display()
);
}
}
}
/// Deletes the overlay `out` unless a process builds or uses it. The lock files stay: deleting
/// them would let two processes lock different files of the same name.
fn remove_unused(out: &Path) -> io::Result<()> {
let open = |path: PathBuf| {
fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
};
let building = open(lock_path(out))?;
let using = open(use_path(out))?;
for lock in [&building, &using] {
match lock.try_lock() {
Ok(()) => {}
Err(fs::TryLockError::WouldBlock) => return Ok(()),
Err(fs::TryLockError::Error(e)) => return Err(e),
}
}
// The marker first: a folder half deleted is never taken for a built one.
match fs::remove_file(marker_path(out)) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
_ => {}
}
fs::remove_dir_all(out)
}
pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
@@ -368,6 +469,77 @@ mod tests {
use crate::{packs, text::Text};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn prune_keeps_the_most_recently_used_overlays_and_those_in_use() {
let f = Fixture::new();
let cache = f.0.join("cache");
let now = std::time::SystemTime::now();
// KEEP + 4 overlays, overlay 0 used last; one more not an overlay.
let overlays: Vec<PathBuf> = (0..KEEP + 4)
.map(|i| {
let out = cache.join(format!("{i:016x}"));
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
f.write(&format!("cache/{i:016x}.overlay-cache.json"), b"{}");
let used = fs::File::create(use_path(&out)).unwrap();
used.set_modified(now - std::time::Duration::from_secs(60 * i as u64))
.unwrap();
out
})
.collect();
f.write("cache/notes/keep.txt", b"x");
// Another process uses the oldest, and builds the second oldest.
let using = fs::File::open(use_path(&overlays[KEEP + 3])).unwrap();
using.lock_shared().unwrap();
let building = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(lock_path(&overlays[KEEP + 2]))
.unwrap();
building.lock().unwrap();
prune(&cache);
let kept: Vec<bool> = overlays.iter().map(|o| o.is_dir()).collect();
let mut expected = vec![true; KEEP];
expected.extend([false, false, true, true]);
assert_eq!(kept, expected);
assert!(
!marker_path(&overlays[KEEP]).exists(),
"never reused half deleted"
);
assert!(cache.join("notes/keep.txt").exists());
// Released, they go at the next prune.
drop((using, building));
prune(&cache);
assert_eq!(
overlays.iter().filter(|o| o.is_dir()).count(),
KEEP,
"only the most recent stay"
);
assert!(overlays[..KEEP].iter().all(|o| o.is_dir()));
}
#[test]
fn an_overlay_held_by_this_process_is_never_pruned() {
let f = Fixture::new();
let cache = f.0.join("cache");
let held = cache.join("00000000000000ff");
f.write("cache/00000000000000ff/assets/a.png", b"x");
hold(&held).unwrap();
// Older than KEEP others, and still in use.
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
fs::File::options()
.write(true)
.open(use_path(&held))
.unwrap()
.set_modified(old)
.unwrap();
for i in 0..KEEP {
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
}
prune(&cache);
assert!(held.is_dir());
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
+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() {
@@ -0,0 +1,63 @@
//! The randoms vanilla creates unseeded: `RandomSource.create()` (sounds,
//! screens, the torch flicker, a raid's random) and an entity's own random
//! (`Entity.random`). Each is seeded from one shared counter and the clock, as
//! `RandomSupport.generateUniqueSeed` does. Kept out of `horizon_core::random`,
//! whose streams are only ever seeded explicitly.
use horizon_core::random::LegacyRandom;
/// `RandomSource.create()`.
pub fn create() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
/// An entity's own random (`Entity.random`). Vanilla's is
/// `RandomSource.createThreadLocalInstance`, seeded from Java's
/// `ThreadLocalRandom`; Horizon seeds it as `create` does. Draws from an
/// entity's own random never touch the level's.
pub fn entity() -> LegacyRandom {
create()
}
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
/// come out the same every run) the clock is left out, so the seeds repeat run
/// to run, in the order they are taken.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
if still() {
return next;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// `HORIZON_STILL=1`, read once, as the clock's `still_requested` reads it.
fn still() -> bool {
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::RandomSource;
/// One shared counter: seeds taken back to back never repeat, even when
/// the clock hasn't moved between them.
#[test]
fn each_seed_and_each_random_is_new() {
let seeds: std::collections::HashSet<i64> = (0..1000).map(|_| unique_seed()).collect();
assert_eq!(seeds.len(), 1000);
assert_ne!(create().next_long(), create().next_long());
}
}
@@ -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);
@@ -1580,27 +1603,6 @@ impl SoundEngine {
pub fn active_instances(&self) -> Vec<InstanceId> {
self.instance_to_channel.iter().map(|(i, _)| *i).collect()
}
/// A fresh unseeded random (`RandomSource.create()`).
pub fn unseeded() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
}
/// `RandomSupport.generateUniqueSeed`.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// The `SoundManager` calls game code makes.
pub trait Sounds {
@@ -1774,7 +1776,7 @@ mod tests {
silent: false,
underwater: false,
};
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
let mut s = SoundInstance::riding_happy_ghast(&ghast, crate::random_source::create());
assert_eq!(s.volume, 0.0);
let mut ctx = TickContext {
ridden: Some(ghast),
@@ -1821,7 +1823,7 @@ mod tests {
attack_scale: 0.0,
sniffer_digging: true,
};
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
assert_eq!(s.position, [0.0; 3]);
assert!(!s.looping);
let mut ctx = TickContext {
@@ -1836,7 +1838,7 @@ mod tests {
s.tick(&ctx);
assert!(s.stopped);
// Removed (missing): stop.
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
ctx.mobs.clear();
s.tick(&ctx);
assert!(s.stopped);
@@ -1850,7 +1852,7 @@ mod tests {
silent: false,
underwater: true,
};
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
let mut s = SoundInstance::riding_nautilus(&nautilus, crate::random_source::create());
let mut ctx = TickContext {
ridden: Some(nautilus),
..TickContext::default()
@@ -1953,7 +1955,7 @@ mod tests {
position: [10.0, 64.0, 5.0],
speed: 0.0,
};
let mut s = SoundInstance::minecart(&cart, SoundEngine::unseeded());
let mut s = SoundInstance::minecart(&cart, crate::random_source::create());
assert!(s.can_start_silent);
assert_eq!(s.volume, 0.0);
@@ -1993,8 +1995,10 @@ mod tests {
position: [0.0, 60.0, 0.0],
speed: 0.4,
};
let mut surface = SoundInstance::riding_minecart(&cart, false, SoundEngine::unseeded());
let mut underwater = SoundInstance::riding_minecart(&cart, true, SoundEngine::unseeded());
let mut surface =
SoundInstance::riding_minecart(&cart, false, crate::random_source::create());
let mut underwater =
SoundInstance::riding_minecart(&cart, true, crate::random_source::create());
let mut ctx = TickContext {
vehicle: Some(99),
@@ -1,8 +1,9 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::random_source;
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
use crate::sound_engine::{InstanceId, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
@@ -44,7 +45,7 @@ impl SoundPreview {
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
let instance = SoundInstance::for_ui(event, 1.0, volume, random_source::create());
self.active = Some(sounds.play_instance(instance, true).0);
}
@@ -72,7 +73,7 @@ impl SoundPreview {
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_engine::{Bound, SoundEngine, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
@@ -70,6 +70,12 @@ pub type Proto = Box<dyn std::any::Any + Send>;
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
/// The persistent wrapper supplies starts from their owning chunk, in every dimension.
fn set_saved_starts(
&self,
_source: Arc<horizon_world::worldgen::generator::SavedStructureStarts>,
) {
}
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler>
where
Self: Sized,
+99
View File
@@ -9,6 +9,102 @@ use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// Runtime trim slots and the completed ordered requests for their current atlas.
/// A frame with more requests than slots keeps the same final residents as the
/// sequential LRU pass, without decoding and evicting them again every frame.
#[derive(Default)]
pub struct TrimSlots {
slots: Vec<(String, usize)>,
atlas_revision: u64,
prepared: Option<Vec<TrimTexture>>,
}
impl TrimSlots {
/// The renderer was rebuilt: runtime pixels must be uploaded again.
pub fn forget(&mut self) {
self.atlas_revision = 0;
self.prepared = None;
}
pub fn prepare(
&mut self,
wanted: Vec<TrimTexture>,
root: &Path,
atlas: &mut crate::hud::HudAtlas,
patches: &mut Vec<crate::font::Patch>,
) {
if self.atlas_revision != atlas.revision {
for (name, _) in self.slots.drain(..) {
atlas.sprites.remove(&name);
}
self.atlas_revision = atlas.revision;
self.prepared = None;
}
let before = self.slots.len();
self.slots
.retain(|(name, _)| atlas.sprites.contains_key(name));
if before != self.slots.len() {
self.prepared = None;
}
if self.prepared.as_ref() == Some(&wanted) {
return;
}
let mut complete = true;
for t in &wanted {
let name = t.sprite();
if let Some(i) = self.slots.iter().position(|e| e.0 == name) {
let e = self.slots.remove(i);
self.slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(root) else {
complete = false;
continue;
};
let slot =
match (0..crate::hud::TRIM_SLOTS).find(|i| !self.slots.iter().any(|e| e.1 == *i)) {
Some(free) => free,
None => {
let (old, slot) = self.slots.remove(0);
atlas.sprites.remove(&old);
slot
}
};
let Some(area) = atlas.sprites.get(&format!("horizon/trim{slot}")).copied() else {
complete = false;
continue;
};
if w > area.w || h > area.h {
complete = false;
continue;
}
let (aw, ah) = (atlas.width as f32, atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
patches.push(crate::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
atlas.sprites.insert(
name.clone(),
crate::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
},
);
self.slots.push((name, slot));
}
// Failed or not-yet-available textures retain their per-frame retries.
self.prepared = complete.then_some(wanted);
}
}
/// An RGBA image: width, height, pixels.
pub type Image = (u32, u32, Vec<u8>);
@@ -249,6 +345,9 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
#[cfg(test)]
mod slots_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,192 @@
use super::*;
use crate::hud::{HudAtlas, Rect, TRIM_SLOTS};
use std::path::PathBuf;
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-trim-slots-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn texture(&self, id: u8) -> TrimTexture {
TrimTexture {
texture: format!("test:trims/entity/humanoid/{id}"),
palette: None,
}
}
fn write(&self, id: u8, width: u32) {
let path = asset_path(&self.0, "textures", &self.texture(id).texture, ".png");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), width, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[id, 255 - id, 42, 255].repeat(width as usize))
.unwrap();
}
fn atlas(&self) -> HudAtlas {
let mut atlas = HudAtlas::fonts(&self.0).unwrap();
atlas.width = TRIM_SLOTS as u32 * 2;
atlas.height = 1;
atlas.pixels = vec![0; atlas.width as usize * 4];
for i in 0..TRIM_SLOTS {
atlas.sprites.insert(
format!("horizon/trim{i}"),
Rect {
u0: i as f32 / TRIM_SLOTS as f32,
u1: (i + 1) as f32 / TRIM_SLOTS as f32,
v0: 0.0,
v1: 1.0,
w: 2,
h: 1,
},
);
}
atlas
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn upload(atlas: &mut HudAtlas, patches: &mut Vec<crate::font::Patch>) {
for patch in patches.drain(..) {
for y in 0..patch.height as usize {
let offset = ((patch.y as usize + y) * atlas.width as usize + patch.x as usize) * 4;
let width = patch.width as usize * 4;
atlas.pixels[offset..offset + width]
.copy_from_slice(&patch.rgba[y * width..(y + 1) * width]);
}
}
}
fn color(atlas: &HudAtlas, texture: &TrimTexture) -> Option<[u8; 4]> {
let rect = atlas.sprites.get(&texture.sprite())?;
let x = (rect.u0 * atlas.width as f32).round() as usize;
Some(atlas.pixels[x * 4..x * 4 + 4].try_into().unwrap())
}
#[test]
fn overflow_keeps_the_rendered_pixels_without_reuploading_each_frame() {
let fixture = Fixture::new();
let wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
for id in 0..33 {
fixture.write(id, 2);
}
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
// The original sequential 32-slot pass finishes with requests 1..=32.
assert_eq!(color(&atlas, &wanted[0]), None);
for (id, texture) in wanted.iter().enumerate().skip(1) {
assert_eq!(
color(&atlas, texture),
Some([id as u8, 255 - id as u8, 42, 255])
);
}
for _ in 0..3 {
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(
patches.is_empty(),
"unchanged overflow must not upload again"
);
}
wanted
.iter()
.skip(1)
.for_each(|t| assert!(color(&atlas, t).is_some()));
}
#[test]
fn changing_request_order_or_equipment_changes_the_resident_images() {
let fixture = Fixture::new();
for id in 0..34 {
fixture.write(id, 2);
}
let mut wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
wanted.rotate_left(1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &fixture.texture(1)), None);
assert_eq!(color(&atlas, &fixture.texture(0)), Some([0, 255, 42, 255]));
wanted = vec![fixture.texture(33), fixture.texture(2)];
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([33, 222, 42, 255]));
assert_eq!(color(&atlas, &wanted[1]), Some([2, 253, 42, 255]));
}
#[test]
fn an_atlas_rebuild_uploads_the_same_requests_again() {
let fixture = Fixture::new();
fixture.write(7, 2);
let wanted = vec![fixture.texture(7)];
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
let mut atlas = fixture.atlas();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
atlas = fixture.atlas();
fixture.write(7, 1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].width, 1, "reload must decode the new image");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.pixels.fill(0);
slots.forget();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(
patches.len(),
1,
"a rebuilt renderer needs the pixels again"
);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.sprites.remove(&wanted[0].sprite());
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1, "an erased sprite must be restored too");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
}
#[test]
fn missing_or_oversized_images_are_retried_when_they_become_available() {
let fixture = Fixture::new();
let wanted = vec![fixture.texture(9)];
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), None);
fixture.write(9, 3);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(patches.is_empty());
fixture.write(9, 2);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([9, 246, 42, 255]));
}
@@ -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);
});
@@ -56,6 +56,32 @@ fn luma(c: vec3<f32>) -> f32 {
return vec4(b, centre.a);
}
// Contrast-adaptive sharpening (after AMD's CAS) of TAA's upscaled picture: each
// pixel pushed away from its four neighbours, less where they already differ a lot,
// so edges get their crispness back without halos or lifted grain.
// The Upscaling Sharpness setting, 0..1.
override sharpness: f32 = 0.85;
@fragment fn fs_sharpen(in: PostOut) -> @location(0) vec4<f32> {
let size = vec2<i32>(textureDimensions(world));
let p = clamp(vec2<i32>(in.uv * vec2<f32>(size)), vec2(0), size - 1);
let centre = textureLoad(world, p, 0);
let c = centre.rgb;
let n = textureLoad(world, clamp(p + vec2(0, -1), vec2(0), size - 1), 0).rgb;
let s = textureLoad(world, clamp(p + vec2(0, 1), vec2(0), size - 1), 0).rgb;
let w = textureLoad(world, clamp(p + vec2(-1, 0), vec2(0), size - 1), 0).rgb;
let e = textureLoad(world, clamp(p + vec2(1, 0), vec2(0), size - 1), 0).rgb;
let lo = min(c, min(min(n, s), min(w, e)));
let hi = max(c, max(max(n, s), max(w, e)));
// How much room there is before clipping, per channel: little at strong edges.
let room = clamp(min(lo, 2.0 - hi) / max(hi, vec3(0.0001)), vec3(0.0), vec3(1.0));
// Up to -1/5, the most CAS allows (the Upscaling Sharpness setting's 100%).
let lobe = -sqrt(room) * 0.2 * sharpness;
let sharp = (c + lobe * (n + s + w + e)) / (1.0 + 4.0 * lobe);
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(clamp(sharp, lo, hi), centre.a);
}
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
// depth buffer through the camera's motion and clamped to this frame's 3x3
// neighbourhood so that what moved or appeared does not ghost.
@@ -95,6 +121,28 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
return max(c / weight, vec3(0.0));
}
fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
return vec3(0.25 * (c.r + 2.0 * c.g + c.b), 0.5 * (c.r - c.b), 0.25 * (2.0 * c.g - c.r - c.b));
}
fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
return max(vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z), vec3(0.0));
}
// Moves `c` toward the box's centre until it lies inside (clipping keeps the
// colour's hue where a per-channel clamp would shift it).
fn clip_box(c: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> vec3<f32> {
let mid = 0.5 * (hi + lo);
let half_size = max(0.5 * (hi - lo), vec3(0.0001));
let d = c - mid;
let unit = abs(d / half_size);
let far = max(unit.x, max(unit.y, unit.z));
if far > 1.0 {
return mid + d / far;
}
return c;
}
// It runs at the history's size, which upscaling makes larger than the world's.
// The history is unjittered: each output pixel takes the world pixel whose
// jittered sample lands nearest its centre, weighted by how near it lands.
@@ -106,17 +154,24 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let last = vec2<i32>(size) - 1;
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
let centre = textureLoad(world, p, 0);
var lo = centre.rgb;
var hi = centre.rgb;
var lo = to_ycocg(centre.rgb);
var hi = lo;
// Mean and spread of the 3x3 (YCoCg): the history is clipped to a box around
// the mean a few deviations wide, tighter than min..max, so that what changed
// does not linger as a smear.
var m1 = vec3(0.0);
var m2 = vec3(0.0);
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
// the edges of what is in front from trailing.
var near = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2(x, y), vec2(0), last);
let c = textureLoad(world, q, 0).rgb;
let c = to_ycocg(textureLoad(world, q, 0).rgb);
lo = min(lo, c);
hi = max(hi, c);
m1 += c;
m2 += c * c;
near = max(near, textureLoad(depth, q, 0).r);
}
}
@@ -126,12 +181,19 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
return vec4(centre.rgb, centre.a);
}
let past = clamp(sample_history(uv), lo, hi);
// How far this pixel moved since the last frame, in output pixels.
let speed = length((uv - in.uv) * vec2<f32>(textureDimensions(history)));
let moving = clamp(speed / 6.0, 0.0, 1.0);
let mean = m1 / 9.0;
let sigma = sqrt(max(m2 / 9.0 - mean * mean, vec3(0.0)));
let wide = mix(1.25, 0.75, moving) * sigma;
let past = from_ycocg(clip_box(to_ycocg(sample_history(uv)), max(lo, mean - wide), min(hi, mean + wide)));
// How far the sample landed from this pixel's centre, in output pixels: a
// Gaussian of it scales the new frame's share, so that frames whose sample
// fell far away (most of them when upscaling) barely move the pixel.
// fell far away (most of them when upscaling) barely move the pixel. Under
// motion the history is resampled every frame and blurs, so new frames weigh more.
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
let share = 0.1 * exp(-2.0 * dot(off, off));
let share = mix(0.1, 0.3, moving) * exp(-2.0 * dot(off, off));
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(mix(past, centre.rgb, share), centre.a);
}
+81 -21
View File
@@ -7,7 +7,10 @@ use std::{
fs,
io::{self, Read, Seek, SeekFrom},
path::Path,
sync::Arc,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use zip::{CompressionMethod, ZipArchive};
@@ -169,30 +172,12 @@ impl Zip {
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
let (method, raw) = self.raw(name)?;
match method {
CompressionMethod::Stored => Ok(raw),
CompressionMethod::Deflated => {
// Java's ZipFile trusts neither size an entry declares: it
// inflates the stream to its end and keeps what comes out.
// Packs in the wild (a Nexo-generated one served through
// Dropbox, for one) carry central-directory sizes that point
// at other files, and miniz_oxide, which refuses input past
// the stream's end, failed whole entries with "corrupt
// deflate stream" and with them the whole pack. Only the
// size limit stands between the archive and a zip bomb.
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
decoder
inflate(&raw)
.take(MAX_ENTRY as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|_| invalid("corrupt deflate stream"))?;
@@ -204,6 +189,81 @@ impl Zip {
_ => Err(invalid("unsupported zip compression")),
}
}
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
/// with [`io::ErrorKind::FileTooLarge`], `out` holding part of the entry; the entry's own
/// limit ([`MAX_ENTRY`]) still applies. Memory stays at the compressed entry and one piece,
/// whatever the entry inflates to. The bytes written.
pub fn copy_to(
&self,
name: &str,
out: &mut impl io::Write,
budget: &AtomicU64,
) -> io::Result<u64> {
let (method, raw) = self.raw(name)?;
let mut from: Box<dyn Read + '_> = match method {
CompressionMethod::Stored => Box::new(raw.as_slice()),
CompressionMethod::Deflated => Box::new(inflate(&raw)),
_ => return Err(invalid("unsupported zip compression")),
};
let mut piece = vec![0; 64 << 10];
let mut total = 0u64;
loop {
let n = match from.read(&mut piece) {
Ok(0) => return Ok(total),
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => return Err(invalid("corrupt deflate stream")),
};
total += n as u64;
if total > MAX_ENTRY as u64 {
return Err(invalid("entry inflates past the size limit"));
}
take(budget, n as u64)?;
out.write_all(&piece[..n])?;
}
}
/// The entry `name` as stored: its compression and its bytes.
fn raw(&self, name: &str) -> io::Result<(CompressionMethod, Vec<u8>)> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
Ok((method, raw))
}
}
/// `n` bytes taken from `budget`, or `FileTooLarge` (taking nothing) when it has fewer left.
fn take(budget: &AtomicU64, n: u64) -> io::Result<()> {
let mut left = budget.load(Ordering::Relaxed);
loop {
let rest = left.checked_sub(n).ok_or_else(|| {
io::Error::new(
io::ErrorKind::FileTooLarge,
"the packs unpack to more than their size allows",
)
})?;
match budget.compare_exchange_weak(left, rest, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return Ok(()),
Err(now) => left = now,
}
}
}
/// A deflated entry's bytes as they inflate. Java's ZipFile trusts neither size an entry
/// declares: it inflates the stream to its end and keeps what comes out. Packs in the wild (a
/// Nexo-generated one served through Dropbox, for one) carry central-directory sizes that point
/// at other files, and miniz_oxide, which refuses input past the stream's end, failed whole
/// entries with "corrupt deflate stream" and with them the whole pack. Only the size limits
/// stand between the archive and a zip bomb.
fn inflate(raw: &[u8]) -> flate2::read::DeflateDecoder<&[u8]> {
flate2::read::DeflateDecoder::new(raw)
}
/// The 22 fixed bytes of the end-of-central-directory record.
@@ -47,6 +47,21 @@
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
"horizon.studio.freecam.enabled": "Studio-Freikamera aktiviert. WASD bewegt sie, Leertaste und Umschalt heben oder senken sie, Strg beschleunigt.",
"horizon.studio.freecam.disabled": "Studio-Freikamera deaktiviert",
"horizon.studio.freecam.unavailable": "Die Studio-Freikamera konnte keine Spielerkamera finden",
"horizon.studio.freecam.replay_required": "Die Studio-Freikamera ist während der Wiedergabe verfügbar",
"horizon.studio.freecam.usage": "Verwendung: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Kameraschlüsselbild bei %s ms hinzugefügt. Der Pfad wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.cleared": "Kamerapfad geleert. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.removed": "Kameraschlüsselbild %s entfernt. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.save_error": "Kamerapfad konnte nicht gespeichert werden: %s",
"horizon.studio.keyframe.remove_usage": "Verwendung: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Kameraschlüsselbildindex liegt außerhalb des Bereichs",
"horizon.studio.keyframe.usage": "Verwendung: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
@@ -113,6 +128,7 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling-Schärfe",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -208,6 +224,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
@@ -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",
@@ -104,6 +107,21 @@
"horizon.studio.replay.seek.invalid": "Invalid replay position",
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
"horizon.studio.replay.closed": "Studio replay closed",
"horizon.studio.freecam.enabled": "Studio free camera enabled. Use WASD to move, Space and Shift to rise or descend, and Control to move faster.",
"horizon.studio.freecam.disabled": "Studio free camera disabled",
"horizon.studio.freecam.unavailable": "Studio free camera could not find a player camera",
"horizon.studio.freecam.replay_required": "Studio free camera is available during replay playback",
"horizon.studio.freecam.usage": "Usage: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Camera keyframe added at %s ms. The path is saved when you close the replay.",
"horizon.studio.keyframe.cleared": "Camera path cleared. The change is saved when you close the replay.",
"horizon.studio.keyframe.removed": "Removed camera keyframe %s. The change is saved when you close the replay.",
"horizon.studio.keyframe.save_error": "Could not save camera path: %s",
"horizon.studio.keyframe.remove_usage": "Usage: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Camera keyframe index is out of range",
"horizon.studio.keyframe.usage": "Usage: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio camera path playback enabled",
"horizon.studio.path.unlocked": "Studio camera path playback stopped",
"horizon.studio.path.usage": "Usage: /studio path <lock|unlock> (add at least one keyframe first)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -216,6 +234,7 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling Sharpness",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -317,6 +336,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
@@ -947,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.",
@@ -965,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",
@@ -76,6 +76,7 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"Upscaling Sharpness": "Резкость апскейла",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -167,6 +168,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Повышает резкость увеличенной картинки. Выше: чётче края, больше мерцания.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
@@ -472,5 +472,450 @@
"Pack settings": "Налаштування пакета",
"Built-in settings": "Вбудовані налаштування",
"%s settings": "Налаштування %s",
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет."
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет.",
"horizon.extras.header.world": "Світ",
"horizon.extras.pregen": "Попередня генерація чанків",
"horizon.extras.pregen.tooltip": "Вмикає /pregen у ваших одиночних і LAN-світах. З увімкненими читами введіть /pregen start, щоб згенерувати область, перш ніж її досліджувати. На серверах, до яких ви приєднуєтесь, команда не вмикається.",
"horizon.options.three_d_skin_layers": "3D-шари скіна",
"horizon.options.play_in_background": "Гра у фоні",
"horizon.options.trigger_threshold": "Поріг тригера",
"horizon.studio.record.usage": "Використання: /studio record <start [назва]|stop|status>",
"horizon.studio.record.started": "Запис Studio розпочато: %s",
"horizon.studio.record.saved": "Запис Studio збережено: %s",
"horizon.studio.record.error": "Помилка запису Studio: %s",
"horizon.studio.record.status.active": "Запис Studio триває — %s с, %s пакетів, %s КБ (%s)",
"horizon.studio.record.status.inactive": "Запис Studio не ведеться",
"horizon.studio.replay.usage": "Використання: /studio replay <open ШЛЯХ|pause|resume|speed МНОЖНИК|seek МС|status|close>",
"horizon.studio.replay.open.usage": "Використання: /studio replay open <ШЛЯХ>",
"horizon.studio.replay.opened": "Повтор Studio відкрито: %s (%s с, %s пакетів)",
"horizon.studio.replay.open_error": "Не вдалося відкрити повтор: %s",
"horizon.studio.replay.paused": "Повтор Studio призупинено",
"horizon.studio.replay.resumed": "Повтор Studio продовжено",
"horizon.studio.replay.inactive": "Немає активного повтору Studio",
"horizon.studio.replay.speed.usage": "Використання: /studio replay speed <МНОЖНИК>",
"horizon.studio.replay.speed.set": "Швидкість повтору Studio: %s×",
"horizon.studio.replay.speed.invalid": "Неправильна швидкість повтору",
"horizon.studio.replay.seek.usage": "Використання: /studio replay seek <МІЛІСЕКУНДИ>",
"horizon.studio.replay.seeked": "Повтор Studio перемотано на %s мс (%s с)",
"horizon.studio.replay.seek.error": "Не вдалося перемотати повтор: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Перемотування назад доступне лише в окремому повторі.",
"horizon.studio.replay.seek.invalid": "Неправильна позиція повтору",
"horizon.studio.replay.status": "Повтор Studio: %s / %s мс (%s%%), швидкість %s×, пауза: %s, завершено: %s",
"horizon.studio.replay.closed": "Повтор Studio закрито",
"horizon.pages.studio": "Студія повторів",
"horizon.pages.music": "Музика",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Верхній світ",
"horizon.pages.music.creative": "Творчий режим",
"horizon.pages.music.underwater": "Під водою",
"horizon.pages.music.end": "Енд",
"horizon.pages.music.boss": "Бос",
"horizon.pages.music.credits": "Титри",
"horizon.pages.music.overworld_biomes": "Біоми Верхнього світу",
"horizon.pages.music.nether_biomes": "Біоми Незеру",
"horizon.pages.music.end_biomes": "Біоми Енду",
"horizon.pages.music.other": "Інша музика",
"horizon.pages.music.empty": "Немає музичних треків: звуки гри не завантажено.",
"horizon.pages.music.tooltip": "Вбудовані треки за ситуаціями: увімкніть будь-який зараз або вимкніть, щоб гра більше його не обирала.",
"horizon.pages.studio.tooltip": "Перегляд збережених записів і їхніх метаданих.",
"horizon.pages.open.studio": "Студія повторів...",
"horizon.studio.saved_recordings": "Збережені записи",
"horizon.studio.empty": "У теці записів немає жодного запису.",
"horizon.studio.empty_hint": "Введіть у світі /studio record start <назва> або покладіть туди файли .mcpr.",
"horizon.studio.open_folder": "Відкрити записи",
"horizon.studio.open_replay": "Відтворити повтор",
"horizon.studio.refresh": "Оновити",
"horizon.studio.unknown_server": "Невідомо",
"horizon.pages.open.music": "Музика...",
"horizon.mods.in_world": " (вийдіть зі світу, щоб змінити)",
"horizon.mods.browse": "Огляд модів...",
"horizon.mods.browse.title": "Огляд модів",
"horizon.mods.browse.search": "Пошук модів...",
"horizon.mods.browse.project": "Мод",
"horizon.mods.browse.loading": "Читання реєстру модів Horizon...",
"horizon.mods.browse.failed": "Не вдалося прочитати реєстр модів: %s",
"horizon.mods.browse.none": "Модів не знайдено",
"horizon.mods.browse.has_update": " (оновлення)",
"horizon.mods.browse.is_installed": " (встановлено)",
"horizon.mods.browse.by": "автор: %s",
"horizon.mods.browse.installing": "Встановлення...",
"horizon.mods.browse.install": "Встановити",
"horizon.mods.browse.update": "Оновити",
"horizon.mods.browse.installed": "Встановлено",
"horizon.mods.browse.open_page": "Відкрити сторінку",
"horizon.mods.browse.enabled": "Мод",
"horizon.mods.browse.changelog": "Список змін",
"horizon.mods.browse.done": "%s %s встановлено й увімкнено.",
"horizon.mods.browse.install_failed": "Не вдалося встановити: %s",
"horizon.mods.switch.worldgen": "змінює генерацію світу",
"horizon.mods.switch.registries": "додає блоки, предмети чи інші записи реєстрів",
"horizon.mods.switch.restart": "Перезапустіть гру, щоб застосувати %s: вони додають блоки, предмети або генерацію світу.",
"GPU Culling": "Відсікання на GPU",
"HDR Lighting": "HDR-освітлення",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Джерела світла, сонце й сонячне світло сяють яскравіше за білий. Вимкнено: як у SDR.",
"Smooth Gradients": "Плавні градієнти",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-бітний кадр: темне небо й тіні без смуг. Трохи більше відеопам’яті. З наступного запуску.",
"Not with software rendering, which runs it on the CPU.": "Не з програмним рендерингом, який виконує це на CPU.",
"Out of video memory: off until the next start.": "Забракло відеопам’яті: вимкнено до наступного запуску.",
"The graphics device was lost: off until the next start.": "Графічний пристрій втрачено: вимкнено до наступного запуску.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Поки не працює на DirectX 12: спробуйте графічний API Vulkan.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Відсікає ближній рельєф на GPU: за великої дальності промальовування менше навантажує CPU, більше — GPU.",
"horizon.realms.not_signed_in": "Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб грати на Realms.",
"horizon.extras.music_page": "Сторінка «Музика»",
"horizon.extras.zoom": "Наближення",
"horizon.extras.zoom_mod_install": "Наближення: встановіть у модах",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Коліщатко миші над кнопкою в списку налаштувань змінює її значення. Вимкнено, як у ванільній грі: коліщатко прокручує список.",
"horizon.extras.pack_browser": "Огляд наборів ресурсів",
"horizon.extras.music_page.tooltip": "У «Музика і звуки» з’являється сторінка «Музика...»: увімкніть будь-який вбудований трек зараз або вимкніть, щоб гра більше його не обирала. Нічого не змінюється, доки ви не вимкнете трек.",
"horizon.friends.relay_stopped": "Забагато неправильних коротких кодів, тому ретранслятор вимкнено, доки ви знову не поділитеся світом. Друзі все ще можуть приєднатися за повним кодом.",
"horizon.extras.pack_browser.tooltip": "У «Вибрати набори ресурсів» з’являється кнопка «Додати», яка шукає в Modrinth і встановлює набір у вашу теку наборів. Вимкнено: гра ніколи не звертається до Modrinth.",
"horizon.splitscreen.online_only": "Цей сервер працює в онлайн-режимі: офлайн-профілі не можуть приєднатися, кожному гравцеві потрібен власний акаунт Minecraft.",
"horizon.splitscreen.own_account": "Цей сервер вимагає від кожного гравця власного акаунта Minecraft",
"horizon.splitscreen.local_only": "Інші гравці можуть приєднатися до цього сервера лише з власним акаунтом Minecraft: офлайн-профілі грають у вашому світі, через LAN або на сервері у вашій локальній мережі.",
"Shaders": "Шейдери",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Пакети шейдерів — від Horizon і з теки shaders/ — та їхні налаштування.",
"Shader packs": "Пакети шейдерів",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Власний пресет ванільної гри: задає лише ванільні налаштування, як це робить Minecraft.",
"Fullscreen takes the display over, at the mode below.": "Повноекранний режим займає весь дисплей у режимі, вказаному нижче.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режим дисплея для ексклюзивного повноекранного режиму. Застосовується, коли ви виходите.",
"Blurs the world behind menus.": "Розмиває світ за меню.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Як вибираються текстури: з анізотропною фільтрацією чіткіші під кутом.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Рівень анізотропної фільтрації. Вищий — чіткіше під кутом.",
"Only with Anisotropic texture filtering.": "Лише з анізотропною фільтрацією текстур.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Обирає пакет шейдерів — від Horizon чи з теки shaders/ — і задає його налаштування.",
"Hidden Faces": "Приховані грані",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Прибирає грані листя за двома шарами листя: швидше, а крони ті самі.",
"Entity Culling Precheck": "Попередня перевірка відсікання сутностей",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Запускає попередню перевірку AABB мобів, коли завантажено стільки мобів. Нуль вимикає її.",
"%s mobs": "%s мобів",
"horizon.splitscreen.profiles.offline": " (Офлайн)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Увійдіть знову в лаунчері",
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
"horizon.splitscreen.account_unavailable": "%s офлайн. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.splitscreen.account_expired": "Вхід Microsoft для %s закінчився. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Додати набір ресурсів із Modrinth",
"horizon.pack.addWorking": "Запит до Modrinth...",
"horizon.pack.addNone": "Наборів ресурсів не знайдено",
"horizon.pack.addTitle": "Додати набір ресурсів",
"horizon.pack.install": "Встановити",
"horizon.pack.installing": "Встановлення",
"horizon.pack.installed": "Встановлено",
"horizon.pack.search": "Пошук",
"horizon.pack.searchHint": "Пошук у Modrinth...",
"horizon.pack.filters": "Фільтри",
"horizon.pack.filter.realistic": "Реалістичні",
"horizon.pack.filter.simplistic": "Спрощені",
"horizon.pack.filter.vanillaLike": "Як ванільні",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Не вдалося додати набір: %s",
"horizon.extras.header.cosmetics": "Косметика",
"horizon.extras.cosmetics_mine": "Моя косметика",
"horizon.extras.cosmetics_mine.tooltip": "Приховано: усі гравці Horizon бачать вас без косметики, і ви теж. Той самий перемикач, що й у Гардеробі лаунчера.",
"key.emote": "Колесо емоцій",
"horizon.emote_wheel.title": "Емоції",
"horizon.emote_wheel.empty": "Емоцій поки немає",
"horizon.emote_wheel.help": "Відпустіть або клацніть, щоб відтворити · 1–8 · Esc закриває",
"horizon.extras.cosmetics_others": "Косметика інших гравців",
"horizon.extras.cosmetics_others.tooltip": "Що носять інші гравці Horizon: усе, лише стилі ніків або нічого. Приховано, якщо у вас немає косметики, доки ви не оберете. Коли приховано, гра ніколи не питає, що вони носять.",
"horizon.extras.cosmetics_hide_fx": "Приховати сліди й аури інших гравців",
"horizon.extras.cosmetics_hide_fx.tooltip": "Сліди й аури інших гравців приховано; решту їхньої косметики видно. Типово вимкнено.",
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Приховано",
"horizon.extras.cosmetics.name_tags": "Лише ніки",
"horizon.moderation.duration.minute": "%s хв",
"horizon.moderation.duration.minutes": "%s хв",
"horizon.moderation.duration.hour": "%s год",
"horizon.moderation.duration.hours": "%s год",
"horizon.moderation.duration.day": "%s дн.",
"horizon.moderation.duration.days": "%s дн.",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Заглушених гравців немає",
"horizon.moderation.mutelist.count": "Заглушених гравців: %s:",
"horizon.moderation.chat_filter": "фільтр чату",
"horizon.moderation.until_unmuted": "до зняття заглушення",
"horizon.moderation.remaining": "ще на %s",
"horizon.moderation.mutelist.entry": "%1$s заглушено гравцем %2$s %3$s",
"horizon.moderation.invalid_duration": "%s — не тривалість: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <гравець> [тривалість] [причина]",
"horizon.moderation.usage.tempmute": "/tempmute <гравець> <тривалість> [причина]",
"horizon.moderation.usage.unmute": "/unmute <гравець>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "на %s",
"horizon.moderation.muted": "Заглушено %1$s %2$s",
"horizon.moderation.unmuted": "Знято заглушення з %s",
"horizon.moderation.not_muted": "%s не заглушено",
"horizon.tempban.usage": "/tempban <гравець> <тривалість> [причина]",
"horizon.tempban.invalid_duration": "%s — не тривалість: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Тимчасові бани потребують виділеного сервера",
"horizon.tempban.success": "%1$s на %2$s, до %3$s",
"horizon.tempban.list.empty": "Тимчасових банів немає",
"horizon.tempban.list.count": "Тимчасових банів: %s:",
"horizon.tempban.list.entry": "%1$s забанено гравцем %2$s %3$s (до %4$s)",
"horizon.options.pointer_speed": "Швидкість курсора в меню",
"horizon.options.scroll_speed": "Швидкість прокрутки в меню",
"horizon.options.repeat_delay": "Затримка повтору навігації",
"horizon.options.repeat_interval": "Інтервал повтору навігації",
"horizon.options.milliseconds": "%s мс",
"horizon.extras.cosmetics_solo": "В одиночній грі",
"horizon.extras.cosmetics_solo.tooltip": "Показувати вашу косметику й значок вашого ніка також в одиночних світах (сам нік — із «Власним ніком»). Вимкнено: лише на серверах.",
"horizon.extras.horizon_marks": "Логотипи Horizon",
"horizon.extras.horizon_marks.tooltip": "Показувати логотип Horizon біля гравців Horizon: маленький логотип перед іменами інших гравців, які грають у Horizon, над їхніми головами та в списку гравців. Його бачать лише гравці Horizon: сервери нічого не отримують.",
"horizon.extras.horizon_me": "Показувати мене як гравця Horizon",
"horizon.extras.horizon_me.tooltip": "Показувати мене як гравця Horizon: інші гравці Horizon бачать логотип біля вашого імені. Коли ввімкнено, сервіс відповідає кожному, хто про вас питає, що ви грали в Horizon за останні 3 години; коли вимкнено — ніколи про вас не повідомляє.",
"horizon.pages.pvp": "PvP та HUD",
"horizon.pages.pvp.tooltip": "Налаштування прицілу, HUD і PvP, усі типово вимкнені. Тут немає нічого, що показує більше, ніж ванільна гра.",
"horizon.pages.open.pvp": "PvP та HUD...",
"horizon.pvp.header.crosshair": "Приціл",
"horizon.pvp.crosshair": "Власний приціл",
"horizon.pvp.crosshair_shape": "Форма",
"horizon.pvp.crosshair_size": "Розмір",
"horizon.pvp.crosshair_gap": "Проміжок",
"horizon.pvp.crosshair_thickness": "Товщина",
"horizon.pvp.crosshair_colour": "Колір",
"horizon.pvp.crosshair_outline": "Обведення",
"horizon.pvp.crosshair_dot": "Точка в центрі",
"horizon.pvp.crosshair_hover": "Колір на сутностях",
"horizon.pvp.crosshair.tooltip": "Малює ваш власний приціл замість ванільного. Початкові налаштування виглядають як ванільний.",
"horizon.pvp.crosshair_shape.tooltip": "Хрест, T, X, коло, квадрат або лише точка.",
"horizon.pvp.crosshair_size.tooltip": "Довжина кожного променя в пікселях інтерфейсу.",
"horizon.pvp.crosshair_gap.tooltip": "Порожнє місце посередині.",
"horizon.pvp.crosshair_thickness.tooltip": "Ширина кожної лінії.",
"horizon.pvp.crosshair_colour.tooltip": "«Інвертований» показує колір під прицілом інвертованим, як ванільний приціл.",
"horizon.pvp.crosshair_outline.tooltip": "Тонкий чорний край, щоб приціл було видно на будь-якому фоні.",
"horizon.pvp.crosshair_dot.tooltip": "Точка посередині прицілу.",
"horizon.pvp.crosshair_hover.tooltip": "Інший колір, коли приціл наведено на моба чи гравця, якого можна вдарити, — як це визначає ванільний індикатор атаки.",
"horizon.pvp.colour.inverted": "Інвертований",
"horizon.pvp.colour.white": "Білий",
"horizon.pvp.colour.red": "Червоний",
"horizon.pvp.colour.orange": "Помаранчевий",
"horizon.pvp.colour.yellow": "Жовтий",
"horizon.pvp.colour.green": "Зелений",
"horizon.pvp.colour.cyan": "Бірюзовий",
"horizon.pvp.colour.blue": "Синій",
"horizon.pvp.colour.magenta": "Пурпуровий",
"horizon.pvp.colour.black": "Чорний",
"horizon.pvp.shape.cross": "Хрест",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Коло",
"horizon.pvp.shape.square": "Квадрат",
"horizon.pvp.shape.dot": "Точка",
"horizon.pvp.outline": "Власне обведення блока",
"horizon.pvp.outline.tooltip": "Змінює обведення навколо блока, на який ви дивитеся.",
"horizon.pvp.outline_colour": "Колір",
"horizon.pvp.outline_colour.tooltip": "Колір обведення. Чорний на 40% — ванільний.",
"horizon.pvp.outline_opacity": "Непрозорість",
"horizon.pvp.outline_opacity.tooltip": "Наскільки щільне обведення.",
"horizon.pvp.outline_thickness": "Товщина",
"horizon.pvp.outline_thickness.tooltip": "Ширина обведення, у разах від ванільної.",
"horizon.pvp.outline_fill": "Заливка",
"horizon.pvp.outline_fill.tooltip": "Злегка підфарбовує грані блока, на який ви дивитеся.",
"horizon.pvp.hit": "Власний колір удару",
"horizon.pvp.hit.tooltip": "Змінює колір, яким спалахують моби й гравці, коли їм завдають шкоди. Червоний на 30% — ванільний.",
"horizon.pvp.hit_colour": "Колір",
"horizon.pvp.hit_colour.tooltip": "Колір, яким спалахують поранені моби й гравці.",
"horizon.pvp.hit_opacity": "Непрозорість",
"horizon.pvp.hit_opacity.tooltip": "Наскільки колір їх покриває.",
"horizon.pvp.header.outline": "Обведення блока",
"horizon.pvp.header.hit": "Колір удару",
"horizon.pvp.hud_armour": "Броня",
"horizon.pvp.hud_armour.tooltip": "Броня, яку ви носите, починаючи з голови, і скільки використань лишилося в кожного предмета.",
"horizon.pvp.hud_armour_corner": "Кут броні",
"horizon.pvp.hud_armour_corner.tooltip": "Де показується броня.",
"horizon.pvp.hud_armour_percent": "Міцність у %",
"horizon.pvp.hud_armour_percent.tooltip": "Залишок використань у відсотках від максимуму предмета замість числа.",
"horizon.pvp.hud_effects": "Ефекти",
"horizon.pvp.hud_effects.tooltip": "Ваші ефекти з назвами, рівнями й часом, що лишився, замість значків угорі праворуч.",
"horizon.pvp.hud_effects_corner": "Кут ефектів",
"horizon.pvp.hud_effects_corner.tooltip": "Де показуються ефекти.",
"horizon.pvp.hud_held": "Кількість предмета в руці",
"horizon.pvp.hud_held.tooltip": "Скільки предметів, як у вашій руці, є в інвентарі; з луком чи арбалетом — стріл.",
"horizon.pvp.hud_held_corner": "Кут кількості",
"horizon.pvp.hud_held_corner.tooltip": "Де показується кількість.",
"horizon.pvp.hud_background": "Фон віджетів",
"horizon.pvp.hud_background.tooltip": "Темна підкладка під кожним віджетом.",
"horizon.pvp.hud_opacity": "Непрозорість фону",
"horizon.pvp.hud_opacity.tooltip": "Наскільки темні підкладки під віджетами.",
"horizon.pvp.hud_text_colour": "Колір тексту",
"horizon.pvp.hud_text_colour.tooltip": "Колір тексту віджетів. Міцність лишається від зеленого до червоного.",
"horizon.pvp.hud_shadow": "Тінь тексту",
"horizon.pvp.hud_shadow.tooltip": "Тінь під текстом віджетів, як у тексту ванільного HUD.",
"horizon.pvp.header.hud": "Віджети HUD",
"horizon.pvp.corner.top_left": "Угорі ліворуч",
"horizon.pvp.corner.top_right": "Угорі праворуч",
"horizon.pvp.corner.bottom_left": "Унизу ліворуч",
"horizon.pvp.corner.bottom_right": "Унизу праворуч",
"horizon.pvp.hud_ping": "Пінг",
"horizon.pvp.hud_ping.tooltip": "Затримка, яку сервер повідомляє для вас, у мілісекундах. Лише в мережевій грі.",
"horizon.pvp.hud_ping_corner": "Кут пінгу",
"horizon.pvp.hud_ping_corner.tooltip": "Де показується пінг.",
"horizon.pvp.hud_coords": "Координати",
"horizon.pvp.hud_coords.tooltip": "Ваша позиція в блоках і напрямок погляду, як їх показує F3. Приховано, коли світ чи сервер скорочує F3.",
"horizon.pvp.hud_coords_corner": "Кут координат",
"horizon.pvp.hud_coords_corner.tooltip": "Де показуються координати.",
"horizon.pvp.hud_server": "Адреса сервера",
"horizon.pvp.hud_server.tooltip": "Адреса сервера, на якому ви граєте. Лише в мережевій грі.",
"horizon.pvp.hud_server_corner": "Кут сервера",
"horizon.pvp.hud_server_corner.tooltip": "Де показується адреса.",
"horizon.pvp.hud_combo": "Лічильник комбо",
"horizon.pvp.hud_combo.tooltip": "Удари поспіль, підтверджені сервером, доки вас не поранять або не мине 3 секунди без удару.",
"horizon.pvp.hud_combo_corner": "Кут комбо",
"horizon.pvp.hud_combo_corner.tooltip": "Де показується комбо.",
"horizon.pvp.hud.ping": "Пінг: %s мс",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Напрямок: %s",
"horizon.pvp.hud.combo": "Комбо: %s",
"horizon.pvp.hud.direction.north": "Північ",
"horizon.pvp.hud.direction.south": "Південь",
"horizon.pvp.hud.direction.east": "Схід",
"horizon.pvp.hud.direction.west": "Захід",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Кадрів за останню секунду.",
"horizon.pvp.hud_fps_corner": "Кут FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Де показується FPS.",
"horizon.pvp.hud_cps": "Кліки за секунду",
"horizon.pvp.hud_cps.tooltip": "Ваші ліві й праві кліки за останню секунду.",
"horizon.pvp.hud_cps_corner": "Кут CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Де показуються кліки за секунду.",
"horizon.pvp.hud_keys": "Натискання клавіш",
"horizon.pvp.hud_keys.tooltip": "Клавіші руху, кнопки миші та стрибок, які підсвічуються, поки ви їх тримаєте, і кліки за секунду.",
"horizon.pvp.hud_keys_corner": "Кут натискань",
"horizon.pvp.hud_keys_corner.tooltip": "Де показуються натискання клавіш.",
"horizon.pvp.hud_clock": "Годинник",
"horizon.pvp.hud_clock.tooltip": "Час на вашому комп’ютері.",
"horizon.pvp.hud_clock_corner": "Кут годинника",
"horizon.pvp.hud_clock_corner.tooltip": "Де показується годинник.",
"horizon.pvp.hud_reach": "Дальність",
"horizon.pvp.hud_reach.tooltip": "На якій відстані від ваших очей влучив останній удар, протягом 3 секунд. Це лише вимірювання: ваша дальність лишається ванільною.",
"horizon.pvp.hud_reach_corner": "Кут дальності",
"horizon.pvp.hud_reach_corner.tooltip": "Де показується дальність.",
"horizon.pvp.hud_speed": "Швидкість",
"horizon.pvp.hud_speed.tooltip": "Як швидко ви рухаєтеся по землі, у блоках за секунду.",
"horizon.pvp.hud_speed_corner": "Кут швидкості",
"horizon.pvp.hud_speed_corner.tooltip": "Де показується швидкість.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Дальність: %s",
"horizon.pvp.hud.speed": "Швидкість: %s б/с",
"horizon.pvp.hud.keys.attack": "ЛКМ",
"horizon.pvp.hud.keys.use": "ПКМ",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Рівні чар числами",
"horizon.pvp.numeric_enchants.tooltip": "Підказки та чарувальний стіл показують «Гострота 5» замість «Гострота V».",
"horizon.pvp.header.items": "Предмети",
"horizon.pvp.fire_lower": "Нижчий вогонь",
"horizon.pvp.fire_lower.tooltip": "Опускає полум’я, яке ви бачите, коли горите, щоб воно менше закривало екран.",
"horizon.pvp.shield_size": "Розмір щита",
"horizon.pvp.shield_size.tooltip": "Розмір щита у вашій руці від першої особи.",
"horizon.pvp.totem_size": "Розмір анімації тотема",
"horizon.pvp.totem_size.tooltip": "Розмір тотема, що заповнює екран, коли рятує вас.",
"horizon.pvp.glint_colour": "Колір блиску чар",
"horizon.pvp.glint_colour.tooltip": "Колір мерехтіння на зачарованих предметах і броні. Вимкнено — ванільний фіолетовий.",
"horizon.pvp.clear_water": "Прозора вода",
"horizon.pvp.clear_water.tooltip": "Під водою одразу бачите так далеко, як ванільна гра дозволяє лише після 30 секунд під водою.",
"horizon.pvp.clear_lava": "Прозора лава",
"horizon.pvp.clear_lava.tooltip": "У лаві бачите так далеко, як дозволяє ванільна Вогнестійкість, без цього ефекту.",
"horizon.pvp.header.visual": "Візуальні зміни",
"horizon.pvp.view_time": "Час доби",
"horizon.pvp.view_time.tooltip": "Час доби, який ви бачите: лише небо та його світло. Час у світі йде далі, і на сервер нічого не надсилається.",
"horizon.pvp.view_weather": "Погода",
"horizon.pvp.view_weather.tooltip": "Погода, яку ви бачите: лише дощ, його світло й туман. Погода у світі не змінюється, і на сервер нічого не надсилається.",
"horizon.pvp.header.view": "Що ви бачите",
"horizon.pvp.view.world": "Як у світі",
"horizon.pvp.time.morning": "Ранок",
"horizon.pvp.time.noon": "Полудень",
"horizon.pvp.time.sunset": "Захід сонця",
"horizon.pvp.time.midnight": "Опівніч",
"horizon.pvp.weather.clear": "Ясно",
"horizon.pvp.weather.rain": "Дощ",
"horizon.pvp.weather.thunder": "Гроза",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Чат",
"horizon.chat.copied": "Повідомлення скопійовано",
"horizon.chat.search": "Пошук у чаті:",
"horizon.pvp.chat_timestamps": "Позначки часу",
"horizon.pvp.chat_timestamps.tooltip": "Перед кожним повідомленням — час, коли воно надійшло.",
"horizon.pvp.chat_compact": "Компактний чат",
"horizon.pvp.chat_compact.tooltip": "Повідомлення, повторене кілька разів поспіль, показується один раз із кількістю повторів: (x3).",
"horizon.pvp.chat_copy": "Копіювання клацанням",
"horizon.pvp.chat_copy.tooltip": "Клацання по повідомленню у відкритому чаті копіює його текст, якщо клацання не робить чогось іншого.",
"horizon.pvp.chat_search": "Пошук у чаті",
"horizon.pvp.chat_search.tooltip": "Ctrl+F у відкритому чаті: показуються лише повідомлення, що містять введений текст. Enter або повторне Ctrl+F завершує пошук.",
"horizon.pvp.preset": "PvP-пресет",
"horizon.pvp.preset.tooltip": "Вмикає власний приціл, віджети броні й ефектів, перемикання бігу та скрадання, нижчий вогонь, а також моди лічильника FPS і натискань клавіш, якщо їх встановлено. Ваші налаштування кожного з них зберігаються.",
"horizon.pvp.hud_editor_key": "Клавіша редактора HUD",
"horizon.pvp.hud_editor_key.tooltip": "Відкриває редактор HUD із гри. Клацніть, потім натисніть клавішу; Escape скасовує призначення.",
"horizon.pvp.editor": "Редагувати HUD",
"horizon.pvp.editor.tooltip": "Розставте віджети просто в грі: перетягуйте куди завгодно, змінюйте розмір коліщатком, клацніть правою кнопкою, щоб повернути віджет у його кут.",
"horizon.pvp.editor.hint": "Перетягуйте віджети · Коліщатко: розмір · Права кнопка: назад у кут",
"horizon.pvp.combat_feel": "Відчуття бою",
"horizon.pvp.combat_feel.tooltip": "«Класичне» показує бої як у 1.8.9: предмет у руці, його змах, їжу, лук і піднятий меч, поки ви тримаєте «використати», без просідання після кожного удару та без індикатора атаки. Лише вигляд: те, що ви надсилаєте серверу, не змінюється.",
"horizon.pvp.combat_indicator": "Індикатор атаки в «Класичному»",
"horizon.pvp.combat_indicator.tooltip": "Залишає в «Класичному» індикатор атаки, якого в 1.8.9 не було.",
"horizon.pvp.combat_feel.modern": "Сучасне",
"horizon.pvp.combat_feel.legacy": "Класичне (1.8.9)",
"horizon.pvp.header.combat": "Відчуття бою",
"horizon.pvp.combat_sounds": "Звуки ударів 1.8",
"horizon.pvp.combat_sounds.tooltip": "У «Класичному» прибирає звуки атаки, додані в 1.9 (сильний, слабкий, розмашистий, критичний удар...). Звуки поранення лишаються.",
"horizon.pvp.combat_rules": "Бій 1.8 у ваших світах",
"horizon.pvp.combat_rules.tooltip": "В одиночній грі та у світах, відкритих для LAN, бої йдуть за правилами 1.8.9: без перезарядки атаки й розмашистого удару, зі шкодою зброї, Силою, Гостротою і критичними ударами як у 1.8. Сервери, до яких ви приєднуєтесь, лишають свої правила.",
"Bindings": "Призначення",
"Play": "Гра",
"Device": "Пристрій",
"Look": "Огляд",
"Movement": "Рух",
"Menus": "Меню",
"Actions": "Дії",
"Reset": "Скинути",
"Detected": "Виявлено",
"Glyphs": "Значки кнопок",
"Feedback": "Віддача",
"Controller": "Геймпад",
"How the sticks turn and move you, and how menus repeat and scroll.": "Як стіки повертають і рухають вас, а меню повторюють і прокручують.",
"Which button does what. Each player has their own.": "Яка кнопка що робить. У кожного гравця — свої.",
"The controller the game sees, its button glyphs and its feedback.": "Геймпад, який бачить гра, значки його кнопок і віддача.",
"Press this row, then the button that should do it.": "Натисніть цей рядок, потім кнопку, яка має це робити.",
"Off, the game ignores every controller for this player.": "Вимкнено: гра ігнорує всі геймпади для цього гравця.",
"How fast the right stick turns the view.": "Як швидко правий стік повертає огляд.",
"Turn the view the other way, left and right.": "Повертати огляд у протилежний бік ліворуч і праворуч.",
"Turn the view the other way, up and down.": "Повертати огляд у протилежний бік угору і вниз.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Наскільки має відхилитися правий стік, перш ніж огляд почне повертатися. Збільште, якщо огляд сам зсувається.",
"Hold the button to sneak, or press it once to start and again to stop.": "Тримайте кнопку, щоб скрадатися, або натисніть раз, щоб почати, і ще раз, щоб припинити.",
"How far the left stick moves before you walk. Raise it if you drift.": "Наскільки має відхилитися лівий стік, перш ніж ви підете. Збільште, якщо вас самого зносить.",
"How far a trigger is pulled before it counts as pressed.": "Наскільки треба натиснути тригер, щоб це вважалося натисканням.",
"How fast the stick moves the crosshair in inventories.": "Як швидко стік рухає курсор в інвентарях.",
"How fast the right stick scrolls a list.": "Як швидко правий стік прокручує список.",
"How long a held direction waits before it repeats.": "Скільки утримуваний напрямок чекає перед першим повтором.",
"How long a held direction takes between repeats.": "Скільки часу минає між повторами утримуваного напрямку.",
"Which on-screen keyboard opens when a text field is pressed.": "Яка екранна клавіатура відкривається, коли натискаєте текстове поле.",
"Every action back on its default button.": "Повернути всі дії на типові кнопки.",
"These settings belong to this player's profile.": "Ці налаштування належать профілю цього гравця.",
"The controller driving this player, or else the first one connected.": "Геймпад, яким керує цей гравець, або інакше перший підключений.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Виробник і продукт USB, які повідомляє система. Знадобиться, коли повідомляєте про геймпад, що працює неправильно.",
"The glyph family the game picked from the controller's maker.": "Набір значків, який гра обрала за виробником геймпада.",
"Follow the controller, or always show one family's buttons.": "Як у підключеного геймпада або завжди кнопки одного набору.",
"The buttons menus show right now.": "Кнопки, які меню показують зараз.",
"Rumble when something happens to you.": "Вібрація, коли з вами щось стається.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Геймпад працює, навіть коли це вікно не у фокусі, а втрата фокусу не ставить гру на паузу.",
"A DualSense's triggers push back on bows and tools.": "Тригери DualSense чинять опір, коли ви стріляєте з лука чи працюєте інструментами.",
"What a DualSense's light shows, over USB or Bluetooth.": "Що показує підсвічування DualSense через USB або Bluetooth.",
"Player profile": "Профіль гравця",
"Vendor and product": "Виробник і продукт",
"Detected glyphs": "Виявлені значки",
"Glyphs in use": "Поточні значки",
"Unknown": "Невідомо",
"No controller": "Немає геймпада",
"Editing: %s": "Редагування: %s",
"Press the button for this. %s cancels.": "Натисніть кнопку для цього. %s — скасувати.",
"This button also does: %s": "Ця кнопка також робить: %s",
"horizon.pad.notice.connected.press": "Натисніть, щоб використовувати",
"horizon.recipe_transfer.missing": "Бракує предметів",
"horizon.recipe_transfer.no_room": "Інвентар заповнено",
"horizon.recipe_transfer.unavailable": "Рецепт не підходить для цього меню",
"horizon.recipe_transfer.unsupported": "Сервер вимагає відомого рецепта"
}
+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("../..")
@@ -1,7 +1,7 @@
//! Headless transport for the block-entity render parity gate: replays each
//! case (block state, light, partial tick and ticked block-entity fields)
//! through `BlockEntityRenderer` at the block origin and writes the quads
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
//! (texture, sprite-local UVs, tint, normal, light) and sign text calls.
#![forbid(unsafe_code)]
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_client::{
@@ -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();
@@ -417,7 +426,8 @@ pub fn main() -> Result<()> {
])
})
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light,
"normal": q[0].normal})
})
.collect();
let texts: Vec<Value> = texts
@@ -425,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();
@@ -0,0 +1,79 @@
//! Rust side of tools/test_enchant_durability_parity.py: `Enchantments` value
//! effects for the `minecraft:item_damage` (Unbreaking) and
//! `minecraft:repair_with_xp` (Mending) components on one stack per case.
use horizon_client::{
enchant::{EffectContext, Enchantments},
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_core::random::LegacyRandom;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, io::Write, path::Path};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(Path::new)
.ok_or("no #resources line")?;
let enchantments = Enchantments::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let out = fs::File::create(args.get(2).ok_or("missing OUTPUT")?)?;
let mut out = std::io::BufWriter::new(out);
for line in input.lines() {
if line.starts_with("#resources ") || line.trim().is_empty() {
continue;
}
let case: Value = serde_json::from_str(line)?;
let item = case["item"].as_str().unwrap();
let level = case["level"].as_u64().unwrap() as u32;
let component = if case["mode"] == "unbreaking" {
"minecraft:item_damage"
} else {
"minecraft:repair_with_xp"
};
let stack = ItemStack {
item: item.into(),
count: 1,
damage: case["damage"].as_u64().unwrap() as u32,
extra: Some(Box::new(StackData {
enchantments: vec![(
component_enchantment(case["mode"].as_str().unwrap()).into(),
level,
)],
..Default::default()
})),
};
let ctx = EffectContext {
tool: Some(&stack.item),
items: Some(&items),
..Default::default()
};
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap());
let value = enchantments.modify_value(
Some(&stack),
component,
&ctx,
&mut random,
case["amount"].as_i64().unwrap() as f32,
);
let value = if case["mode"] == "mending" {
value.max(0.0)
} else {
value
};
writeln!(out, "{}", value as i64)?;
}
Ok(())
}
fn component_enchantment(mode: &str) -> &'static str {
if mode == "unbreaking" {
"minecraft:unbreaking"
} else {
"minecraft:mending"
}
}
@@ -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,
@@ -0,0 +1,76 @@
//! Environment attribute entries for tools/test_environment_attribute_modifier_parity.py: each
//! case parses an entry for an attribute and applies it to a subject value.
use horizon_world::environment_attributes::{ATTRIBUTES, Attribute, Entry, Kind, Value, attribute};
use std::{error::Error, fmt::Write};
fn bits(f: f32) -> String {
format!("{:08x}", f.to_bits())
}
fn format(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Float(f) => bits(*f),
Value::Rgb(v) => v.map(bits).join(" "),
Value::Argb(v) => v.map(bits).join(" "),
Value::Other(json) => json.to_string(),
}
}
fn run(attribute: &Attribute, subject: &str, entry: &str) -> Result<String, Box<dyn Error>> {
let subject = match Entry::parse(attribute, &serde_json::from_str(subject)?)? {
Entry::Override(value) => value,
_ => return Err("the subject is not a plain value".into()),
};
let Ok(entry) = Entry::parse(attribute, &serde_json::from_str(entry)?) else {
return Ok("error".into());
};
let value = entry.apply(&subject);
Ok(format!(
"{}\t{}",
format(&value),
format(&attribute.sanitize(value.clone()))
))
}
/// Every attribute in registration order: id, type, default, and how a float range clamps.
fn registry(output: &mut String) -> std::fmt::Result {
for a in &ATTRIBUTES {
let kind = match a.kind {
Kind::Boolean => "minecraft:boolean",
Kind::Float => "minecraft:float",
Kind::AngleDegrees => "minecraft:angle_degrees",
Kind::RgbColor => "minecraft:rgb_color",
Kind::ArgbColor => "minecraft:argb_color",
Kind::Other => "other",
};
let default = a.default_value();
let range = match default {
Some(Value::Float(_)) => format!(
"{} {}",
format(&a.sanitize(Value::Float(f32::NEG_INFINITY))),
format(&a.sanitize(Value::Float(f32::INFINITY)))
),
_ => "-".into(),
};
let default = default.map_or("unported".into(), |v| format(&v));
writeln!(output, "minecraft:{}\t{kind}\t{default}\t{range}", a.id)?;
}
Ok(())
}
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
if line == "registry" {
registry(&mut output)?;
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let attribute = attribute(fields[0]).ok_or("unknown attribute")?;
writeln!(output, "{}", run(attribute, fields[1], fields[2])?)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,159 @@
//! Rust side of tools/test_falling_block_parity.py: replays the landing branch
//! of `FallingBlockEntity.tick` over the case's block map through
//! `horizon_world::level::Behaviours::falling_block_landed` and prints the same
//! decision, changed blocks and numeric level events as the Java oracle.
use horizon_core::{position::BlockPos, random::LegacyRandom};
use horizon_world::{
fluid::FluidKind,
level::{Behaviours, Dimension, Landing, LevelAccess, LevelEvent, Ticks, data::load_registry},
registry::BlockStateId,
};
use serde_json::Value;
use std::{
collections::HashMap,
io::{BufRead, Write},
path::Path,
};
struct Cells {
cells: HashMap<BlockPos, BlockStateId>,
air: BlockStateId,
random: LegacyRandom,
events: Vec<String>,
}
impl LevelAccess for Cells {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.cells.get(&pos).copied().unwrap_or(self.air)
}
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
let old = self.block_state(pos);
if state == self.air {
self.cells.remove(&pos);
} else {
self.cells.insert(pos, state);
}
Some(old)
}
fn is_loaded(&self, _pos: BlockPos) -> bool {
true
}
fn min_y(&self) -> i32 {
-64
}
fn max_y(&self) -> i32 {
320
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
0
}
fn dimension(&self) -> Dimension {
Dimension::Overworld
}
fn emit(&mut self, event: LevelEvent) {
if let LevelEvent::LevelEvent { id, pos, data } = event
&& id == 1031
{
self.events
.push(format!("L {} {},{},{} {}", id, pos.x, pos.y, pos.z, data));
}
}
}
fn pos(v: &Value) -> BlockPos {
BlockPos::new(
v[0].as_i64().unwrap() as i32,
v[1].as_i64().unwrap() as i32,
v[2].as_i64().unwrap() as i32,
)
}
fn main_landing(case: &Value, b: &Behaviours, air: BlockStateId, out: &mut Vec<String>) {
let mut level = Cells {
cells: HashMap::new(),
air,
random: LegacyRandom::new(case["seed"].as_i64().unwrap()),
events: Vec::new(),
};
for c in case["cells"].as_array().unwrap() {
let state = BlockStateId(c[3].as_i64().unwrap() as u32);
if state != air {
level.cells.insert(pos(c), state);
}
}
let at = pos(&case["pos"]);
let before = level.cells.clone();
let original = BlockStateId(case["state"].as_i64().unwrap() as u32);
// `FallingBlockEntity.fall` drops the WATERLOGGED property before the entity
// carries the state and puts the source's legacy fluid block at the cell;
// `falling_block_fall` does the same for the caller. The case's destination
// state is then restored for the landing probe.
let state = b
.table
.with_bool(original, b.props.waterlogged, false)
.unwrap_or(original);
let destination = level.cells.get(&at).copied();
let legacy = b.legacy_block(b.fluid(original));
if legacy == air {
level.cells.remove(&at);
} else {
level.cells.insert(at, legacy);
}
if let Some(destination) = destination {
level.cells.insert(at, destination);
}
let stuck = matches!(
b.fluid(level.block_state(at)).kind(),
FluidKind::Water | FluidKind::FlowingWater
);
let mut ticks = Ticks::new(0);
let landing = b.falling_block_landed(&mut level, &mut ticks, at, state, stuck);
match landing {
Landing::Placed(placed) => {
out.push(format!("decision placed {}", placed.0));
}
Landing::Broken => out.push("decision broken".into()),
Landing::Continue => out.push("decision continue".into()),
}
let mut changed: Vec<BlockPos> = before
.keys()
.copied()
.chain(level.cells.keys().copied())
.collect();
changed.sort_by_key(|p| (p.x, p.y, p.z));
changed.dedup();
for p in changed {
let now = level.block_state(p);
if before.get(&p).copied().unwrap_or(air) != now {
out.push(format!("set {},{},{} {}", p.x, p.y, p.z, now.0));
}
}
out.extend(level.events);
}
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = crate::args().skip(1);
let resources = args.next().expect("resource directory");
let cases = args.next().expect("case file");
let output = args.next().expect("output file");
let resources = Path::new(&resources);
let registry = load_registry(resources)?;
let behaviours = Behaviours::load(&registry, resources)?;
let air = registry.default_state("minecraft:air").unwrap();
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for (index, line) in std::io::BufReader::new(std::fs::File::open(cases)?)
.lines()
.enumerate()
{
let case: Value = serde_json::from_str(&line?)?;
let mut lines = Vec::new();
main_landing(&case, &behaviours, air, &mut lines);
writeln!(out, "== {index}")?;
for line in lines {
writeln!(out, "{line}")?;
}
}
Ok(())
}
@@ -0,0 +1,106 @@
//! Furnace parity (tools/test_furnace_parity.py): reads "furnace KIND TICKS
//! INPUT FUEL OUTPUT TIMER TOTAL LIT LITTOTAL SPEED CHANGE_AT NEW_INPUT"
//! lines, runs the furnace model and prints the slots, timers, LIT state,
//! RecipesUsed counts and each used recipe's experience.
//!
//! The first input line is `#resources RESOURCES`; usage: furnace_fixture INPUT OUTPUT
use horizon_client::{
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
};
use horizon_client_render::{
chunk::block_entity::{Furnace, FurnaceKind},
providers::Providers,
};
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(std::path::PathBuf::from)
.ok_or("no #resources line")?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
let providers = Providers::load(&root);
let mut out = String::new();
for (n, line) in input
.lines()
.filter(|line| !line.starts_with('#'))
.enumerate()
{
let f: Vec<&str> = line.split(' ').collect();
let kind = match f[1] {
"furnace" => FurnaceKind::Furnace,
"blast_furnace" => FurnaceKind::BlastFurnace,
"smoker" => FurnaceKind::Smoker,
_ => return Err("unknown furnace kind".into()),
};
let ticks: u32 = f[2].parse()?;
let mut items3 = [None, None, None];
for i in 0..3 {
items3[i] = parse_stack(f[3 + i])?;
}
let mut furnace = Furnace {
kind,
items: items3,
lit_remaining: f[8].parse()?,
lit_total: f[9].parse()?,
cooking_timer: f[6].parse()?,
cooking_total: f[7].parse()?,
speed: f[10].parse()?,
recipes_used: HashMap::new(),
};
let change_at: i32 = f[11].parse()?;
let new_input = parse_stack(f[12])?;
let mut lit_state = false;
let mut drops = Vec::new();
for t in 0..ticks {
if t as i32 == change_at {
furnace.set_input(&recipes, new_input.clone());
}
if let Some(lit) = furnace.tick(&recipes, &items, &providers, &mut drops) {
lit_state = lit;
}
}
if change_at == ticks as i32 {
furnace.set_input(&recipes, new_input.clone());
}
write!(out, "{n} kind={} ", f[1])?;
for i in 0..3 {
write!(out, "s{i}={} ", canon_stack(furnace.items[i].as_ref()))?;
}
write!(
out,
"timer={} total={} lit={} littotal={} speed={:.3} state={lit_state} used=",
furnace.cooking_timer,
furnace.cooking_total,
furnace.lit_remaining,
furnace.lit_total,
furnace.speed
)?;
let mut ids: Vec<&String> = furnace.recipes_used.keys().collect();
ids.sort();
for (i, id) in ids.iter().enumerate() {
if i > 0 {
out.push(',');
}
write!(out, "{id}={}", furnace.recipes_used[*id])?;
}
out.push_str(" xp=");
for (i, id) in ids.iter().enumerate() {
if i > 0 {
out.push(',');
}
write!(out, "{id}={}", recipes.cooking_experience(id))?;
}
writeln!(out)?;
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -39,18 +39,22 @@ mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchant_durability_fixture;
mod enchanting_fixture;
mod entity_anim_fixture;
mod entity_components_probe;
mod entity_death_loot_fixture;
mod entity_particles_probe;
mod entity_sound_packet_fixture;
mod environment_attribute_modifier_fixture;
mod environment_fixture;
mod falling_block_fixture;
mod finalize_spawn_fixture;
mod fire_starter_sound_probe;
mod firework_tooltip_fixture;
mod first_person_probe;
mod frame_limiter_probe;
mod furnace_fixture;
mod game_loop_probe;
mod ghost_recipe_fixture;
mod held_drop_fixture;
@@ -101,6 +105,7 @@ mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod stack_foil_fixture;
mod stack_mining_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
@@ -109,6 +114,7 @@ mod text_display_fixture;
mod text_display_render_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trade_components_probe;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
@@ -128,12 +134,14 @@ probes! {
block_entity_tickers_fixture,
block_particles_fixture,
block_transform_fixture,
furnace_fixture,
brewing_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
squid_client_probe,
creative_mining_probe,
stack_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
@@ -146,11 +154,14 @@ probes! {
conversion_tags_fixture,
creative_search_fixture,
emitter_particles_probe,
enchant_durability_fixture,
enchanting_fixture,
falling_block_fixture,
entity_anim_fixture,
entity_components_probe,
entity_death_loot_fixture,
entity_particles_probe,
environment_attribute_modifier_fixture,
environment_fixture,
finalize_spawn_fixture,
fire_starter_sound_probe,
@@ -217,6 +228,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trade_components_probe,
trident_probe,
translucency_fixture,
stack_foil_fixture,
@@ -204,6 +204,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"Float" => "FloatGoal",
"RandomFloatAround" => "RandomFloatAroundGoal",
"GhastLook" => "GhastLookGoal",
"Peek" => "ShulkerPeekGoal",
"GhastShoot" => "GhastShootFireballGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"ParrotWander" => "ParrotWanderGoal",
@@ -642,7 +643,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
..
} => true,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
MobEvent::Explode(at, radius, _, _) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -1068,6 +1069,13 @@ pub fn main() -> horizon_client::Result<()> {
// Set once the brain exists (below).
x if x.starts_with("cooldown=") => {}
"dead" => m.health = 0.0,
// effect=<name>:<amplifier>, never running out.
x if x.starts_with("effect=") => {
let (name, amp) = x[7..].split_once(':').unwrap();
let name: &'static str =
Box::leak(format!("minecraft:{name}").into_boxed_str());
m.add_effect(name, -1, amp.parse().unwrap());
}
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
@@ -1208,7 +1216,7 @@ pub fn main() -> horizon_client::Result<()> {
day_time: world.day_time,
raining: s.rain,
thundering: s.thunder,
nether: false,
level: horizon_core::LevelKey::OVERWORLD,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
@@ -1217,7 +1225,7 @@ pub fn main() -> horizon_client::Result<()> {
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
dimension_type: horizon_world::dimension_type::DimensionFacts::overworld(),
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
@@ -30,7 +30,7 @@ use horizon_client::{
mobs::{
MobTypes, Species,
data::MobData,
spawn_rules::{Dimension, SpawnLevel},
spawn_rules::SpawnLevel,
spawn_selection::{
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
@@ -49,12 +49,13 @@ use horizon_client::{
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
};
use horizon_core::{
LevelKey,
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
registry::BlockStateId,
block_collision::BlockCollisionRegistry, chunk::BlockState, dimension_type::DimensionFacts,
loot::PackOverlay, nbt::Tag, registry::BlockStateId,
};
use serde_json::Value;
use std::{
@@ -98,6 +99,7 @@ struct Shared<'a> {
entity_types: &'a HashSet<String>,
}
struct World<'a> {
border: horizon_core::geometry::border::BorderGeometry,
s: &'a Shared<'a>,
blocks: Vec<(Bx, BlockStateId)>,
lights: Vec<(Bx, i32, i32)>,
@@ -110,7 +112,7 @@ struct World<'a> {
sea: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension: &'static DimensionFacts,
dimension_id: String,
slime: f32,
starts: Vec<Start>,
@@ -158,6 +160,10 @@ impl World<'_> {
}
}
impl SpawnLevel for World<'_> {
fn within_world_border(&self, x: i32, z: i32) -> bool {
self.border
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
}
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if let Some((_, s)) = self
.placed
@@ -214,7 +220,7 @@ impl SpawnLevel for World<'_> {
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -232,28 +238,24 @@ impl SpawnLevel for World<'_> {
}
impl NaturalLevel for World<'_> {
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
319
} else {
255
}
}
fn ambient_light(&self) -> f32 {
match self.dimension {
Dimension::Overworld => 0.0,
Dimension::Nether => 0.1,
Dimension::End => 0.25,
}
self.dimension.ambient_light
}
fn has_ceiling(&self) -> bool {
self.dimension == Dimension::Nether
self.dimension.id == "minecraft:the_nether"
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.s.types.types.get(entity)?;
@@ -316,6 +318,8 @@ impl NaturalLevel for World<'_> {
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
!self.unloaded.contains(&(cx, cz))
&& self.within_world_border(cx << 4, cz << 4)
&& self.within_world_border((cx << 4).wrapping_add(15), (cz << 4).wrapping_add(15))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
@@ -664,11 +668,8 @@ pub fn main() -> horizon_client::Result<()> {
})
.collect::<Vec<_>>();
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let dimension =
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
let respawn = {
let r = get("respawn", "0,-1000,0/minecraft:overworld");
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
@@ -709,6 +710,11 @@ pub fn main() -> horizon_client::Result<()> {
));
}
let world = World {
border: horizon_core::geometry::border::BorderGeometry::new(
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
get("bordersize", "59999968").parse()?,
29_999_984,
)?,
s: &shared,
blocks,
lights,
@@ -885,15 +891,17 @@ pub fn main() -> horizon_client::Result<()> {
let categories = state
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
trace(Event::Categories(categories.clone()));
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
if mode != "tick_chunk" || world.can_spawn_in_chunk(chunk.0, chunk.1) {
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
}
}
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
}
@@ -11,14 +11,16 @@ use horizon_client::{
mobs::{
Species,
data::MobData,
spawn_rules::{self, Dimension, SpawnLevel},
spawn_rules::{self, SpawnLevel},
spawn_selection::{self, Heightmap},
spawning::LocalDifficulty,
},
patrol::{self, GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
};
use horizon_core::LevelKey;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::dimension_type::DimensionFacts;
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use std::{
@@ -74,7 +76,7 @@ struct World<'a> {
light: i32,
darken: i32,
difficulty: i32,
dimension: Dimension,
dimension: &'static DimensionFacts,
dimension_id: String,
unloaded: HashSet<(i32, i32)>,
no_ticking: HashSet<(i32, i32)>,
@@ -96,14 +98,14 @@ impl World<'_> {
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
}
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
319
} else {
255
@@ -175,7 +177,7 @@ impl SpawnLevel for World<'_> {
fn peaceful(&self) -> bool {
self.difficulty == 0
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -193,7 +195,7 @@ impl PatrolLevel for World<'_> {
self.patrols
}
fn bright_outside(&self) -> bool {
self.dimension == Dimension::Overworld && self.darken < 4
self.dimension.id == "minecraft:overworld" && self.darken < 4
}
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
self.player
@@ -548,11 +550,8 @@ pub fn main() -> horizon_client::Result<()> {
.unwrap_or_default()
};
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let dimension =
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
let player = c.get("p0").map(|p| {
let mut parts = p.split('/');
let v: Vec<f64> = parts
@@ -75,6 +75,18 @@ pub fn main() -> horizon_client::Result<()> {
.collect::<Vec<_>>()
.join(";"),
},
"smith_transform" => {
let files = vec![(
"recipe/test:transform".to_owned(),
vec![f[1].as_bytes().to_vec()],
)];
let stations = StationRecipes::load(root, &items, &PackOverlay { files: &files });
let (t, b, a) = (stack(2)?, stack(3)?, stack(4)?);
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
None => "none".to_owned(),
Some(r) => canon_stack(Some(&r)),
}
}
"smith" => {
let (t, b, a) = (stack(1)?, stack(2)?, stack(3)?);
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
@@ -10,14 +10,16 @@ use horizon_client::{
mobs::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
spawn_rules::{self, Reason, SpawnLevel},
spawn_selection::{
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
StructureSpawns, spawn_aabb,
},
},
};
use horizon_core::LevelKey;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::dimension_type::DimensionFacts;
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
@@ -28,6 +30,7 @@ use std::{
};
struct Column<'a> {
border: horizon_core::geometry::border::BorderGeometry,
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
@@ -43,7 +46,7 @@ struct Column<'a> {
surface: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension: &'static DimensionFacts,
biome: String,
slime: f32,
/// A player position and whether it is survival or adventure.
@@ -53,6 +56,10 @@ struct Column<'a> {
structure_spawns: StructureSpawns,
}
impl SpawnLevel for Column<'_> {
fn within_world_border(&self, x: i32, z: i32) -> bool {
self.border
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
}
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if x != self.x0 || z != self.z0 {
return self.air;
@@ -95,7 +102,7 @@ impl SpawnLevel for Column<'_> {
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -264,6 +271,11 @@ pub fn main() -> horizon_client::Result<()> {
)
});
let level = Column {
border: horizon_core::geometry::border::BorderGeometry::new(
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
get("bordersize", "59999968").parse()?,
29_999_984,
)?,
facts: &facts,
data: &data,
block_tags: &items.block_tags,
@@ -279,11 +291,10 @@ pub fn main() -> horizon_client::Result<()> {
surface: get("surface", "0").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension: match get("dim", "minecraft:overworld").as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
},
dimension: DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&get(
"dim",
"minecraft:overworld",
))),
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
@@ -0,0 +1,56 @@
use horizon_client::{
Result, item_data,
items::{ItemStack, Items},
};
use horizon_items::item_nbt;
use horizon_world::loot::PackOverlay;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
fn query(items: &Items, stack: &ItemStack, block: &str) -> String {
format!(
"{} {} {} {}",
items.mining_speed(Some(stack), block).to_bits(),
items.correct_for_drops(Some(stack), block),
items
.mining_damage(stack)
.map_or_else(|| "none".into(), |n| n.to_string()),
items.mining_damage_capacity(stack)
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[3]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: serde_json::Value = serde_json::from_str(&line?)?;
let input = row["item"].as_str().unwrap();
let (id, components) = input
.find('[')
.map_or((input, None), |i| (&input[..i], Some(&input[i..])));
let mut stack = ItemStack::new(id, 1);
stack.count = row["count"].as_u64().unwrap_or(1) as u32;
if let Some(components) = components {
let tag = item_data::command_components(components)?;
(stack.damage, stack.extra) = item_data::decode_components(Some(&tag));
}
let block = row["block"].as_str().unwrap();
let restored = if stack.count == 0 || id == "minecraft:air" {
stack.clone()
} else {
item_nbt::item_from_nbt(&item_nbt::item_to_nbt(&stack)).unwrap()
};
writeln!(
out,
"{} | {}",
query(&items, &stack, block),
query(&items, &restored, block)
)?;
}
Ok(())
}
@@ -0,0 +1,70 @@
//! Headless trade costs/modifiers: no game, window or server is started.
use horizon_client::{
enchant::Enchantments,
items::{ItemStack, Items},
merchant::{TradeContext, Trades},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use serde_json::{Value, json};
use std::{collections::HashMap, fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[1]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let trades = Trades {
trades: HashMap::new(),
tags: HashMap::new(),
sets: HashMap::new(),
potion_tags: HashMap::new(),
};
let ctx = TradeContext {
items: &items,
enchantments: &enchantments,
variant: "minecraft:plains",
origin: [0.0; 3],
maps: None,
};
let mut output = String::new();
for line in fs::read_to_string(&args[2])?.lines() {
let v: Value = serde_json::from_str(line)?;
let mut random = LegacyRandom::new(v["seed"].as_i64().unwrap());
let o = trades.offer_from(&ctx, &v["trade"], &mut random);
let result = if let Some(o) = o {
let mut levels = o.result.crafting_enchantments().to_vec();
levels.sort_by(|a, b| a.0.cmp(&b.0));
let payments: Vec<_> = ["plain", "renamed", "wrong", "extra_potion"]
.iter()
.map(|kind| {
let mut p =
ItemStack::new("minecraft:potion", 2).with_potion(if *kind == "wrong" {
"minecraft:awkward"
} else {
"minecraft:water"
});
if *kind == "renamed" {
p.data_mut()
.set_loot_component("minecraft:custom_name", &json!("Bottle"));
}
if *kind == "extra_potion" {
p.data_mut()
.potion_extra
.push(("custom_color".into(), horizon_world::nbt::Tag::Int(123)));
}
let mut a = Some(p);
let accepted = o.take(&items, &mut a, &mut None);
json!([accepted, a.map_or(0, |s| s.count)])
})
.collect();
json!({"item": &*o.result.item, "count": o.result.count, "levels": levels.iter().map(|(id,l)| json!([&**id,l])).collect::<Vec<_>>(), "a": o.base_cost_a.stack.count, "b": o.cost_b.as_ref().map(|b| b.stack.count), "payments": payments})
} else {
Value::Null
};
output.push_str(&json!({"result": result, "next":random.next_long()}).to_string());
output.push('\n');
}
fs::write(&args[3], output)?;
Ok(())
}
+96 -7
View File
@@ -638,6 +638,9 @@ pub struct ItemView {
pub enchantments: Vec<(String, u32)>,
pub potion: Option<String>,
pub custom_name: bool,
/// Typed snapshot for supported mob equipment views. `None` leaves this
/// family unprojected and unsupported; an empty compound is actual absence.
pub custom_data: Option<horizon_world::nbt::Tag>,
}
/// An entity as predicates see it.
#[derive(Clone, Debug, Default)]
@@ -689,13 +692,17 @@ pub struct EntityView {
/// real holder without scores. Named predicates snapshot current values.
pub scores: Option<BTreeMap<String, i32>>,
}
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
/// a predicate that asks for a part left `None` fails.
/// What `PlayerPredicate` reads of a player beyond its entity. Each record
/// is filled only when a criterion may ask for it (`Tree::player_facts_asked`),
/// the food always (two numbers); a predicate that asks for a part left
/// `None` fails.
#[derive(Clone, Default)]
pub struct PlayerFacts {
/// `Player.experienceLevel`.
pub level: Option<i32>,
/// `Player.getFoodData`: `FoodData.getFoodLevel` and
/// `FoodData.getSaturationLevel`.
pub food: Option<(i32, f32)>,
/// `ServerStatsCounter`: stat type -> stat id -> value, both namespaced.
pub stats: Option<BTreeMap<String, BTreeMap<String, i32>>>,
/// `PlayerAdvancements`: the loaded advancements and the player's
@@ -708,6 +715,7 @@ impl std::fmt::Debug for PlayerFacts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlayerFacts")
.field("level", &self.level)
.field("food", &self.food)
.field("stats", &self.stats)
.field(
"advancements",
@@ -847,6 +855,20 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
return false;
}
}
// `CustomDataPredicate.matches`: typed subset matching of a projected
// stack snapshot. Unprojected consumers keep their unsupported behavior.
if let Some(pattern) = keyed(preds, "minecraft:custom_data").and_then(Value::as_str)
&& let Some(actual) = it.custom_data.as_ref()
{
let Ok(pattern @ horizon_world::nbt::Tag::Compound(_)) =
crate::command::snbt::parse_fully(pattern)
else {
return false;
};
if !crate::nbt_path::compare_nbt(&pattern, Some(actual)) {
return false;
}
}
true
}
/// `LockCode.canUnlock`: a spectator opens any container; otherwise the
@@ -1093,15 +1115,29 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
true
}
/// The rest of `PlayerPredicate.matches` (expected from it): the experience
/// level in the `level` bounds; each `stats` entry's value (0 when never
/// counted) in its bounds; each listed recipe known or not as asked; each
/// listed advancement loaded, and done or not, or each named criterion of it
/// granted or not. A field asked of facts that weren't filled fails.
/// level in the `level` bounds; the food (`FoodPredicate.matches`); each
/// `stats` entry's value (0 when never counted) in its bounds; each listed
/// recipe known or not as asked; each listed advancement loaded, and done or
/// not, or each named criterion of it granted or not. A field asked of facts
/// that weren't filled fails.
fn player_facts(p: &Value, f: &PlayerFacts) -> bool {
let level = &p["level"];
if !level.is_null() && !f.level.is_some_and(|l| range_i(level, i64::from(l))) {
return false;
}
// `FoodPredicate.matches`: the food level in `level`, then the
// saturation (a float, compared as a double) in `saturation`.
let food = &p["food"];
if !food.is_null() {
let Some((level, saturation)) = f.food else {
return false;
};
if !range_i(&food["level"], i64::from(level))
|| !range_f(&food["saturation"], f64::from(saturation))
{
return false;
}
}
let stats = &p["stats"];
if !stats.is_null() {
let Some(list) = stats.as_array() else {
@@ -2811,6 +2847,59 @@ mod tests {
assert!(player_matches(json!({}), &PlayerFacts::default()));
}
#[test]
fn player_food_tests_level_and_saturation_in_their_bounds() {
let fed = PlayerFacts {
food: Some((17, 3.0)),
..PlayerFacts::default()
};
for (food, expected) in [
(json!({}), true),
(json!({"level": {"min": 17}}), true),
(json!({"level": {"min": 18}}), false),
(json!({"level": {"max": 16}}), false),
(json!({"level": 17}), true),
(json!({"saturation": {"min": 3.0}}), true),
(json!({"saturation": {"min": 3.5}}), false),
(json!({"saturation": {"max": 2.9}}), false),
(
json!({"level": {"min": 17}, "saturation": {"max": 3.0}}),
true,
),
(
json!({"level": {"min": 17}, "saturation": {"min": 3.5}}),
false,
),
(
json!({"level": {"min": 18}, "saturation": {"min": 3.0}}),
false,
),
] {
assert_eq!(
player_matches(json!({"food": food}), &fed),
expected,
"{food}"
);
}
// The saturation float widens to a double: 1.2f is 1.2000000476837158.
let widened = PlayerFacts {
food: Some((20, 1.2)),
..PlayerFacts::default()
};
assert!(player_matches(
json!({"food": {"saturation": {"min": 1.2}}}),
&widened
));
assert!(!player_matches(
json!({"food": {"saturation": {"max": 1.2}}}),
&widened
));
// No food known: food asked fails, even unbounded.
assert!(!player_matches(
json!({"food": {}}),
&PlayerFacts::default()
));
}
#[test]
fn player_stats_match_each_counter_by_type_and_id() {
let mut counts: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
(counts.entry("minecraft:custom".into()).or_default())
+8 -7
View File
@@ -3,7 +3,8 @@
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
use crate::random_source;
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundInstance, Sounds};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, path::Path};
@@ -160,7 +161,7 @@ pub struct Ambience {
}
impl Default for Ambience {
fn default() -> Self {
Self::new(crate::sound_engine::unique_seed())
Self::new(random_source::unique_seed())
}
}
impl Ambience {
@@ -225,7 +226,7 @@ impl Ambience {
);
let instance = SoundInstance::under_liquid_loop(
"minecraft:ambient.underwater.loop",
SoundEngine::unseeded(),
random_source::create(),
);
sounds.play_instance(instance, false);
}
@@ -276,7 +277,7 @@ impl Ambience {
};
if let Some(sound) = sound {
self.underwater_delay = 0;
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
let instance = SoundInstance::under_liquid_sub(sound, random_source::create());
sounds.play_instance(instance, false);
}
}
@@ -308,7 +309,7 @@ impl Ambience {
let id = match self.loops.iter().find(|(e, _)| *e == event) {
Some((_, id)) => *id,
None => {
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
let instance = SoundInstance::biome_loop(&event, random_source::create());
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
self.loops.push((event, id));
@@ -323,7 +324,7 @@ impl Ambience {
for (sound, chance) in &ctx.sounds.additions {
if self.random.next_double() < *chance {
let event = crate::sound_events::normalize(sound);
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
let instance = SoundInstance::for_ambient_addition(&event, random_source::create());
sounds.play_instance(instance, false);
}
}
@@ -376,7 +377,7 @@ impl Ambience {
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
use crate::sound_engine::{Bound, SoundEngine, TickContext, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
+137
View File
@@ -0,0 +1,137 @@
//! Existing pure bed geometry shared by gameplay and the reusable Mob layer.
use horizon_core::geometry::Vec3;
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
/// answers with the shape of the block it is given, and `StrawBedBlock`
/// first walks a head over to its foot, so a straw bed is slept on at its
/// foot's shape while the head's own (the pillow's) is taller.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SleepOn {
/// The head itself: `BedBlock` and every other bed.
Head,
/// The foot the straw bed's head lies against.
Foot,
}
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
/// head takes its foot's block, and only a foot of the same block will do:
/// with the foot broken, or replaced by something else, the height is empty
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
/// so nobody lies in the half that is left.
pub fn slept_on(
bed: &str,
part: Option<&str>,
foot: Option<(&str, Option<&str>)>,
) -> Option<SleepOn> {
if bed != "minecraft:straw_bed" || part != Some("head") {
return Some(SleepOn::Head);
}
match foot {
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
_ => None,
}
}
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
pub fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
boxes
.iter()
.map(|b| b[4] - base_y)
.fold(None, |top: Option<f64>, y| {
Some(top.map_or(y, |top| top.max(y)))
})
}
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
/// above the top of the shape, which `getSleepHeight` measured from that
/// block's own y.
pub fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + height + 0.125,
f64::from(head[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
/// foot, and one whose foot is gone - or is not a straw bed's foot any
/// more - has no height, so `startSleeping` gives up on it.
#[test]
fn a_straw_beds_head_is_slept_on_at_its_foot() {
let straw = "minecraft:straw_bed";
assert_eq!(slept_on(straw, Some("head"), None), None, "missing foot");
assert_eq!(
slept_on(straw, Some("head"), Some((straw, None))),
None,
"missing foot part"
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
Some(SleepOn::Foot)
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("head")))),
None,
"a head facing another head"
);
assert_eq!(
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
None,
"the foot was replaced"
);
assert_eq!(
slept_on(
straw,
Some("head"),
Some(("minecraft:red_bed", Some("foot")))
),
None,
"the foot is another bed"
);
assert_eq!(
slept_on(straw, Some("foot"), None),
Some(SleepOn::Head),
"a straw bed's foot sleeps on its own shape"
);
assert_eq!(
slept_on("minecraft:red_bed", Some("head"), None),
Some(SleepOn::Head),
"an ordinary bed's head is its own shape"
);
}
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
/// the block's own y; a shape with nothing in it has no height.
#[test]
fn the_height_is_the_top_of_the_shape() {
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
let two = [
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
];
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
}
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
/// was given) had them at 70.6875.
#[test]
fn the_sleeper_lies_an_eighth_above_the_shape() {
let at = pos_to_bed([10, 70, 5], 0.4375);
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod bed_geometry;
pub mod block_contact;
pub mod powder_snow;
pub use horizon_client_render::{
@@ -19,8 +20,8 @@ pub use horizon_client_render::{
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source,
screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
+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.
+97 -20
View File
@@ -1,5 +1,5 @@
//! Non-player entities simulated on the client world. Dropped items follow
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
//! `ItemEntity.tick`: gravity 0.04, 0.98F drag (block friction on the ground),
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
@@ -524,9 +524,25 @@ impl ItemEntity {
spilled.extend(crate::item_data::destroyed_container(&mut self.stack));
Some(spilled)
}
/// The ItemEntity snapshot used by both its move and its noCollision check.
fn collision_context(&self) -> horizon_world::collision_context::CollisionContext<'static> {
use horizon_world::collision_context::{
CollisionContext, CollisionEntity, EntityContextKind,
};
CollisionContext::for_entity(
CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: EntityContextKind::Item,
y: self.position.y,
descending: false,
main_hand: None,
},
false,
)
}
/// `ItemEntity.tick` on a server: an item whose box is inside a block
/// (one placed where it lay) has no physics this tick
/// (`level.noCollision(box.deflate(1.0E-7))`).
/// (`level.noCollision(this, box.deflate(1.0E-7))`).
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
let mut scene = SceneCollisions {
world,
@@ -534,7 +550,7 @@ impl ItemEntity {
subject: None,
};
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!crate::collisions::no_collision_empty(&mut scene, inner)
!crate::collisions::no_collision_with_context(&mut scene, inner, self.collision_context())
}
/// `Entity.updateFluidInteraction`: entering water after the first tick
/// runs `doWaterSplashEffect`.
@@ -575,6 +591,22 @@ impl ItemEntity {
speed,
);
}
/// `ItemEntity.setUnderwaterMovement` / `setUnderLavaMovement`: horizontal
/// drag is a float (`0.99F` / `0.95F`), and a slow item is lifted by
/// `5.0E-4F` while slower than `0.06F`.
fn set_fluid_movement(&mut self, drag: f32) {
let drag = f64::from(drag);
let lift = if self.velocity.y < f64::from(0.06_f32) {
f64::from(5.0e-4_f32)
} else {
0.0
};
self.velocity = Vec3::new(
self.velocity.x * drag,
self.velocity.y + lift,
self.velocity.z * drag,
);
}
/// `ItemEntity.tick` up to the blocks' push. The game has just run the
/// fluid push (`Entity::push_by_fluid`) and passes the heights it found;
/// it pushes by a bubble column next (`Entity::push_by_bubble_column`) and
@@ -624,18 +656,11 @@ impl ItemEntity {
let old_velocity = self.velocity;
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
self.velocity = Vec3::new(
self.velocity.x * 0.99,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
// ItemEntity.setUnderwaterMovement: 0.99F, 5.0E-4F, 0.06F.
self.set_fluid_movement(0.99_f32);
} else if lava_height > f64::from(0.1f32) {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.95,
);
// ItemEntity.setUnderLavaMovement: 0.95F, 5.0E-4F, 0.06F.
self.set_fluid_movement(0.95_f32);
} else {
self.velocity.y -= self.get_gravity();
}
@@ -673,7 +698,13 @@ impl ItemEntity {
entities: entity_boxes,
border: border.map(|b| (b, [self.position.x, self.position.z])),
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.resolve_with_context(
self.bounds(),
wanted,
0.0,
self.on_ground,
self.collision_context(),
)
.unwrap_or(Vec3::ZERO)
};
if move_applies(wanted, moved) {
@@ -804,18 +835,22 @@ impl ItemEntity {
if !no_physics {
self.slime_step_on(world, facts);
}
let mut friction = 0.98;
// `float friction = getAirDrag()` (0.98F); on the ground, times
// the block's friction as a float, then widened for the scale.
let mut friction = 0.98_f32;
if self.on_ground {
let below = world.block(
self.position.x.floor() as i32,
(self.position.y - 0.999_999).floor() as i32,
self.position.z.floor() as i32,
);
friction = f64::from(facts.get(below).friction) * 0.98;
friction *= facts.get(below).friction;
}
let friction = f64::from(friction);
let air = f64::from(0.98_f32);
self.velocity = Vec3::new(
self.velocity.x * friction,
self.velocity.y * 0.98,
self.velocity.y * air,
self.velocity.z * friction,
);
if self.on_ground && self.velocity.y < 0.0 {
@@ -2371,6 +2406,7 @@ impl ArmorStand {
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
@@ -3612,12 +3648,53 @@ mod slime_bounce_tests {
#[test]
fn a_slime_landing_stays_upward_after_item_friction() {
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
// ItemEntity then multiplies Y by 0.98F and applies -0.5 only if still
// negative on the ground. A slime bounce must remain positive.
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
let bounced =
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * f64::from(0.98_f32);
assert!(bounced > 0.0);
}
#[test]
fn item_air_drag_widens_get_air_drag() {
// ItemEntity.tick: getAirDrag 0.98F, not the f64 0.98 (#6814).
let mut item = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.2, -0.1);
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
item.tick_settle(
&world,
&facts,
true,
ItemStep {
old_velocity: item.velocity,
movement: Some(item.velocity),
clipped_vertical: false,
},
);
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
assert_eq!(item.velocity.y.to_bits(), 0x3fc9_1687_3333_3334);
assert_eq!(item.velocity.z.to_bits(), 0xbfb9_1687_3333_3334);
}
#[test]
fn item_fluid_drags_widen_float_constants() {
// setUnderwaterMovement 0.99F / 5.0E-4F; setUnderLavaMovement 0.95F.
let mut water = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
water.set_fluid_movement(0.99_f32);
assert_eq!(water.velocity.x.to_bits(), 0x3fb9_5810_6666_6667);
assert_eq!(water.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
assert_eq!(water.velocity.z.to_bits(), 0xbfc9_5810_6666_6667);
let mut lava = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
lava.set_fluid_movement(0.95_f32);
assert_eq!(lava.velocity.x.to_bits(), 0x3fb8_51eb_8000_0000);
assert_eq!(lava.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
assert_eq!(lava.velocity.z.to_bits(), 0xbfc8_51eb_8000_0000);
}
#[test]
fn a_slime_bounce_marks_the_item_for_immediate_sync() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+491 -14
View File
@@ -685,9 +685,13 @@ pub fn mob_to_nbt(
} else {
map.remove(&horizon_world::nbt::JavaString::from("TicksFrozen"));
}
// LivingEntity.addAdditionalSaveData: `putBoolean(FallFlying, isFallFlying())`.
map.insert(
"FallFlying".into(),
Tag::Byte(i8::from(m.extra.fall_flying)),
);
for (key, value) in [
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
@@ -909,7 +913,7 @@ pub fn mob_to_nbt(
| "minecraft:llama"
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
// eating haystack yet.
@@ -1114,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) {
@@ -1149,6 +1155,11 @@ pub fn mob_to_nbt(
);
}
"minecraft:camel" | "minecraft:camel_husk" => {
// AbstractHorse.addAdditionalSaveData, including Camel.isTamed.
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
map.insert("EatingHaystack".into(), Tag::Byte(i8::from(m.extra.flag)));
// LastPoseTick: the pose change time, negative while sitting. The
// game time is not known here; the world's save overwrites it
// with the real one (`camel::synced_pose_tick`).
@@ -1190,6 +1201,7 @@ pub fn mob_to_nbt(
map.insert("listener".into(), st.vibration.to_nbt());
map.insert("anger".into(), warden_anger_to_nbt(&st.anger, &mut uuid_of));
}
write_warden_owned_memories(m, map);
}
// Armadillo.addAdditionalSaveData: the time left to the next scute, and
// the state it is in, which is always written (`idle` when it is not
@@ -1250,9 +1262,19 @@ pub fn mob_to_nbt(
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
// Phantom.addAdditionalSaveData.
// Phantom.addAdditionalSaveData: the size, and the circling anchor.
// `storeNullable` writes nothing for a phantom that has none, so its
// save carries no `anchor_pos` at all.
"minecraft:phantom" => {
map.insert("size".into(), Tag::Int(m.extra.size));
match m.extra.home {
Some(anchor) => {
map.insert("anchor_pos".into(), Tag::IntArray(anchor.to_vec()));
}
None => {
map.remove(&"anchor_pos".into());
}
}
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
@@ -1697,6 +1719,8 @@ pub fn mob_from_nbt(
let mut m = Mob::new(id, kind, position, yaw);
m.velocity = motion(tag);
m.head_pitch = pitch;
// LivingEntity.readAdditionalSaveData: `setSharedFlag(7, getBooleanOr(FallFlying, false))`.
m.extra.fall_flying = flag(tag, "FallFlying");
read_mob_home(&mut m, tag);
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
@@ -1996,7 +2020,8 @@ pub fn mob_from_nbt(
}
if crate::mobs::equine::is_equine(m.species()) {
m.extra.tame = flag(tag, "Tame");
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
// AbstractHorse.readAdditionalSaveData: the saved Int as is, camels included.
m.extra.equine_temper = tag.get("Temper").and_then(int_value).unwrap_or(0);
m.extra.bred = flag(tag, "Bred");
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
@@ -2195,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"))
@@ -2226,6 +2265,9 @@ pub fn mob_from_nbt(
crate::mobs::frog::set_pregnant(&mut m, true);
}
if crate::mobs::camel::is_camel(m.species()) {
// AbstractHorse.readAdditionalSaveData: the eating state (Temper is
// read with the other equines' above).
m.extra.flag = flag(tag, "EatingHaystack");
// The tick is a game time: `camel::finish_load` puts it on the mob's
// clock once the world's time is known.
let tick = match tag.get("LastPoseTick") {
@@ -2303,6 +2345,7 @@ pub fn mob_from_nbt(
crate::mobs::warden::AngerManagement::from_saved(warden_anger_from_nbt(anger));
}
}
restore_warden_owned_memories(&mut m, tag);
}
// Armadillo.readAdditionalSaveData: the saved state through `switchToState`
// (`onSyncedDataUpdated` then restarts the time in that state), idle for a
@@ -2395,8 +2438,11 @@ pub fn mob_from_nbt(
m.extra.flag2 = flag(tag, "wasOnGround");
}
if m.species() == crate::mobs::Species::Phantom {
// Phantom.readAdditionalSaveData: setPhantomSize(size).
// Phantom.readAdditionalSaveData: setPhantomSize(size), and the circling
// anchor, absent or unreadable when the save has no anchor at all
// (`input.read("anchor_pos", BlockPos.CODEC).orElse(null)`).
crate::mobs::phantom::set_size(&mut m, number(tag.get("size")).unwrap_or(0) as i32);
m.extra.home = home_block_pos(tag.get("anchor_pos"));
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
@@ -3396,7 +3442,9 @@ pub fn falling_block_to_nbt(
/// (block state tag, position, motion, time, fall distance)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
Some((
tag.get("BlockState")?.clone(),
tag.get("BlockState")
.cloned()
.unwrap_or_else(|| string("minecraft:sand")),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0) as i32,
@@ -3404,6 +3452,21 @@ pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)>
))
}
/// FallingBlockEntity.readAdditionalSaveData uses the modern state codec.
pub fn falling_state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
BlockState::from_nbt(tag)
.ok()
.and_then(|mut state| {
state.name = horizon_world::identifier::Identifier::parse(&state.name)
.ok()?
.to_string();
registry
.id(&state)
.or_else(|| registry.default_state(&state.name))
})
.or_else(|| registry.default_state("minecraft:sand"))
}
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
(
@@ -4487,7 +4550,7 @@ mod tests {
events
.iter()
.find_map(|e| match e {
crate::mobs::MobEvent::Explode(_, radius, _) => Some(*radius),
crate::mobs::MobEvent::Explode(_, radius, ..) => Some(*radius),
_ => None,
})
.expect("the creeper exploded")
@@ -4766,6 +4829,94 @@ mod tests {
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
fn falling_decode_registry() -> BlockRegistry {
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
(
"states",
Tag::List(
["minecraft:air", "minecraft:sand", "minecraft:gravel"]
.into_iter()
.map(string)
.collect(),
),
),
(
"defaults",
Tag::compound([
("minecraft:air", Tag::Int(0)),
("minecraft:sand", Tag::Int(1)),
("minecraft:gravel", Tag::Int(2)),
]),
),
]))
.unwrap()
}
fn falling_decode_input() -> Tag {
horizon_world::nbt::parse_snbt(
"{Pos:[3.5d,200d,4.5d],Motion:[0d,0d,0d],Time:10,DropItem:0b,NoGravity:1b}",
)
.unwrap()
.0
}
fn falling_decode_save(registry: &BlockRegistry, input: &Tag) -> Tag {
let (state, pos, motion, time, distance) =
falling_block_from_nbt(input).expect("absent BlockState is valid input");
let actual = falling_state_from_nbt(registry, &state).unwrap();
assert_eq!(falling_block_flags(input), (true, false));
// Save only the decoded concrete model; no original source compound.
let saved = falling_block_to_nbt(
state_to_nbt(registry, actual),
pos,
motion,
time,
false,
crate::falling_block::Damage::default(),
distance,
[0.0, 0.0],
[96, 0, 0, 1],
);
assert_eq!(saved.get("Pos"), input.get("Pos"));
assert_eq!(saved.get("Motion"), input.get("Motion"));
assert_eq!(saved.get("Time"), Some(&Tag::Int(10)));
assert_eq!(saved.get("DropItem"), Some(&Tag::Byte(0)));
saved
}
#[test]
fn falling_decode_absent_state_saves_actual_sand_without_raw() {
let registry = falling_decode_registry();
let saved = falling_decode_save(&registry, &falling_decode_input());
assert_eq!(saved.get("BlockState"), Some(&string("minecraft:sand")));
}
#[test]
fn falling_decode_modern_boundary_keeps_shared_legacy_reader() {
let registry = falling_decode_registry();
let legacy = Tag::compound([("Name", string("minecraft:gravel"))]);
assert_eq!(
state_from_nbt(&registry, &legacy),
registry.default_state("minecraft:gravel")
);
for (state, expected) in [
(string("minecraft:sand"), "minecraft:sand"),
(
Tag::compound([("id", string("minecraft:gravel"))]),
"minecraft:gravel",
),
(string("minecraft:air"), "minecraft:air"),
(legacy, "minecraft:sand"),
(Tag::Compound(Default::default()), "minecraft:sand"),
] {
let mut input = falling_decode_input();
put(&mut input, "BlockState", state);
let saved = falling_decode_save(&registry, &input);
assert_eq!(saved.get("BlockState"), Some(&string(expected)));
}
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
@@ -5585,6 +5736,60 @@ fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
}
}
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
let Tag::Compound(memories) = memories else {
return;
};
let mem = m.extra.warden.as_ref().map(|st| &st.brain.mem);
for (key, slot) in [
("minecraft:is_emerging", mem.map(|mem| &mem.is_emerging)),
("minecraft:dig_cooldown", mem.map(|mem| &mem.dig_cooldown)),
] {
if let Some(slot) = slot.filter(|slot| slot.present()) {
let mut entry = Tag::compound([("value", Tag::Compound(Compound::new()))]);
if slot.ttl() != i64::MAX {
put(&mut entry, "ttl", Tag::Long(slot.ttl()));
}
memories.insert(key.into(), entry);
} else {
memories.remove(&key.into());
}
}
}
/// `Brain` / `ExpirableValue`: admitted Unit values with absent or Long TTL.
fn restore_warden_owned_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|brain| brain.get("memories"));
let Some(st) = m.extra.warden.as_mut() else {
return;
};
for (key, slot) in [
("minecraft:is_emerging", &mut st.brain.mem.is_emerging),
("minecraft:dig_cooldown", &mut st.brain.mem.dig_cooldown),
] {
let Some(entry) = memories.and_then(|memories| memories.get(key)) else {
// Fresh Java read/load: absent entries do not retain finalize's slot.
slot.clear();
continue;
};
if !matches!(entry.get("value"), Some(Tag::Compound(value)) if value.is_empty()) {
continue; // Unmeasured malformed shapes retain existing initialization.
}
match entry.get("ttl") {
None => slot.set(()),
Some(Tag::Long(ttl)) => slot.set_ttl((), *ttl),
_ => {} // No new numeric/malformed TTL policy.
}
}
}
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
@@ -5762,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 {
@@ -5773,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))
@@ -9168,3 +9391,257 @@ mod carrier_inventory_tests {
);
}
}
#[cfg(test)]
mod camel_inherited_saved_tests {
use super::*;
use crate::mobs::Species;
fn types(species: Species, id: &str) -> HashMap<String, Arc<MobType>> {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
HashMap::from([(id.to_owned(), kind)])
}
fn input(id: &str, temper: Option<i32>) -> Tag {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[1.5, 200.0, 4.5])),
("Health", Tag::Float(10.0_f32.to_bits())),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Silent", Tag::Byte(1)),
("Invulnerable", Tag::Byte(1)),
("LastPoseTick", Tag::Long(1)),
("CustomName", string("kept")),
("Tags", Tag::List(vec![string("kept")])),
("audit:kept", Tag::Int(17)),
]);
if let Some(temper) = temper {
put(&mut raw, "Tame", Tag::Byte(0));
put(&mut raw, "Temper", Tag::Int(temper));
put(&mut raw, "Bred", Tag::Byte(1));
put(&mut raw, "EatingHaystack", Tag::Byte(1));
}
raw
}
#[test]
fn camel_inherited_fields_decode_and_save_without_raw_or_with_stale_raw() {
for (species, id) in [
(Species::Camel, "minecraft:camel"),
(Species::CamelHusk, "minecraft:camel_husk"),
] {
let types = types(species, id);
for temper in [None, Some(0), Some(50), Some(-1), Some(256), Some(i32::MAX)] {
let raw = input(id, temper);
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
assert!(
mob.extra.tame,
"Camel.isTamed remains true even with Tame:0b"
);
assert_eq!(mob.extra.bred, temper.is_some());
assert_eq!(
mob.extra.flag,
temper.is_some(),
"actual loaded eating state"
);
assert!(mob.no_ai && mob.no_gravity && mob.silent && mob.invulnerable_flag);
assert!(!mob.extra.camel_sitting && !mob.extra.flag2);
assert_eq!(mob.extra.pose_change_tick, 1);
let mut stale = raw.clone();
for (field, value) in [
("Tame", Tag::Byte(0)),
("Temper", Tag::Long(9999)),
("Bred", Tag::Byte(i8::from(temper.is_none()))),
("EatingHaystack", Tag::Byte(i8::from(temper.is_none()))),
] {
put(&mut stale, field, value);
}
for source in [None, Some(&raw), Some(&stale)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)), "{id}, {temper:?}");
assert_eq!(saved.get("Temper"), Some(&Tag::Int(temper.unwrap_or(0))));
for field in ["Bred", "EatingHaystack"] {
assert_eq!(
saved.get(field),
Some(&Tag::Byte(i8::from(temper.is_some())))
);
}
for field in ["NoAI", "NoGravity", "Silent", "Invulnerable"] {
assert_eq!(saved.get(field), Some(&Tag::Byte(1)));
}
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![92, 0, 0, 1])));
if source.is_some() {
for field in ["CustomName", "Tags", "audit:kept"] {
assert_eq!(saved.get(field), raw.get(field));
}
}
}
}
}
}
#[test]
fn camel_signed_state_and_mutations_override_contradictory_raw() {
for (species, id) in [
(Species::Camel, "minecraft:camel"),
(Species::CamelHusk, "minecraft:camel_husk"),
] {
let types = types(species, id);
for (tag, value) in [
(Tag::Int(-1), -1),
(Tag::Int(256), 256),
(Tag::Int(i32::MAX), i32::MAX),
(Tag::Float((-1.5_f32).to_bits()), -2),
(Tag::Long(4_294_967_297), 1),
(string("bad"), 0),
] {
let mut raw = input(id, Some(50));
put(&mut raw, "Temper", tag);
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
assert_eq!(mob.extra.equine_temper, value, "actual signed model state");
assert!(mob.extra.tame && mob.extra.bred && mob.extra.flag);
assert_eq!(
mob_to_nbt(&mob, [92, 0, 0, 1], None, None, |_| None).get("Temper"),
Some(&Tag::Int(value))
);
}
let raw = input(id, Some(50));
let mut mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
crate::mobs::camel::finish_load(&mut mob, 1024);
let unchanged = (
mob.id,
mob.position,
mob.velocity,
mob.health,
mob.extra.variant3,
mob.extra.pose_change_tick,
mob.extra.egg_time,
mob.extra.camel_sitting,
mob.extra.flag2,
);
mob.extra.equine_temper = -77;
mob.extra.bred = false;
mob.extra.flag = false;
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(-77)));
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("EatingHaystack"), Some(&Tag::Byte(0)));
}
assert_eq!(
(
mob.id,
mob.position,
mob.velocity,
mob.health,
mob.extra.variant3,
mob.extra.pose_change_tick,
mob.extra.egg_time,
mob.extra.camel_sitting,
mob.extra.flag2
),
unchanged
);
}
}
#[test]
fn camel_saved_carry_leaves_the_seven_equine_paths_unchanged() {
for (species, id) in [
(Species::Horse, "minecraft:horse"),
(Species::Donkey, "minecraft:donkey"),
(Species::Mule, "minecraft:mule"),
(Species::SkeletonHorse, "minecraft:skeleton_horse"),
(Species::ZombieHorse, "minecraft:zombie_horse"),
(Species::Llama, "minecraft:llama"),
(Species::TraderLlama, "minecraft:trader_llama"),
] {
let mut raw = input(id, Some(12));
put(&mut raw, "Tame", Tag::Byte(1));
let mob = mob_from_nbt(&raw, &types(species, id), 92, None).unwrap();
assert_eq!(mob.extra.equine_temper, 12);
assert!(!mob.extra.flag, "the other species' load path is unchanged");
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(12)));
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(1)));
// Preserve the existing raw/default behavior outside Camel;
// this control does not claim the other species' eating codec.
assert_eq!(
saved.get("EatingHaystack"),
Some(&Tag::Byte(i8::from(source.is_some())))
);
}
}
}
}
#[cfg(test)]
mod equine_temper_preservation_tests {
use super::*;
use crate::mobs::Species;
#[test]
fn temper_preservation_decodes_and_saves_without_raw_input() {
for (name, species) in [
("llama", Species::Llama),
("trader_llama", Species::TraderLlama),
("horse", Species::Horse),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = format!("minecraft:{name}");
def.species = species;
let types = [(def.id.clone(), kind.clone())].into();
for value in [50, 0, 30, 100, -7, 1000, i32::MIN, i32::MAX] {
let raw = Tag::compound([
("id", string(&format!("minecraft:{name}"))),
("Pos", doubles(&[1.5, 200.0, 1.5])),
("Temper", Tag::Int(value)),
("Tame", Tag::Byte(1)),
("NoAI", Tag::Byte(1)),
("Strength", Tag::Int(3)),
("ChestedHorse", Tag::Byte(1)),
]);
let m = mob_from_nbt(&raw, &types, 7, None).unwrap();
assert_eq!(m.species(), species);
assert!(m.extra.tame && m.no_ai);
let stale =
Tag::compound([("Temper", Tag::Int(17)), ("audit:unrelated", Tag::Int(9))]);
for base in [None, Some(&raw), Some(&stale)] {
let saved = mob_to_nbt(&m, [93, 0, 0, 1], base, None, |_| None);
assert_eq!(
saved.get("Temper"),
Some(&Tag::Int(value)),
"{name}: loaded temper is not a gameplay limit"
);
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
if species != Species::Horse {
assert_eq!(saved.get("Strength"), Some(&Tag::Int(3)));
assert_eq!(saved.get("ChestedHorse"), Some(&Tag::Byte(1)));
}
if base == Some(&stale) {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(9)));
}
}
}
let fresh = Mob::new(7, kind, Vec3::ZERO, 0.0);
let saved = mob_to_nbt(&fresh, [93, 0, 0, 1], None, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(0)));
let absent = Tag::compound([
("id", string(&format!("minecraft:{name}"))),
("Pos", doubles(&[1.5, 200.0, 1.5])),
]);
let decoded = mob_from_nbt(&absent, &types, 7, None).unwrap();
assert_eq!(
mob_to_nbt(&decoded, [0; 4], None, None, |_| None).get("Temper"),
Some(&Tag::Int(0))
);
}
}
}
+25 -1
View File
@@ -2,9 +2,16 @@
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
//! number, so a kind that forgot the check ignored the flag (#6481).
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
//! `applyGravity`, `isPushedByFluid`, `getAirDrag`; issue #6509, slice #6544.
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
use horizon_core::geometry::Vec3;
/// `Entity.scale(getAirDrag())`: 0.98F, widened before it scales double motion.
pub fn scale_air_drag(velocity: Vec3) -> Vec3 {
let drag = f64::from(0.98_f32);
Vec3::new(velocity.x * drag, velocity.y * drag, velocity.z * drag)
}
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
/// only where its vanilla class does; the defaults are `Entity`'s.
@@ -31,3 +38,20 @@ pub trait EntityRules {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_ticks_of_air_drag_match_vanilla_0_98f() {
// Issue #6814: 0.1 × 0.98F⁴ is 0.09223682318…, not 0.1 × 0.98⁴ as f64.
let mut v = Vec3::new(0.1, 0.0, 0.0);
for _ in 0..4 {
v = scale_air_drag(v);
}
assert_eq!(v.x.to_bits(), 0x3fb7_9cd5_1b0c_538e);
assert_eq!(v.y.to_bits(), 0);
assert_eq!(v.z.to_bits(), 0);
}
}
File diff suppressed because it is too large. Load diff
+171 -4
View File
@@ -298,6 +298,7 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
.collect(),
potion: s.potion().map(str::to_owned),
custom_name: s.custom_name().is_some(),
custom_data: None,
}
}
impl Game<'_> {
@@ -530,6 +531,8 @@ impl Game<'_> {
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
adv::PlayerFacts {
level: asked.level.then_some(g.xp_level),
// The guest's food as last written back from its body.
food: Some((g.food, g.saturation)),
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
advancements: asked
.advancements
@@ -561,13 +564,17 @@ impl Game<'_> {
me
}
/// The player's level, statistics, advancements and recipes that
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
/// `asked` names, and its food, for `PlayerPredicate`; records not
/// loaded stay `None`.
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
if self.player.is_none() {
let Some(p) = self.player.as_ref() else {
return adv::PlayerFacts::default();
}
};
adv::PlayerFacts {
level: asked.level.then_some(self.experience.level),
// `ServerPlayer.getFoodData`, read by every player predicate:
// two numbers, so always filled.
food: Some((p.health.food.food, p.health.food.saturation)),
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
advancements: (asked.advancements && self.adv.loaded)
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
@@ -690,6 +697,24 @@ impl Game<'_> {
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
// Only the new snapshot is adopted; the existing view fields stay as
// constructed above. Body and other view consumers stay unprojected.
use horizon_client::mobs::finalize;
for (view, slot) in equipment[..6].iter_mut().zip([
finalize::MAINHAND,
finalize::OFFHAND,
finalize::HEAD,
finalize::CHEST,
finalize::LEGS,
finalize::FEET,
]) {
if let Some(view) = view {
let data = finalize::item_stack(m, slot)
.and_then(|stack| stack.custom_data().cloned())
.unwrap_or_default();
view.custom_data = Some(horizon_world::nbt::Tag::Compound(data));
}
}
let passengers: Vec<EntityView> = self
.mobs
.iter()
@@ -2920,7 +2945,11 @@ mod structure_tag_tests {
let game_tags = pack.join("data/minecraft/tags/worldgen/structure");
std::fs::create_dir_all(&tags).unwrap();
std::fs::create_dir_all(&game_tags).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"fixture"}}"#,
)
.unwrap();
std::fs::write(
tags.join("village.json"),
r##"{"values": ["#minecraft:village", "minecraft:igloo"]}"##,
@@ -2949,3 +2978,141 @@ mod structure_tag_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod custom_data_equipment_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::{mobs::finalize, save};
use horizon_core::position::BlockPos;
use horizon_world::nbt::{Tag, parse_snbt};
fn item_predicate(pattern: &str) -> Value {
serde_json::json!({"items":"minecraft:stick","count":1,
"predicates":{"minecraft:custom_data":pattern}})
}
fn condition(pattern: &str) -> Value {
serde_json::json!({"type":"minecraft:entity_properties","entity":"this",
"predicate":{"minecraft:entity_type":"minecraft:pig",
"minecraft:equipment":{"mainhand":item_predicate(pattern)}}})
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_mob_equipment_projects_typed_custom_data_into_named_predicate_evaluation() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let clock = game.world.game_time;
let rich = r#"{h289:1b,nested:{k:7,extra:9},entries:[{k:1b,extra:2b},{k:2b}],wide:9007199254740993L,spare:"keep"}"#;
let mut failures = Vec::new();
for (i, data) in [(1, Some(rich)), (2, None)] {
let uuid = [289, 1, 0, i];
let actor = format!("00000121-0000-0001-0000-{i:012x}");
let components = data.map_or(String::new(), |d| {
format!(",components:{{\"minecraft:custom_data\":{d}}}")
});
let x = f64::from(i) + 0.5;
assert!(game.run_command(&format!(
"summon minecraft:pig {x} 200 4.5 {{UUID:[I;289,1,0,{i}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Health:10f,equipment:{{mainhand:{{id:\"minecraft:stick\",count:1{components}}}}}}}"
)));
assert!(game.world.loaded(i, 4));
assert_eq!(
&*game.res.facts().get(game.world.block(i, 199, 4)).name,
"minecraft:stone"
);
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;289,1,0,{i}],NoAI:1b,Health:10f,Pos:[{x}d,200d,4.5d]}}]")));
let id = *game
.persist
.uuids
.iter()
.find(|(_, value)| **value == uuid)
.expect("actual admitted UUID")
.0;
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert!(mob.no_ai);
let stack = finalize::item_stack(mob, finalize::MAINHAND)
.expect("actual loaded equipment stack");
assert_eq!((&*stack.item, stack.count), ("minecraft:stick", 1));
let saved = save::item_to_nbt(&stack);
if let Some(data) = data {
let (expected, used) = parse_snbt(data).unwrap();
assert_eq!(used, data.len());
assert_eq!(
saved
.get("components")
.and_then(|t| t.get("minecraft:custom_data")),
Some(&expected)
);
assert_eq!(
stack.custom_data().unwrap().get(&"h289".into()),
Some(&Tag::Byte(1))
);
assert_eq!(
stack.custom_data().unwrap().get(&"wide".into()),
Some(&Tag::Long(9_007_199_254_740_993))
);
// Contradictory retained raw equipment must not replace actual stack state.
let (wrong, _) = parse_snbt(r#"{equipment:{mainhand:{id:"minecraft:stick",count:1,components:{"minecraft:custom_data":{h289:2b}}}}}"#).unwrap();
let raw = game
.persist
.raw
.entry(id)
.or_insert_with(|| Tag::Compound(Default::default()));
let Tag::Compound(raw) = raw else {
panic!("actual raw compound")
};
raw.insert("equipment".into(), wrong.get("equipment").unwrap().clone());
assert!(game.run_command(&format!("execute if data entity {actor} equipment.mainhand.components{{\"minecraft:custom_data\":{{h289:1b}}}}")));
} else {
assert!(stack.custom_data().is_none());
assert!(game.run_command(&format!("execute unless data entity {actor} equipment.mainhand.components.\"minecraft:custom_data\"")));
}
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
let view = game.mob_entity(mob);
assert_eq!(
(
&*view.equipment[0].as_ref().unwrap().id,
view.equipment[0].as_ref().unwrap().count
),
("minecraft:stick", 1)
);
let probes: Vec<(&str, bool)> = if data.is_some() {
vec![
("{h289:1b}", true),
("{nested:{k:7}}", true),
("{entries:[{k:2b}]}", true),
("{entries:[{k:2b},{k:1b}]}", true),
("{wide:9007199254740993L}", true),
("{}", true),
("{h289:2b}", false),
("{h289:1}", false),
("{missing:1b}", false),
("{entries:[]}", false),
("{wide:9007199254740992L}", false),
]
} else {
vec![("{}", true), ("{h289:1b}", false)]
};
for (pattern, want) in probes {
let actual =
game.conditions_hold(&condition(pattern), Some(&view), [x, 200.0, 4.5]);
if actual != want {
failures.push((i, pattern, actual, want));
}
}
// Existing non-equipment views are unprojected and retain unsupported legacy behavior.
assert!(game.loot_item_filter(&item_predicate("{h289:99b}"), &stack));
assert_eq!(game.world.game_time, clock, "zero observation ticks");
}
assert!(
failures.is_empty(),
"actual mob-equipment custom_data mismatch: {failures:?}"
);
}
}
+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",
+187 -58
View File
@@ -336,9 +336,8 @@ impl Game<'_> {
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
/// (the player's armour and the armour stands'): each generated texture
/// goes into an atlas trim slot, a free one, else the least recently
/// drawn.
/// drawn. Reuse the completed preparation while the requests and atlas stay the same.
pub(super) fn prepare_trims(&mut self) {
use horizon_client::hud::TRIM_SLOTS;
let assets = &self.armor_stand_assets;
let items = self.items();
let mut wanted: Vec<TrimTexture> = Vec::new();
@@ -398,60 +397,12 @@ impl Game<'_> {
wanted.push(t);
}
}
for t in wanted {
let name = t.sprite();
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
let sprites = &self.gui.atlas.sprites;
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
let e = self.trim_slots.remove(i);
self.trim_slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
continue;
};
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
Some(free) => free,
None => {
let (old, slot) = self.trim_slots.remove(0);
self.gui.atlas.sprites.remove(&old);
slot
}
};
let Some(area) = self
.gui
.atlas
.sprites
.get(&format!("horizon/trim{slot}"))
.copied()
else {
continue;
};
if w > area.w || h > area.h {
continue;
}
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
self.map_patches.push(horizon_client::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
let rect = horizon_client::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
};
self.gui.atlas.sprites.insert(name.clone(), rect);
self.trim_slots.push((name, slot));
}
self.trim_slots.prepare(
wanted,
&assets.root,
&mut self.gui.atlas,
&mut self.map_patches,
);
}
/// `ArmorStandItem.useOn`.
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
@@ -1527,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();
@@ -1961,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:?}"
);
}
}
+39 -13
View File
@@ -5,8 +5,10 @@
pub(super) struct AtlasSlots<K> {
count: usize,
/// (image, slot), most recently shown last.
held: Vec<(K, usize)>,
/// (image, slot, the frame it was last shown in), most recently shown last.
held: Vec<(K, usize, u64)>,
/// The frame being drawn (`next_frame`).
frame: u64,
}
impl<K: PartialEq> AtlasSlots<K> {
@@ -14,30 +16,39 @@ impl<K: PartialEq> AtlasSlots<K> {
Self {
count,
held: Vec::new(),
frame: 0,
}
}
/// A new frame starts: what was shown before it may give up its slot.
pub(super) fn next_frame(&mut self) {
self.frame += 1;
}
/// The slot holding `key`, marking it recently shown.
pub(super) fn show<Q: PartialEq + ?Sized>(&mut self, key: &Q) -> Option<usize>
where
K: std::borrow::Borrow<Q>,
{
let i = self.held.iter().position(|e| e.0.borrow() == key)?;
let e = self.held.remove(i);
let mut e = self.held.remove(i);
e.2 = self.frame;
let slot = e.1;
self.held.push(e);
Some(slot)
}
/// A slot for `key`: a free one, else the least recently shown, returned
/// with the image it held.
pub(super) fn claim(&mut self, key: K) -> (usize, Option<K>) {
/// with the image it held. `None` when every slot shows something this
/// frame: taking one would put this image on what was already drawn.
pub(super) fn claim(&mut self, key: K) -> Option<(usize, Option<K>)> {
let (slot, evicted) = if self.held.len() < self.count {
(self.held.len(), None)
} else {
let (old, slot) = self.held.remove(0);
} else if self.held[0].2 != self.frame {
let (old, slot, _) = self.held.remove(0);
(slot, Some(old))
} else {
return None;
};
self.held.push((key, slot));
(slot, evicted)
self.held.push((key, slot, self.frame));
Some((slot, evicted))
}
/// The atlas was replaced: every slot is blank.
pub(super) fn forget(&mut self) {
@@ -52,13 +63,28 @@ mod tests {
#[test]
fn the_least_recently_shown_image_gives_up_its_slot() {
let mut slots = AtlasSlots::new(2);
assert_eq!(slots.claim("a"), (0, None));
assert_eq!(slots.claim("b"), (1, None));
assert_eq!(slots.claim("a"), Some((0, None)));
assert_eq!(slots.claim("b"), Some((1, None)));
slots.next_frame();
assert_eq!(slots.show(&"a"), Some(0));
assert_eq!(slots.claim("c"), (1, Some("b")));
assert_eq!(slots.claim("c"), Some((1, Some("b"))));
assert_eq!(slots.show(&"b"), None);
}
#[test]
fn an_image_shown_this_frame_keeps_its_slot() {
// More maps in view than slots: the ones past the slots go undrawn
// rather than taking a slot another map was drawn with, both showing
// the later picture.
let mut slots = AtlasSlots::new(2);
assert_eq!(slots.claim("a"), Some((0, None)));
assert_eq!(slots.claim("b"), Some((1, None)));
assert_eq!(slots.claim("c"), None);
slots.next_frame();
assert_eq!(slots.show(&"b"), Some(1));
assert_eq!(slots.claim("c"), Some((0, Some("a"))));
}
#[test]
fn a_new_atlas_shows_no_stored_skin() {
// #3385: a skin stored before a pack reload must upload again, not
@@ -68,6 +94,6 @@ mod tests {
assert_eq!(slots.show("skins/abc"), Some(0));
slots.forget();
assert_eq!(slots.show("skins/abc"), None);
assert_eq!(slots.claim("skins/abc".to_string()), (0, None));
assert_eq!(slots.claim("skins/abc".to_string()), Some((0, None)));
}
}
+49 -7
View File
@@ -287,15 +287,26 @@ impl Game<'_> {
return;
};
let range = f64::from(b.levels * 10 + 10);
let Some(pp) = self.player.as_ref().map(|p| p.position) else {
let Some((pp, pl)) = self.player.as_ref().map(|p| (p.position, p.dimensions)) else {
return;
};
let inside = pp.x >= f64::from(p[0]) - range
&& pp.x <= f64::from(p[0] + 1) + range
&& pp.z >= f64::from(p[2]) - range
&& pp.z <= f64::from(p[2] + 1) + range
&& pp.y >= f64::from(p[1]) - range;
if !inside {
let height = f64::from(self.world.top_y() - self.world.bottom_y());
let bounds = pl.bounds(pp);
if !effect_box_hits(
p,
range,
height,
[
bounds.min_position().x,
bounds.min_position().y,
bounds.min_position().z,
],
[
bounds.max_position().x,
bounds.max_position().y,
bounds.max_position().z,
],
) {
return;
}
let secondary = b.secondary.as_deref().and_then(effect_id);
@@ -682,6 +693,17 @@ fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
_ => None,
}
}
/// `BeaconBlockEntity.applyEffects` box: `AABB(pos).inflate(range).expandTowards(0, height, 0)`,
/// tested with `AABB.intersects` (strict) against the player's bounding box.
fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64; 3]) -> bool {
let [x, y, z] = p.map(f64::from);
lo[0] < x + 1.0 + range
&& hi[0] > x - range
&& lo[1] < y + 1.0 + range + height
&& hi[1] > y - range
&& lo[2] < z + 1.0 + range
&& hi[2] > z - range
}
#[cfg(test)]
mod tests {
use super::*;
@@ -696,4 +718,24 @@ mod tests {
Some(&[(0xFFFFFF, 10)][..])
);
}
#[test]
fn effect_box_uses_player_overlap() {
let hit = |x: f64, y: f64| {
effect_box_hits(
[0, 100, 0],
50.0,
384.0,
[x - 0.3, y, -0.3],
[x + 0.3, y + 1.8, 0.3],
)
};
assert!(hit(-50.2, 100.0));
assert!(!hit(-50.4, 100.0));
assert!(hit(51.2, 100.0));
assert!(!hit(51.4, 100.0));
assert!(hit(0.0, 48.3));
assert!(!hit(0.0, 48.1));
assert!(hit(0.0, 534.9));
assert!(!hit(0.0, 535.0));
}
}
+3 -127
View File
@@ -7,6 +7,7 @@
//! a broken bed, or leaving the game.
use super::{Dim, Game};
use horizon_client::Result;
use horizon_client::bed_geometry::{SleepOn, pos_to_bed, shape_top, slept_on};
use horizon_core::geometry::Vec3;
use horizon_world::registry::BlockStateId;
@@ -73,8 +74,8 @@ impl Game<'_> {
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
let bed = self.world.block(head[0], head[1], head[2]);
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
if dim == Dim::Overworld
&& straw
if straw
&& self.dimension_type_of(dim).straw_bed_rule.destroy_on_leave
&& let Err(e) = self.destroy_straw_bed(head)
{
log::warn!("could not break the straw bed at {head:?}: {e}");
@@ -108,57 +109,6 @@ impl Game<'_> {
}
}
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
/// answers with the shape of the block it is given, and `StrawBedBlock`
/// first walks a head over to its foot, so a straw bed is slept on at its
/// foot's shape while the head's own (the pillow's) is taller.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SleepOn {
/// The head itself: `BedBlock` and every other bed.
Head,
/// The foot the straw bed's head lies against.
Foot,
}
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
/// head takes its foot's block, and only a foot of the same block will do:
/// with the foot broken, or replaced by something else, the height is empty
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
/// so nobody lies in the half that is left.
fn slept_on(bed: &str, part: Option<&str>, foot: Option<(&str, Option<&str>)>) -> Option<SleepOn> {
if bed != "minecraft:straw_bed" || part != Some("head") {
return Some(SleepOn::Head);
}
match foot {
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
_ => None,
}
}
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
boxes
.iter()
.map(|b| b[4] - base_y)
.fold(None, |top: Option<f64>, y| {
Some(top.map_or(y, |top| top.max(y)))
})
}
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
/// above the top of the shape, which `getSleepHeight` measured from that
/// block's own y.
fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + height + 0.125,
f64::from(head[2]) + 0.5,
)
}
impl Game<'_> {
/// The bed's `part`: head or foot.
fn bed_part(&self, state: BlockStateId) -> Option<String> {
@@ -198,77 +148,3 @@ impl Game<'_> {
.map(|height| pos_to_bed(head, height))
}
}
#[cfg(test)]
mod tests {
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
/// foot, and one whose foot is gone - or is not a straw bed's foot any
/// more - has no height, so `startSleeping` gives up on it.
#[test]
fn a_straw_beds_head_is_slept_on_at_its_foot() {
let straw = "minecraft:straw_bed";
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
Some(SleepOn::Foot)
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("head")))),
None,
"a head facing another head"
);
assert_eq!(
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
None,
"the foot was replaced"
);
assert_eq!(
slept_on(
straw,
Some("head"),
Some(("minecraft:red_bed", Some("foot")))
),
None,
"the foot is another bed"
);
assert_eq!(
slept_on(straw, Some("foot"), None),
Some(SleepOn::Head),
"a straw bed's foot sleeps on its own shape"
);
assert_eq!(
slept_on("minecraft:red_bed", Some("head"), None),
Some(SleepOn::Head),
"an ordinary bed's head is its own shape"
);
}
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
/// the block's own y; a shape with nothing in it has no height.
#[test]
fn the_height_is_the_top_of_the_shape() {
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
let two = [
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
];
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
}
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
/// was given) had them at 70.6875.
#[test]
fn the_sleeper_lies_an_eighth_above_the_shape() {
let at = pos_to_bed([10, 70, 5], 0.4375);
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
}
}
+36 -10
View File
@@ -58,6 +58,8 @@ impl Game<'_> {
self.edit_height(h, x, z)
})?;
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
self.ensure_stateful_entity_table();
let stateful = self.world.block_entities.stateful_entities();
let registry = self.pack.assets.registry;
// (the tags as `block_matches` reads them for commands)
let world_items = match &self.world_tables {
@@ -81,6 +83,7 @@ impl Game<'_> {
let f = facts.get(BlockStateId(s));
watched.get(s as usize).copied().unwrap_or(false)
|| f.block_entity
|| stateful.get(s as usize).copied().unwrap_or(false)
|| BlockEntity::for_block(&f.name).is_some()
},
);
@@ -88,7 +91,7 @@ impl Game<'_> {
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
// included), in the order `replace_block_entities` takes them: the positions needing a fresh entity.
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
let wanted: Vec<Arc<str>> = out
let wanted: Vec<BlockStateId> = out
.tracked
.iter()
.filter(|&&((x, y, z), old, new)| {
@@ -99,9 +102,11 @@ impl Game<'_> {
.get(&(x, y, z))
.is_some_and(|e| e.fits(&name(new)))
})
.map(|&(_, _, new)| name(new))
.map(|&(_, _, new)| BlockStateId(new))
.collect();
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
let mut made =
make_block_entities(&wanted, |state| self.new_block_entity_for_state(*state))
.into_iter();
let facts = self.res.facts();
let removed = replace_block_entities(
&mut self.world,
@@ -384,15 +389,15 @@ fn resolve_runs(
/// A block entity for each new block, in order: made for each position, as vanilla calls
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
/// asked once.
fn make_block_entities(
names: &[Arc<str>],
mut make: impl FnMut(&str) -> Option<BlockEntity>,
fn make_block_entities<S: Eq + std::hash::Hash>(
states: &[S],
mut make: impl FnMut(&S) -> Option<BlockEntity>,
) -> Vec<Option<BlockEntity>> {
let mut none: FxHashSet<&str> = FxHashSet::default();
names
let mut none: FxHashSet<&S> = FxHashSet::default();
states
.iter()
.map(|n| {
if none.contains(&**n) {
if none.contains(n) {
return None;
}
let entity = make(n);
@@ -767,7 +772,7 @@ mod tests {
.map(Arc::<str>::from),
|n| {
calls.push(n.to_string());
(n == "machines:press").then(|| {
(n.as_ref() == "machines:press").then(|| {
BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntityFields {
id: format!("press-{}", calls.len()),
@@ -799,4 +804,25 @@ mod tests {
["machines:press", "minecraft:stone", "machines:press"]
);
}
#[test]
fn null_factories_are_cached_per_state_not_per_block_name() {
// One block can create no BE in one state and a BE in another.
// A cached null for the first state must not suppress the second.
let mut calls = Vec::new();
let made = make_block_entities(&[7_u32, 8, 7, 8], |state| {
calls.push(*state);
(*state == 8).then(|| {
BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntityFields {
id: "machines:heater".into(),
..Default::default()
}
.into(),
))
})
});
assert_eq!(calls, [7, 8, 8]);
assert!(made[0].is_none() && made[2].is_none());
assert!(made[1].is_some() && made[3].is_some());
}
}
@@ -21,6 +21,7 @@ impl Game<'_> {
self.tick_craft_table();
self.tick_container_reach();
self.load_entity_types();
self.ensure_stateful_entity_table();
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
@@ -206,7 +206,13 @@ impl Game<'_> {
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
// sherds only; the pot's item comes with include_data.
let pot = &*facts.name == "minecraft:decorated_pot";
if (include_data || banner || pot)
// CopperGolemStatueBlockEntity.getItem: collectComponents (the golem's
// custom name), data or not.
let statue = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::CopperGolemStatue(_))
);
if (include_data || banner || pot || statue)
&& let Some(saved) = self.saved_block_entity(key)
{
let mut picked = block_entity_item::pick(&saved);
+3 -2
View File
@@ -50,10 +50,11 @@ impl Game<'_> {
format!("{}[{}]", s.name, props.join(","))
}
fn put_block(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
let name = self.res.facts().get(state).name.clone();
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
self.level_set(pos, state)?;
if let Some(entity) = self.new_block_entity(&name) {
if !self.world.block_entities.stateful_entity(state.0)
&& let Some(entity) = self.new_block_entity_for_state(state)
{
self.world
.block_entities
.insert((pos[0], pos[1], pos[2]), entity);
+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,
);
}
+131 -4
View File
@@ -9,7 +9,7 @@ use horizon_client::{
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
SYSTEM_INDICATOR, normalize_chat_message,
},
command::Registries,
command::{ArgType, Dispatcher, MOD_COMMAND, Registries},
gamerules::GameRules,
hud::HudBatch,
screen_draw::Measure,
@@ -55,6 +55,91 @@ pub(super) fn with_native_commands(
)
}
/// Add Studio's local command grammar to the suggestion tree. Commands are
/// still dispatched by `studio_command`; this tree supplies coloring,
/// completion, and Tab acceptance in chat.
fn with_studio_commands(base: &Dispatcher) -> Dispatcher {
fn literal(d: &mut Dispatcher, parent: usize, name: &str) -> usize {
let node = d.literal(parent, name);
d.executes(node, MOD_COMMAND);
node
}
fn argument(d: &mut Dispatcher, parent: usize, name: &str, ty: ArgType) -> usize {
let node = d.argument(parent, name, ty);
d.executes(node, MOD_COMMAND);
node
}
let mut d = base.clone();
let root = d.find("studio").unwrap_or_else(|| d.literal(0, "studio"));
d.executes(root, MOD_COMMAND);
let record = literal(&mut d, root, "record");
let start = literal(&mut d, record, "start");
argument(&mut d, start, "name", ArgType::Word);
literal(&mut d, record, "stop");
literal(&mut d, record, "status");
let replay = literal(&mut d, root, "replay");
let open = literal(&mut d, replay, "open");
argument(&mut d, open, "path", ArgType::Greedy);
literal(&mut d, replay, "pause");
literal(&mut d, replay, "resume");
let speed = literal(&mut d, replay, "speed");
argument(
&mut d,
speed,
"multiplier",
ArgType::Double {
min: 0.01,
max: 20.0,
},
);
let seek = literal(&mut d, replay, "seek");
argument(
&mut d,
seek,
"milliseconds",
ArgType::Integer {
min: 0,
max: i32::MAX,
},
);
literal(&mut d, replay, "status");
literal(&mut d, replay, "close");
let freecam = literal(&mut d, root, "freecam");
argument(
&mut d,
freecam,
"state",
ArgType::StrEnum(&["on", "off"], "argument.enum.invalid"),
);
let keyframe = literal(&mut d, root, "keyframe");
literal(&mut d, keyframe, "add");
let remove = literal(&mut d, keyframe, "remove");
argument(
&mut d,
remove,
"index",
ArgType::Integer {
min: 0,
max: i32::MAX,
},
);
literal(&mut d, keyframe, "clear");
let path = literal(&mut d, root, "path");
argument(
&mut d,
path,
"state",
ArgType::StrEnum(&["lock", "unlock"], "argument.enum.invalid"),
);
d
}
impl Game<'_> {
/// The tree the chat box colours, completes and parses with for the
/// world's connection: `base`, the tree the server sent, or the game's own
@@ -66,12 +151,11 @@ impl Game<'_> {
) -> horizon_client::command::Dispatcher {
let mut names = Vec::new();
self.native_commands(&mut names);
names.push("studio");
let base = match base {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
};
with_native_commands(base, &names)
with_studio_commands(&with_native_commands(base, &names))
}
}
@@ -1799,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
@@ -2144,6 +2230,43 @@ mod tests {
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
}
#[test]
fn studio_command_tree_suggests_subcommands_and_arguments() {
let reg = Registries::default();
let base = horizon_client::command::vanilla::game_dispatcher();
let tree = with_studio_commands(base);
let suggestions = |input: &str| {
let parse = tree.parse(input, &reg);
tree.client_suggestions(&parse, input.len(), &reg)
.1
.into_iter()
.map(|suggestion| suggestion.text)
.collect::<Vec<_>>()
};
assert_eq!(
suggestions("studio "),
["freecam", "keyframe", "path", "record", "replay"]
);
assert_eq!(suggestions("studio record "), ["start", "status", "stop"]);
assert_eq!(
suggestions("studio replay "),
[
"close", "open", "pause", "resume", "seek", "speed", "status"
]
);
assert_eq!(suggestions("studio freecam "), ["off", "on"]);
assert_eq!(suggestions("studio keyframe "), ["add", "clear", "remove"]);
assert_eq!(suggestions("studio path "), ["lock", "unlock"]);
assert_eq!(
tree.parse("studio replay open some-recording.mcpr", &reg)
.executable()
.unwrap()
.1,
MOD_COMMAND
);
}
/// When `entity_types` is empty (no `entity-types.json`), fallback includes
/// standard mobs, non-mob entities, and `wind_charge` / `breeze_wind_charge`.
#[test]
@@ -2399,7 +2522,11 @@ mod enchantment_snapshot_tests {
let pack = dir.join("datapacks/model");
let tags = pack.join("data/h024/tags/enchantment");
std::fs::create_dir_all(&tags).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"fixture"}}"#,
)
.unwrap();
std::fs::write(tags.join("empty.json"), r#"{"values":[]}"#).unwrap();
std::fs::write(
dir.join("datapacks.json"),
@@ -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)]);
}
@@ -166,7 +166,7 @@ impl Game<'_> {
// its hub record, which leave_guest would otherwise overwrite.
self.in_owner_level(|g| {
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
g.start_server_trip(&mut host, conn, plan.dim, Some(plan), false);
g.lan = Some(host);
Ok(())
})
@@ -271,9 +271,19 @@ impl Game<'_> {
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
self.harness_owner(|g| {
let previous = g.command_source;
if op["source"].as_str() == Some("console") {
// Actual server source; permission-only `as_console`
// otherwise keeps the connected owner's entity.
g.command_source = Some(super::commands::execute::Source {
silent: false,
..g.server_source()
});
}
g.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
g.command_source = previous;
Ok(())
})?;
let messages = self.chat_capture.take().unwrap_or_default();
@@ -670,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
+429 -19
View File
@@ -68,6 +68,8 @@ pub(super) enum Target {
Dragon(u32),
/// A primed TNT, by its entity id (`minecraft:tnt`).
Tnt(u32),
/// A cushion, by its entity id (`minecraft:cushion`).
Cushion(u32),
/// A falling block, by its entity id (`minecraft:falling_block`).
Falling(u32),
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
@@ -164,6 +166,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Cushion(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
@@ -565,6 +568,11 @@ impl Game<'_> {
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
@@ -639,6 +647,7 @@ impl Game<'_> {
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Cushion(_) => "minecraft:cushion".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
Target::Rocket(_) => "minecraft:firework_rocket".into(),
Target::Trident(_) => "minecraft:trident".into(),
@@ -915,6 +924,7 @@ impl Game<'_> {
});
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.cushions.iter().map(|c| Target::Cushion(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
candidates.extend(self.fangs.list.iter().map(|f| Target::Fangs(f.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
@@ -963,6 +973,7 @@ impl Game<'_> {
Target::Crystal(id),
Target::Dragon(id),
Target::Tnt(id),
Target::Cushion(id),
Target::Falling(id),
Target::Rocket(id),
Target::Trident(id),
@@ -992,6 +1003,7 @@ impl Game<'_> {
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
Target::Cushion(id) => self.cushions.iter().any(|c| c.id == id),
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
Target::Trident(id) => self.tridents.iter().any(|t| t.id == id),
@@ -1724,6 +1736,16 @@ impl Game<'_> {
}
}
}
Target::Cushion(id) => {
// BlockAttachedEntity.setPos: the block it rests on follows the position.
if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
c.position = v;
c.pos = [v.x.floor() as i32, v.y.floor() as i32, v.z.floor() as i32];
if let Some(r) = rotation {
c.yaw = r[0];
}
}
}
Target::Falling(id) => {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
f.position = v;
@@ -2061,12 +2083,9 @@ impl Game<'_> {
// `time of <clock>` names the clock; otherwise the dimension's default clock.
let clock = match ctx.get("clock") {
Some(Arg::Resource(c)) => c.clone(),
_ => match self.dim {
Dim::Overworld => "minecraft:overworld".to_owned(),
Dim::End => "minecraft:the_end".to_owned(),
Dim::Nether => {
return Err(err("commands.time.no_default_clock", &[&self.dim.id()]));
}
_ => match self.dimension_type().default_clock.clone() {
Some(clock) => clock,
None => return Err(err("commands.time.no_default_clock", &[&self.dim.id()])),
},
};
let total = self.command_clock_time(&clock);
@@ -2361,18 +2380,23 @@ impl Game<'_> {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.spill_killed_vehicle_contents(b.position, &b.items);
self.boat_removed(&b);
}
}
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = self.carts.remove(i);
self.spill_killed_vehicle_contents(c.position, &c.items);
}
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Fangs(id) => self.fangs.list.retain(|f| f.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
Target::Cushion(id) => self.kill_cushion(id),
Target::Falling(id) => self.falling.retain(|f| f.id != id),
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
Target::Trident(id) => self.tridents.retain(|t| t.id != id),
@@ -2386,6 +2410,30 @@ impl Game<'_> {
}
self.native_leaves_flush();
}
/// `AbstractChestBoat.remove` / `AbstractMinecartContainer.remove` for
/// destroying removal (`Entity.kill`): `Containers.dropContents`, separate
/// from hurt-time hull drops and after the live menu writes back its slots.
fn spill_killed_vehicle_contents(&mut self, pos: Vec3, slots: &[Option<ItemStack>]) {
for stack in slots {
let count = stack.as_ref().map_or(0, |s| s.count);
for piece in horizon_world::level::removal::drop_item_stack_at(
&mut self.world.random,
pos.components(),
count,
) {
let Some(stack) = stack else { continue };
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", false)?
@@ -2590,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],
@@ -2641,6 +2704,7 @@ impl Game<'_> {
if previous == state {
return Ok(false);
}
self.ensure_stateful_entity_table();
// A different block replaces the block entity (Level.setBlock).
let (old, new) = (
self.res.facts().get(previous).name.clone(),
@@ -2673,9 +2737,10 @@ impl Game<'_> {
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
let needs_entity = !self.world.block_entities.stateful_entity(landed.0) && old != new;
if needs_entity
&& self.res.facts().get(landed).name == new
&& let Some(entity) = self.new_block_entity(&new)
&& let Some(entity) = self.new_block_entity_for_state(landed)
{
self.world.block_entities.insert(key, entity);
}
@@ -2781,7 +2846,15 @@ impl Game<'_> {
let lock_changed = self.world.block_lock(key) != tag.get("lock");
// So is the custom name.
let name_changed = self.load_block_name(key, &new, &tag);
if after == before && !rolled && !lock_changed && !name_changed {
let cooldown_changed = if name == "minecraft:hopper" {
let state = super::machines::HopperState::load(&tag);
let changed = self.hoppers.get(&key).map_or(-1, |s| s.cooldown) != state.cooldown;
self.hoppers.insert(key, state);
changed
} else {
false
};
if after == before && !rolled && !lock_changed && !name_changed && !cooldown_changed {
return false;
}
self.world.block_entities.insert(key, new);
@@ -2969,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 {
@@ -3058,7 +3136,7 @@ impl Game<'_> {
continue;
}
if let Some(f) = &filter
&& !self.block_matches(f, s)
&& !self.block_matches(f, [x, y, z])
{
continue;
}
@@ -3101,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", &[]));
}
@@ -4413,6 +4504,7 @@ mod look_at_tests {
Target::Crystal(7),
Target::Dragon(7),
Target::Tnt(7),
Target::Cushion(7),
Target::Falling(7),
Target::Rocket(7),
Target::Trident(7),
@@ -4683,6 +4775,205 @@ mod selector_bounds_tests {
}
}
#[cfg(test)]
mod local_coordinate_command_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_core::position::BlockPos;
use horizon_world::nbt::Tag;
fn pos_bits(tag: &Tag) -> [u64; 3] {
let Some(Tag::List(pos)) = tag.get("Pos") else {
panic!("actual saved Double position missing")
};
assert_eq!(pos.len(), 3);
std::array::from_fn(|i| match &pos[i] {
Tag::Double(bits) => *bits,
other => panic!("actual Pos must be Double: {other:?}"),
})
}
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn h149_actual_registered_local_commands_preserve_exact_saved_positions() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("scoreboard objectives add h149 dummy"));
let clock = game.world.game_time;
let mut failures = Vec::new();
let cases = [
(
"south",
"execute positioned 8.5 202.25 8.5 rotated 0.0 0.0 run tp 00000095-0000-0001-0000-000000000001 ^0.75 ^0.5 ^1.25",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
false,
),
(
"west",
"execute positioned 8.5 202.25 8.5 rotated 90.0 0.0 run tp 00000095-0000-0001-0000-000000000002 ^0.75 ^0.5 ^1.25",
[
0x401d000000000000u64,
0x4069580000000000u64,
0x4022800000000000u64,
],
false,
),
(
"north",
"execute positioned 8.5 202.25 8.5 rotated 180.0 0.0 run tp 00000095-0000-0001-0000-000000000003 ^0.75 ^0.5 ^1.25",
[
0x401f000000000000u64,
0x4069580000000000u64,
0x401d000000000000u64,
],
false,
),
(
"east",
"execute positioned 8.5 202.25 8.5 rotated 270.0 0.0 run tp 00000095-0000-0001-0000-000000000004 ^0.75 ^0.5 ^1.25",
[
0x4023800000000000u64,
0x4069580000000000u64,
0x401f000000000000u64,
],
false,
),
(
"oblique",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000005 ^0.1 ^0.1 ^0.1",
[
0x402117934a6314b9u64,
0x40694c2c5ee98f58u64,
0x40213562572e889au64,
],
false,
),
(
"positioned_oblique",
"execute positioned 8.5 202.25 8.5 rotated 123.75 47.125 positioned ^0.1 ^0.1 ^0.1 run tp 00000095-0000-0001-0000-000000000006 ~ ~ ~",
[
0x4020a7648c7aa86bu64,
0x406947d514129374u64,
0x4021025e8b180036u64,
],
false,
),
(
"world",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000007 9.25 202.75 9.75",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
true,
),
(
"relative_eyes",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 anchored eyes run tp 00000095-0000-0001-0000-000000000008 ~0.75 ~0.5 ~1.25",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
true,
),
(
"feet",
"execute as 00000095-0000-0001-0000-000000000009 at @s anchored feet rotated 37.5 -22.25 run tp @s ^ ^ ^",
[
0x4012000000000000u64,
0x4069000000000000u64,
0x4012000000000000u64,
],
true,
),
(
"eyes",
"execute as 00000095-0000-0001-0000-00000000000a at @s anchored eyes rotated 37.5 -22.25 run tp @s ^ ^ ^",
[
0x4012000000000000u64,
0x4069187ae1400000u64,
0x4012000000000000u64,
],
true,
),
];
for (i, (name, command, want, control)) in cases.into_iter().enumerate() {
let number = i as i32 + 1;
let actor = format!("00000095-0000-0001-0000-{number:012x}");
let uuid = [149, 1, 0, number];
assert!(game.run_command(&format!(r#"summon pig 4.5 200 4.5 {{UUID:[I;149,1,0,{number}],Tags:["h149"],Health:10f,NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Motion:[0d,0d,0d],Rotation:[11f,7f]}}"#)));
let id = *game
.persist
.uuids
.iter()
.find(|(_, u)| **u == uuid)
.unwrap()
.0;
assert!(game.mobs.iter().any(|m| m.id == id));
assert!(game.run_command(&format!("execute if entity @e[type=pig,nbt={{UUID:[I;149,1,0,{number}],Health:10f,NoAI:1b,NoGravity:1b,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],Rotation:[11f,7f]}}]")));
assert!(game.world.loaded(4, 4));
let before = game.entity_nbt(Target::Mob(id)).unwrap();
assert_eq!(pos_bits(&before), [4.5f64, 200.0, 4.5].map(f64::to_bits));
assert_eq!(before.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(before.get("Health"), Some(&Tag::Float(10f32.to_bits())));
// Discard retained raw input: only actual modeled position may prove this.
game.persist.raw.remove(&id);
assert!(game.run_command("scoreboard players set #r h149 91"));
assert!(game.run_command("scoreboard players set #s h149 17"));
assert!(
game.run_command(&format!(
"execute store result score #r h149 store success score #s h149 run {command}"
)),
"actual command accepted: {name}"
);
assert_eq!(game.score.board.score("#r", "h149").unwrap().value, 1);
assert_eq!(game.score.board.score("#s", "h149").unwrap().value, 1);
let model = game.mobs.iter().find(|m| m.id == id).unwrap();
let model_bits =
[model.position.x, model.position.y, model.position.z].map(f64::to_bits);
let saved = game.entity_nbt(Target::Mob(id)).unwrap();
let got = pos_bits(&saved);
assert_eq!(
model_bits, got,
"actual model and typed canonical save agree: {name}"
);
assert_eq!(saved.get("UUID"), before.get("UUID"));
for key in ["Health", "NoAI", "NoGravity", "Rotation", "Motion"] {
assert_eq!(saved.get(key), before.get(key), "unchanged {key}: {name}");
}
assert!(game.run_command(&format!("execute as {actor} at @s if block ~ 199 ~ stone")));
assert!(game.world.loaded(
f64::from_bits(got[0]).floor() as i32,
f64::from_bits(got[2]).floor() as i32,
));
if control {
assert_eq!(
got, want,
"independent world/relative/anchor control {name}"
);
} else if got != want {
failures.push((name, got, want));
}
assert_eq!(game.world.game_time, clock, "zero observation ticks");
}
assert!(
failures.is_empty(),
"actual registered local-coordinate bit mismatches: {failures:#?}"
);
}
}
#[cfg(test)]
mod query_feedback_tests {
#[test]
@@ -4836,3 +5127,122 @@ mod forced_respawn_tests {
}
}
}
#[cfg(test)]
mod vehicle_kill_tests {
use super::*;
use crate::game::BlockEntity;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_world::nbt::parse_snbt;
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn kill_spills_explicit_vehicle_contents_after_menu_writeback() {
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.build_registries();
game.set_allow_commands(true);
let clock = game.world.game_time;
let mut differences = Vec::new();
for (index, kind) in [
"oak_chest_boat",
"bamboo_chest_raft",
"chest_minecart",
"hopper_minecart",
"oak_boat",
]
.iter()
.enumerate()
{
let uuid = [51, 0, 0, index as i32 + 1];
let source = r#"{Pos:[5.25d,200d,4.75d],NoGravity:1b,Items:[{Slot:0b,id:"minecraft:diamond",count:3,components:{"minecraft:custom_data":{h051:1}}},{Slot:1b,id:"minecraft:dirt",count:5},{Slot:2b,id:"minecraft:stick",count:7}]}"#.to_string();
let (tag, _) = parse_snbt(&source).unwrap();
let boat = kind.contains("boat") || kind.contains("raft");
let (id, mut slots) = if boat {
game.load_boat(tag, Some(uuid), &format!("minecraft:{kind}"));
let b = game.boats.last().unwrap();
assert_eq!(b.position, Vec3::new(5.25, 200.0, 4.75));
(b.id, b.items.clone())
} else {
game.load_cart(tag, Some(uuid), &format!("minecraft:{kind}"));
let c = game.carts.last().unwrap();
(c.id, c.items.clone())
};
let container = index < 4;
assert_eq!(
slots.iter().flatten().map(|s| s.count).sum::<u32>(),
if container { 15 } else { 0 }
);
let target = if boat {
Target::Boat(id)
} else {
Target::Cart(id)
};
assert_eq!(
game.entity_nbt(target).unwrap().get("UUID"),
Some(&horizon_world::nbt::Tag::IntArray(uuid.to_vec()))
);
// The active menu's actual edited slot must win over the older model.
let key = (i32::MAX - 15, crate::game::minecarts::CART_KEY_Y, id as i32);
if container {
slots[0].as_mut().unwrap().count = 4;
game.world
.block_entities
.insert(key, BlockEntity::Chest(slots.clone()));
game.container = Some(key);
}
let old_len = game.dropped.len();
let actor = format!("00000033-0000-0000-0000-{:012x}", index + 1);
assert!(
game.run_command(&format!("kill {actor}")),
"{kind}: actual command admitted"
);
assert!(!game.boats.iter().any(|b| b.id == id));
assert!(!game.carts.iter().any(|c| c.id == id));
assert!(!game.world.block_entities.contains_key(&key));
assert_ne!(game.container, Some(key));
let dropped = &game.dropped[old_len..];
let expected: Vec<_> = slots.into_iter().flatten().collect();
if dropped.len() != expected.len()
|| expected
.iter()
.any(|s| !dropped.iter().any(|d| &d.stack == s))
{
differences.push(format!(
"{kind}: expected {expected:?}, actual {:?}",
dropped.iter().map(|d| &d.stack).collect::<Vec<_>>()
));
}
}
assert_eq!(game.world.game_time, clock);
assert!(
differences.is_empty(),
"kill must spill authoritative explicit contents: {differences:#?}"
);
}
}
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff. Show more