Compare commits

...
Author SHA1 Message Date
Ludovic Marie ebcfa2263a Merge pull request #11637 from horizon-rust/fix/lightning-fire-radius-11376
🤖 Agent: lightning now places fire (and draws its extra-source offsets) only when canSpreadFireAround passes at the bolt, so fire_spread_radius_around_player holds for strikes far from every player.
2026-11-07 07:40:30 +01:00
Ludovic Marie 7127fb8ed1 Merge pull request #10065 from ilguerro/fix/thorns-victim
🤖 Agent: merged main again (#10015's lan_e2e check landed beside this one's); the Thorns change itself is the head the mace harness passed on.
2026-11-07 07:19:55 +01:00
Horizon agent 9d35a0c0ad Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 07:18:08 +01:00
Ludovic Marie b6aa962829 Merge pull request #10035 from IsT3RiK/traduction-messages-mods
🤖 Agent: the Mods page's install and port messages go through tr/trf with named arguments, so they translate; lang files merged key by key onto main, i18n tests pass.
2026-11-07 07:05:28 +01:00
Ludovic Marie c1102d9227 Merge pull request #11628 from horizon-rust/fix/absorption-instant-shift-11386
🤖 Agent: a mob's Absorption now refills on every accepted add (AbsorptionMobEffect.onEffectStarted), and heal/harm and effect intervals shift like Java's int << / >> (distance mod 32, wrapping) instead of clamping or panicking at amplifier 32+.
2026-11-07 06:58:08 +01:00
Horizon agent 057869bdad Merge remote-tracking branch 'origin/main' into n/10035
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-07 06:43:57 +01:00
Ludovic Marie 84ec8516c6 Merge pull request #11622 from horizon-rust/fix/frame-map-limit-11383
🤖 Agent: an empty frame now refuses a map tracking more than 256 counted markers (ItemFrame.interact's isTrackedCountOverLimit FAIL): the map stays held, no swing; exactly 256 still goes in.
2026-11-07 06:40:06 +01:00
Ludovic Marie 4500b9e72b Merge pull request #10015 from repositorykreml1n/codex/fix-issue-9377
🤖 Agent: /say from a command block reaches every player under the block's name, and a captured guest command no longer echoes it back as feedback (kacpherek's point); lan_e2e command-block-chat not run here.
2026-11-07 06:35:42 +01:00
Horizon agentandClaude Opus 5.5 2710eae7cf Start a bolt's fire only near a player
LightningBolt.spawnFire places fire, and draws its extra sources, only when
ServerLevel.canSpreadFireAround passes at the bolt: the
fire_spread_radius_around_player rule now holds for lightning (#11376).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:30:22 +01:00
Ludovic Marie ac40181853 Merge pull request #11613 from horizon-rust/fix/omen-added-sound-11373
🤖 Agent: the omen sound now plays from the player's addEffect for any newly added omen (command, potion, mod) at the feet, as MobEffect.onEffectAdded does; the three special paths moved from the eye to the feet too.
2026-11-07 06:29:33 +01:00
Ludovic Marie 2a4a9a4616 Merge pull request #11601 from horizon-rust/fix/tnt-entity-collisions
🤖 Agent: TNT, falling blocks and XP orbs now collide with boats, shulkers and a held happy ghast (Entity.collide's canBeCollidedWith); sand landing on a boat over air breaks into an item like vanilla's wouldContinueFalling.
2026-11-07 06:29:29 +01:00
Horizon agent bec6e40518 Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:26:44 +01:00
Horizon agent 702adabb25 Merge remote-tracking branch 'origin/main' into n/10015
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:24:27 +01:00
Ludovic Marie eb2d90fb10 Merge pull request #10040 from NikitaMGrimm/shader-api-02-serve
🤖 Agent: 0.2 shader packs get the float HDR frame only under HORIZON_HDR_FRAME=float; the default and 0.1 paths keep their formats (frame_format tests cover both), so the vanilla look is unchanged.
2026-11-07 06:18:07 +01:00
Ludovic Marie 5a83184c7e Merge pull request #10170 from TheMrZZ/fix/furnace-loaded-cooking-total
🤖 Agent: a loaded furnace keeps its saved cooking total until the item finishes, and a hopper or dropper filling the empty input slot restarts the cooking as setItem does (kacpherek's point); furnace_loaded_total and furnace_output_components pass.
2026-11-07 06:15:34 +01:00
Ludovic Marie 1082a55b0f Merge pull request #10013 from MSedgeMC/feat/chat-draft-italic
🤖 Agent: a restored chat draft draws grey italic until edited, as ChatScreen's isDraft does; text a mod opens chat with is not a draft. Merge with main builds and the chat tests pass.
2026-11-07 06:15:30 +01:00
Horizon agentandClaude Opus 5.5 aacb969efa Refuse a framed map that tracks more than 256 markers
ItemFrame.interact returns FAIL when MapItemSavedData.isTrackedCountOverLimit(256):
the map stays in the hand and the frame stays empty (part of #11383).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:15:02 +01:00
Horizon agentandClaude Opus 5.5 143f965f6f native_open_chat: mod text is not a draft (open_chat takes is_draft)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:12:51 +01:00
Ludovic Marie 318e263959 Merge pull request #10119 from DUzzL/fix/10049-frost-walker-equip
🤖 Agent: Frost Walker's speed modifier now follows a boots change made by a guest too (the guest's armor is swapped in before the comparison); armor_changes copies only changed slots. armor_stand_frost passes on a union with main.
2026-11-07 06:12:42 +01:00
Horizon agent 955cd0b289 Merge remote-tracking branch 'origin/main' into n/10013 2026-11-07 05:59:01 +01:00
Ludovic Marie 29082ca5d1 Merge pull request #10009 from DUzzL/fix/9959-spectator-portals
🤖 Agent: a spectator using a portal block travels to its destination, never builds a Nether exit, and gets spectator.cannot_teleport without one (ServerPlayerGameMode.useItemOn); nether_portal harness passes on a union with main.
2026-11-07 05:55:35 +01:00
Ludovic Marie 3b6d6b542c Merge pull request #9995 from DUzzL/fix/9963-projectile-absorbed
🤖 Agent: damage a mob's absorption takes is credited to the thrower (guest throws settle inside as_guest, kacpherek's point); mace and trident harnesses pass on a union with main.
2026-11-07 05:55:32 +01:00
Ludovic Marie 7a2f824043 Merge pull request #10701 from Detimes/perf-reuse-packet-deflater
🤖 Agent: region saves reuse one deflater per writer instead of building one per chunk; the test checks the compressed bytes are identical to a fresh deflater's. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green; verify green after the runner's full-disk rerun.
2026-11-07 05:46:01 +01:00
Ludovic Marie cca49a3f34 Merge pull request #11606 from horizon-rust/fix/boat-controller-11541
🤖 Agent: the second rider's PlayerInput/MoveVehicle is now ignored like vanilla's getControllingPassenger check; riders are listed in boarding order so the first one in keeps the tiller.
2026-11-07 05:43:37 +01:00
Ludovic Marie b03633436a Merge pull request #11597 from horizon-rust/fix/swing-echo-9887
🤖 Agent: only SUCCESS_SERVER block uses (beds, light, a charged anchor, signs) swing back to their own player; item and entity uses still echo, as the body says. use_item_on, flat_place and dedicated_server pass locally.
2026-11-07 05:39:22 +01:00
Ludovic Marie f6a188d8cd Merge pull request #11586 from horizon-rust/fix/piston-push-order-11399
🤖 Agent: each moving block's collision view now advances right after its own push, so later pushes in the same tick see it at its new progress; the cost is one lookup per moving piston. piston_push and piston_movement pass locally.
2026-11-07 05:39:19 +01:00
Horizon agentandClaude Opus 5.5 93d50e0318 Refill a mob's Absorption on every add; shift heal and harm like Java
LivingEntity.addEffect runs onEffectStarted for every effect it accepts,
so Absorption given again refills a mob's shield to 4 per level of the
effect given. Horizon refilled it only for a new or stronger effect.
HealOrHarmMobEffect and the periodic effects use Java's shifts, which take
the distance modulo 32 and wrap: amplifier 32 is amplifier 0 again, and a
ticked heal is at least 0. Horizon clamped the distance to 30, and an
amplifier of 32 or more panicked on Regeneration's interval in a debug
build (#11386).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:36:50 +01:00
Ludovic Marie 507e0ae6f4 Merge pull request #10180 from lightcodegit/fix/brush-horn-use-vibration
🤖 Agent: horn and brush uses end with stopUsingItem (the interaction vibration), and a mob in front of the brushed block stops the brush out to the block reach; item_use_catalog harness passes on a union with main.
2026-11-07 05:16:14 +01:00
Horizon agentandClaude Opus 5.5 1d0b591927 Play an omen's sound when it is added to an entity
MobEffect.onEffectAdded plays Bad Omen's, Trial Omen's and Raid Omen's
sound (withSoundOnAdded) whenever LivingEntity.addEffect adds one that
is new, at the entity's feet. Horizon played them only on the raid, bottle
and trial spawner paths, at the eye, so /effect give, a potion's custom
contents or a mod adding one was silent (#11373). The generic add now
plays it for players and, from /effect, for mobs; the three special paths
play at the feet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:16:06 +01:00
Ludovic Marie 348e296ffa Merge pull request #10039 from MSedgeMC/fix/hud-text-height
🤖 Agent: the item name and the action bar take fixed heights above the hotbar (Hud.extractSelectedItemName 59, extractOverlayMessage 68) instead of following the bars; hud harness passes on a union with main.
2026-11-07 05:15:29 +01:00
Ludovic Marie 28f081a242 Merge pull request #10118 from DUzzL/fix/10050-breeze-potion-deflect
🤖 Agent: a breeze turns a thrown potion back at half speed (Breeze.deflection) once in a row, stopping at the hit point that tick; the REVERSE draw uses the potion's own random.
2026-11-07 05:14:31 +01:00
Ludovic Marie 47672aefc6 Merge pull request #10193 from MSedgeMC/fix/automatic-advancements
🤖 Agent: advancements with no criteria get their rewards on load (checkForAutomaticTriggers) but stay not done, as AdvancementRequirements.test answers false; checked on a union with main.
2026-11-07 05:13:38 +01:00
Ludovic Marie d81408ef0f Merge pull request #10124 from MSedgeMC/feat/stats-tab-keys
🤖 Agent: Ctrl+Tab and Ctrl+1..0 switch the statistics tabs from the raw key, as TabNavigationBar.keyPressed does; checked on a union with main.
2026-11-07 05:12:51 +01:00
Ludovic Marie 0ce9091c37 Merge pull request #10002 from some-du6e/optifine/sky-layers
🤖 Agent: the fade out ending at startFadeIn now runs to the end of the day (since()==0 read as DAY), which was kacpherek's review point; checked on a union with main (fmt, clippy -D warnings, tests).
2026-11-07 05:01:28 +01:00
Ludovic Marie 6c45dc0561 Merge pull request #11596 from horizon-rust/perf/terrain-hdr-override
🤖 Agent: the solid terrain pipelines are built with HDR_LIGHTING off and the lit copy only exists when hdr_output is Some, so HDR players switch per frame by self.hdr; frame-identity label makes CI check HDR off, offscreen HDR and HDR toggled off in a running game. −16 to −22% GPU on M4 Pro, image identical in-process.
2026-11-07 04:57:22 +01:00
Ludovic Marie 7775a14526 Merge pull request #11562 from horizon-rust/perf/11024-parallel-world-convert
🤖 Agent: merging. Empty region files (13 303 of 13 620 in the test world) are no longer rewritten and synced on world open, and region files convert on all cores but one with byte-identical output; datafix harness 6/6.
2026-11-07 04:40:56 +01:00
Horizon agentandClaude Opus 5.5 e3b3159901 Build solid terrain without HDR lighting when HDR output is off
terrain.wgsl's solid shaders carry HDR output's lighting behind a runtime
`hdr_light.z` test. The branch is never taken without HDR, yet on Apple GPUs its
code alone costs ~15% of the solid pass (register pressure). A pipeline
constant `HDR_LIGHTING` now guards it: the solid pipelines (per-section, pulled
and flat) are built without it, and a copy with it only where the display
offers HDR output, picked per frame by whether HDR is on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:35:44 +01:00
Ludovic Marie 77936bf4be Merge pull request #10196 from IanWorley/native-api/session-state
🤖 Agent: the session state a client HUD reads (world id, screen kind) comes from fields the window already keeps; merged with #10195 and checked with clippy, the native_client tests and the hook stamp test.
2026-11-07 04:33:57 +01:00
Horizon agent f47ac2cbad Merge remote-tracking branch 'origin/main' into conv-par 2026-11-07 04:24:38 +01:00
Horizon agentandClaude Opus 5.5 7f0778e4da Merge origin/main into #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:12:16 +01:00
Ludovic Marie b0a8cbf6fb Merge pull request #11579 from horizon-rust/mods/client-combat-hooks
🤖 Agent: client_hurt reports the packet's causing entity (the shooter, not the arrow) and treats an untracked entity as not a player, so a mod never gets a false PvP signal. Main merged in (test conflict with #11512's open-chat test, both kept); verify green on this head.
2026-11-07 04:12:11 +01:00
Horizon agentandClaude Opus 5.5 6bcbf52250 Merge origin/main into mods/client-combat-hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:59:52 +01:00
Horizon agentandClaude Opus 5.5 d2c44db65e Primed TNT, falling blocks and orbs land on boats and shulkers
PrimedTnt, FallingBlockEntity and ExperienceOrb move with Entity.move,
whose Entity.collide includes EntityGetter.getEntityCollisions: a boat,
a living shulker and a happy ghast held still stop them, as they stop a
dropped item. Horizon resolved their moves against blocks and the world
border only (#11518). The ordered tick gathers those boxes once per run
(again after an explosion) and passes the nearby ones to each move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:41 +01:00
Horizon agentandClaude Opus 5.5 2b1731508d Let only a boat's first rider drive it on a server
A boat's passengers went to the clients in the order the players joined
the server, so the player who got in second could hear it sits first and
take the boat over, and the server took MoveVehicle from either seat: with
two players in one boat it kept snapping back (#11541). The passengers now
go in the order they boarded, and handleMoveVehicle ignores a player who
isn't the vehicle's controlling passenger, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:07 +01:00
Ludovic Marie 026f014ff6 Merge pull request #11538 from horizon-rust/mods/chat-input-helpers
🤖 Agent: native_set_chat_input filters text to what typing allows (no control chars, DEL or §) and clamps the cursor, so a mod can't inject formatting codes into the chat box.
2026-11-07 03:50:58 +01:00
Ludovic Marie 2aa5716a6a Merge pull request #11528 from horizon-rust/fix/followup-10529
🤖 Agent: Launcher-only follow-up to #10529: the Home tab strip is flex_none on wide windows again (glass::tabbed keeps its notch at 1440 px) and only scrolls when compact; settings rows keep 40 px between name and control and wrap the control only when narrow. No new strings; fmt/clippy/tests green on the union of this wave's heads.
2026-11-07 03:50:53 +01:00
Ludovic Marie 3e96961f1e Merge pull request #11524 from horizon-rust/fix/cg66-followups
🤖 Agent: follow-ups to #11033/#11044/#11043/#11016 that missed the merges: death protection is one prototype lookup per hand per tick (was a full component map for every armed mob), checked against the component map for patched and stripped stacks. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:48 +01:00
Ludovic Marie ed38625015 Merge pull request #11036 from ilguerro/perf/9996-interp2
🤖 Agent: the interpolation change is checked bit for bit against the old formula over the test's whole range, so no position moves; one multiply fewer per entity per frame. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:43 +01:00
Ludovic Marie 31d0888da7 Merge pull request #10703 from Detimes/perf-region-inflate-in-place
🤖 Agent: inflates each region chunk in place instead of through a second buffer; the differential test decodes the same chunks with the old block decoder and compares every section. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:39 +01:00
Ludovic Marie a5ea46ce3e Merge pull request #11049 from DUzzL/fix/11024-parallel-world-upgrade
🤖 Agent: Optimize World now upgrades region files in parallel with one sync per file instead of one per chunk (F_FULLFSYNC on macOS was the cost); the 1-vs-4-thread test checks the counts against the fixture's own layout, and a canceled recreate leaves every file untouched. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:46:43 +01:00
Ludovic Marie 5088011241 Merge pull request #9566 from hildlabs/panel/glass-look
🤖 Agent: the server panel moves its dialogs into the page and follows the Glass look; launcher only, strings through tr().
2026-11-07 03:45:02 +01:00
Ludovic Marie 3be1aae4a1 Merge pull request #10099 from yahya-nusseibeh/fix/rsa-constant-time
🤖 Agent: private-key operations are now constant-time (no branch or table index on secret bits); no change to the key format or the public API.
2026-11-07 03:44:58 +01:00
Ludovic Marie 7f3bf11140 Merge pull request #10169 from NikitaMGrimm/shader-pack-identity-launcher
🤖 Agent: the launcher keeps each native shader's identity and options across the settings screen rewrite; its parent #10168 is merged, and the union with main passes clippy and tests.
2026-11-07 03:44:55 +01:00
Ludovic Marie afcfad914c Merge pull request #10195 from IanWorley/native-api/client-entities
🤖 Agent: client mods read entities and textures through the Host only, no new write path; #10196 conflicts with it in native_client_services.rs and gets main merged after this lands.
2026-11-07 03:44:51 +01:00
Ludovic Marie 6fefc220f9 Merge pull request #9968 from masa-masa-0314/feat/realms-invite-codes
🤖 Agent: the invite-code calls go through one frontend() helper with x-mojangauth, only to the Realms frontend URL; builds and tests clean on a union with current main.
2026-11-07 03:44:47 +01:00
Horizon agentandClaude Opus 5.5 fc8e14548a A pushed entity meets the other moving blocks at their progress then
PistonMovingBlockEntity.tick pushes entities from each block entity's own
tick, and getCollisionShape reads its progress: the moving blocks that
ticked before it have advanced, the ones after it have not. Horizon
pushed every entity against last tick's progress of all of them, so a
TNT duper's TNT was pushed through a block that was about to move
(#11399). Each piston's collision view now advances right after its own
push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:42:55 +01:00
Horizon agentandClaude Opus 5.5 ae8bfba8c6 Fill the swing test's Joined like main's, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:40:33 +01:00
Horizon agent 8703ad614c Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:39:58 +01:00
Ludovic Marie fce784f752 Merge pull request #11575 from horizon-rust/mods/screenshot-helper
🤖 Agent: native_take_screenshot refuses while another screenshot waits for the frame, so a mod's call can't overwrite F2's or the world icon's pending grab.
2026-11-07 03:37:48 +01:00
Ludovic Marie a5b752f7cc Merge pull request #11555 from horizon-rust/fix/sound-harness-runner-defaults
🤖 Agent: parity.py starts harnesses with no arguments; these two required --binary/--work-dir and failed parity-main in 0 s. On the desktop both now reach their servers (the Java sides ran); the full runs there timed out only because the desktop's swap was full.
2026-11-07 03:37:45 +01:00
Horizon agentandClaude Opus 5.5 9834143df1 Fix clippy: is_player needs no mut; keep player_voice_pitch's doc on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:34:42 +01:00
Ludovic Marie 4774d07dd5 Merge pull request #11513 from horizon-rust/fix/items-fall-through-minecarts-11091
🤖 Agent: removes only the minecart boxes from the item's collision list; boats, shulkers and a held happy ghast still hold items, as AbstractMinecart doesn't override canBeCollidedWith. entity_collision, held_drop, shulker_space, boat_item_hit pass locally.
2026-11-07 03:22:31 +01:00
Horizon agent b9693b5716 Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:20:04 +01:00
Ludovic Marie 1647c1b466 Merge pull request #11028 from Detimes/hooks-b-all
🤖 Agent: merged after #11019 and main (hook and doc blocks kept whole, quinn kept beside the new optional crates). Without a mod, PlayerSender::send pays one relaxed WATCHED load and sends the bytes as they were, and worldgen, LOD columns and redstone keep vanilla's values.
2026-11-07 03:19:45 +01:00
Horizon agentandClaude Opus 5.5 ac31802b58 Give #11019's guest test a Joined with its configuration
The zero-health test that #11019 added builds a Joined, which this branch
gave a configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:01:53 +01:00
Horizon agentandClaude Opus 5.5 c2adc89cc7 Keep the invocation test's binary inside its scratch folder
The harnesses resolve --binary, and /bin is a link to /usr/bin on
Ubuntu runners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:59:37 +01:00
Horizon agent e82a08ca4d Merge remote-tracking branch 'origin/main' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-mod-deps/Cargo.toml
#	docs/native-mods.md
2026-11-07 02:55:11 +01:00
Ludovic Marie 41944ed914 Merge pull request #11553 from horizon-rust/pvp-2/legacy-pvp-check
🤖 Agent: the check, not the 1.8 rules, was broken: the switch row sat half under the list's edge so the click missed it; verified 7/7 legacy, 9/3.6 modern on the Mac.
2026-11-07 02:49:45 +01:00
Ludovic Marie 87784fb3df Merge pull request #11543 from horizon-rust/pvp-2/bots-crits
🤖 Agent: the fight bots' swing carried a hand byte 26.3's swing packet doesn't have, so every server kicked them on the first hit; it now sends the same Punch as --attack.
2026-11-07 02:49:41 +01:00
Ludovic Marie 1b4a669936 Merge pull request #11019 from Detimes/hooks-all
🤖 Agent: twelve hook groups land together. Without a mod, the move, teleport and trample hooks are fn pointers over an empty mods() slice, so a player's move costs one indirect call and no allocation; a mod command's permission node falls back to its own level when no provider answers.
2026-11-07 02:49:38 +01:00
Horizon agent 230d414d62 Merge commit '97e3a74179cf3e6137908ea2c8ce36a489605a84' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan_actions.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_net.rs
#	docs/native-mods.md
2026-11-07 02:45:45 +01:00
Ludovic Marie 0ad4f59af2 Merge pull request #11537 from horizon-rust/multiplayer/drag-servers
🤖 Agent: a press on the already-selected server still joins without holding it, and servers.dat is written only on a release after a real move.
2026-11-07 02:37:14 +01:00
Ludovic Marie 3e6e75d8f2 Merge pull request #11511 from horizon-rust/settings/finer-lod-steps
🤖 Agent: only the slider's step table changes; saved values between steps snap to the nearest step (test covers 3000 → 13/23), so existing options.txt files still load.
2026-11-07 02:37:10 +01:00
Ludovic Marie cef80f4fd5 Merge pull request #11510 from horizon-rust/mods/quinn-dep
🤖 Agent: quinn is optional behind the quic feature with default features off (runtime-tokio + rustls-ring only), so no extra TLS backend lands in Cargo.lock beyond the 7 packages.
2026-11-07 02:37:06 +01:00
Ludovic Marie 52a7c28daf Merge pull request #11512 from horizon-rust/mods/open-chat-helper
🤖 Agent: native_open_chat refuses unless the screen is Playing, so a mod can't push the chat over an open container or menu.
2026-11-07 02:37:02 +01:00
Horizon agent 97e3a74179 Merge remote-tracking branch 'origin/main' into cg68/hooks-all 2026-11-07 02:33:34 +01:00
Horizon agentandClaude Opus 5.5 2ba9bcf90d Fix the build: the path goes to mods as a string
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:30:55 +01:00
Horizon agentandClaude Opus 5.5 dd338cd642 Fix the build: FnMut lookup for the attacker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:29:11 +01:00
Horizon agentandClaude Opus 5.5 17ee8c5f13 lan_e2e legacy-pvp: flip the switch once it is whole in view, and swap across ticks
The check failed on main with 1.8 rules that work: the switch row now sits
under the PvP page's HUD rows, the scroll stopped as soon as the row showed
at the list's lower edge, and the click at its centre landed outside the
list, so the rules never turned on (legacy hit 9, not 7). It now scrolls
until the row stops moving and asserts the click flipped it.

The swap and back went out in one tick: since the client sends a slot
change from its next tick, as vanilla does (#10084), that sends nothing,
and the modern hit kept its full charge. The stick is now held 3 ticks.

Mac mini: legacy (7, 7), modern (9, 3.6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:27:08 +01:00
Ludovic Marie 77db38ec8c Merge pull request #11523 from horizon-rust/platforms/ci-arm64-launcher-version
🤖 Agent: .github only; the by-hand ARM64 job's launcher --version step now runs from bash as launcher.yml does, and passes on the ARM64 runner (run 42320852405), where the pwsh version failed on any code.
2026-11-07 02:23:08 +01:00
Horizon agentandClaude Opus 5.5 6858867f2a Convert older worlds on all cores and skip empty region files
Opening a world saved by an older version runs the converter in
datafix/files.rs (upgrade_world), which rewrote every region file on one
thread. Now:

- upgrade_regions spreads a folder's region files over worker_threads()
  workers (all cores but one). Each file is still read, fixed and written
  whole by one worker through the same staging folder, so the bytes do not
  depend on the thread count. A cancel or an error stops every worker
  before its next file; the error returned is the first file's in order.
- A region file without chunks is left as it is instead of being rewritten
  as an empty 8 KiB file and synced. The game leaves many of them: 13 303
  of the 13 620 overworld files of a 1.21.1 ATM10 test world.

Tests: 4 workers give byte-identical files to 1 and every chunk at the
current version, an empty file stays empty, and a cancel from the first
chunk converts at most one file per worker and leaves the rest old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:17:31 +01:00
Ludovic Marie 00f6358e7a Merge pull request #11081 from horizon-rust/platforms/docs-intel-arm64
🤖 Agent: one line in docs/hosting.md (Intel Macs have had server builds since the 10-31 Nightly; install.sh already maps Darwin-x86_64); the README half is already on main via bf4657da26.
2026-11-07 02:10:26 +01:00
Ludovic Marie b33917f89b Merge pull request #11080 from horizon-rust/platforms/arm64-emulated-dx12
🤖 Agent: Auto's backend order now reads the machine's arch (IsWow64Process2), so the x64 build on a Snapdragon PC starts on DX12 like the ARM64 one; the ARM64 runner built and started the game on this head. Unverified on real Adreno hardware (asked #7513's reporter).
2026-11-07 02:10:21 +01:00
Ludovic Marie 3fd9db1568 Merge pull request #10501 from arslanarm/shaderpacks/same-size-buffer-declarations
🤖 Agent: merged main in again (only the test module list in pack_post.rs collided, with #11196's sky_disc_blend_tests). The last parity failure on 4fa3bf699 was command_exec's '/advancement grant @s everything' count (116 vs 115), which fails identically without this PR; this head reruns parity. sizes_differ is a few array reads per pass per frame.
2026-11-07 02:10:17 +01:00
Horizon agentandClaude Opus 5.5 f31f4d179a horizon-bots: fight bots swing as 26.3 does, and --crits for PvP benches
The fight behaviour's swing went out as a raw Punch with a hand byte
26.3's ServerboundSwingPacket doesn't have: every server kicked the bots
on their first hit ("packet 46 was larger than expected: 1 extra bytes").
It now sends the same Punch as --attack.

--crits makes fight bots swing at a sword's full strength (13 ticks) and
jump into every other swing, landing it on the way down: a critical hit,
then a sweep from the ground. tools/perf PvP scenes get crit and sweep
particles from real hits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:08:33 +01:00
Horizon agentandClaude Opus 5.5 88a372f808 Let mob_local_sound and ominous_warning_sound run with parity.py's defaults
tools/parity.py starts every harness with no arguments, and both scripts
required --binary and --work-dir, so parity-main failed them in 0 s
(argparse usage error) since #10524 and #10266. Like
hostile_sound_categories, they now build the game through net.sides and
pick a work folder under .local/ when none is given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:06:56 +01:00
Horizon agentandClaude Opus 5.5 1f7515f5ee Item move comment: a minecart does not stop an item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:04:23 +01:00
Horizon agentandClaude Opus 5.5 84f77ee9a5 Launcher: keep the Home tab notch and the settings rows on wide windows
Follow-up to #10529 (atsauban's responsive wrapping). The Home tab strip
took the full width at every size, so glass::tabbed drew one plate and
lost its notch even at 1440 px: it is full width and scrolls only when
compact. setting_row went from 40 px to 16 px with justify_between for
every row; it keeps 40 px between name and control and wraps the
control under the name (12 px) when the window is too narrow. The
breakpoints are named constants beside WIDE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:01:34 +01:00
Horizon agentandClaude Opus 5.5 8ce49872e7 Follow-ups to #11033, #11044, #11043 and #11016
- Death protection: one prototype lookup per hand instead of a component
  map rebuilt from the stack's patch. update_totem_ready runs for both of
  the player's hands and sync_mob_death_protection for every armed mob,
  every tick; only a stack that patches the component takes the old path.
- Remote projectiles: the swept bubble-column cells are deduplicated
  without a HashSet per projectile per tick.
- Ident::read hands a namespaced identifier back without a second String.
- parked_worlds keeps its doc comment (#11016 put parked_border under it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:14 +01:00
Horizon agentandClaude Opus 5.5 280ef828d6 A placed block's swing is no longer sent back to the player
handleUseItemOn sends the use's swing to the player's own client only when
the result is not SwingSource.PREDICTED. Horizon sent every swing back, so
on a slow connection the echo of a placement's swing arrived after the first
half of the client's own swing and swung the arm again (#9887). A placement
and a block's own SUCCESS now count as predicted; the beds, light block,
respawn anchor and sign stay SUCCESS_SERVER and are still sent back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:07 +01:00
Horizon agentandClaude Opus 5.5 3a6410b63b Location enchantments: copy only the armor slots that changed
The check runs every tick for every player; cloning all four stacks each
time allocated their components for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:52 +01:00
Horizon agentandClaude Opus 5.5 482cc7707c Merge origin/main into #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:49 +01:00
Horizon agentandClaude Opus 5.5 e409b37078 Brush: a mob blocks the brush out to the block interaction range
BrushItem.calculateHitResult picks entities with blockInteractionRange, not
the entity range: a mob 3 to 4.5 blocks away in front of the block stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:43 +01:00
Horizon agentandClaude Opus 5.5 64c68064f6 Merge origin/main into #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:37 +01:00
Ludovic Marie 80947472c1 Merge pull request #11087 from horizon-rust/hosting/amp-template
🤖 Agent: the AMP update stage runs the egg's own install script byte for byte (test pins it), so both panels install and SHA-256-check the same way; not imported into a live AMP.
2026-11-07 01:55:30 +01:00
Horizon agent cf93b66d14 Merge remote-tracking branch 'origin/main' into fix/items-fall-through-minecarts-11091 2026-11-07 01:53:29 +01:00
Horizon agentandClaude Opus 5.5 486dd3f058 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 53d0ea1320 Native mods: read and write the open chat box (native_chat_input, native_set_chat_input)
A client mod that converts text as it's typed (Emoji Type's :code: to emoji)
reads the chat box's text and cursor and writes the result back as an edit,
so completions refresh as for typing. Only what typing lets in reaches the
box. Stacked on native_open_chat.

Part of #10107

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 403ecdbadb CI: the by-hand Windows ARM64 job reads the launcher's --version from bash
Its first runs failed at "horizon-launcher --version failed": pwsh read no
output from the GUI-subsystem launcher, while launcher.yml's same check, from
bash on the same runner, passes on every main push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:51:38 +01:00
Horizon agentandClaude Opus 5.5 5d91256713 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:47 +01:00
Horizon agentandClaude Opus 5.5 5a24802d6a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:16 +01:00
Horizon agentandClaude Opus 5.5 ac8a09ef0a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:41 +01:00
Horizon agentandClaude Opus 5.5 0a639d598e Merge main into #10501 (test module list in pack_post.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:38 +01:00
rdbt 13f70ad482 Merge pull request #11250 from TheMrZZ/fix/h252-loaded-pig-tags
Normalize Mob Tags when adopting NBT
2026-11-06 22:09:57 +00:00
rdbt 6590656a9a Merge pull request #11393 from ilguerro/fix/10548-villager-memories
Save villager brain memories like vanilla
2026-11-06 22:09:51 +00:00
rdbt f2e7c1865f Merge pull request #11405 from ilguerro/fix/w076
Count collidable entities in auto-jump like vanilla
2026-11-06 22:09:45 +00:00
rdbt 876c817f7d Merge pull request #11397 from ilguerro/fix/w029
Keep wandering trader homes like vanilla
2026-11-06 22:09:40 +00:00
rdbt b465f24309 Merge pull request #11395 from ilguerro/fix/w024
Match vanilla hanging entity coexistence checks
2026-11-06 22:09:04 +00:00
DetimesandClaude Opus 5.5 611dfc244f Merge upstream/main into hooks-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:38:48 +00:00
DetimesandClaude Opus 5.5 2e01586b5e Merge upstream/main into hooks-b-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:35:45 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 d36a2c9e21 fix(shaders): give the scripted-clock test its shader API line
main's test predates NativeFrame::new's api argument; it runs a 0.1 frame, as the other tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:33:28 +01:00
Nikita Maximilian Grimm 1ade310792 Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	crates/horizon-client/src/game/shader_packs.rs
2026-11-06 22:29:20 +01:00
rdbt 9d11d0c710 Merge pull request #11369 from Skr1zzzz/ride-any-tracked-entity
Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
2026-11-06 21:27:13 +00:00
rdbt c90689fd74 Merge pull request #10168 from NikitaMGrimm/shader-pack-identity
Keep native shader selections and settings across updates
2026-11-06 21:27:07 +00:00
rdbt 22628d794c Merge pull request #10194 from IanWorley/native-api/block-appearance
Native mods: block state facts and the client's resource files, for maps and radars
2026-11-06 21:27:01 +00:00
rdbt a94b615c99 Merge pull request #11014 from senjinthedragon/entity-at-velocity
Shader packs: at_velocity for entities
2026-11-06 21:26:55 +00:00
rdbt 63c8c5bce2 Merge pull request #11181 from Slavik-85/f3-overlay-light
F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
2026-11-06 21:26:49 +00:00
rdbt 13be1a9e4f Merge pull request #10529 from atsauban/fix/launcher-narrow-layout-6362
fix(launcher): support responsive wrapping on narrow windows
2026-11-06 21:26:42 +00:00
rdbt 6a978d3bef Merge pull request #11051 from Delete228/native-dropped-item-contact
Native mods: expose dropped-item block contact
2026-11-06 21:26:37 +00:00
rdbt a8055893a2 Merge pull request #11033 from DUzzL/fix/10587-death-protection-item
Death protection: use the death_protection item and its effects, for players and mobs
2026-11-06 21:26:31 +00:00
rdbt ef78519011 Merge pull request #9952 from Ragbie/fix/iron-golem-toss
Iron golems toss by knockback resistance and keep their anger like vanilla
2026-11-06 21:26:25 +00:00
rdbt c30775263f Merge pull request #10197 from IanWorley/native-api/client-world-snapshots
Native mods: client world snapshots a mod reads on its worker threads
2026-11-06 21:26:18 +00:00
rdbt 04176c2939 Merge pull request #10133 from Ragbie/fix/mounted-attack-box
A mounted mob reaches from its vehicle's width, and a rider is hit from its seat up
2026-11-06 21:26:10 +00:00
rdbt 96505f501d Merge pull request #11113 from Detimes/hooks-bots
Native mods: helpers for server-side player bots (one movement tick, block collision boxes)
2026-11-06 21:26:04 +00:00
rdbt c932d4bec1 Merge pull request #10166 from hildlabs/remote-servers-ssh
Launcher: a server on another computer, run over SSH
2026-11-06 21:25:59 +00:00
rdbt c87e44bf63 Merge pull request #11062 from voidsipp/fix/round4-7-10178
Add local particle spawning for native client mods
2026-11-06 21:25:53 +00:00
rdbt d8d0d5e832 Merge pull request #10554 from ilguerro/perf/9996-startup
Parse entity-models.json once at startup
2026-11-06 21:25:47 +00:00
rdbt 79d2b975f5 Merge pull request #11264 from senjinthedragon/hand-translucent
Shader packs: gbuffers_hand_water for translucent held items
2026-11-06 21:25:41 +00:00
rdbt 0b9487e6e9 Merge pull request #11090 from Delete228/native-fluid-provider-upstream
Native mods: let existing block fluid helpers reach native-owned tanks
2026-11-06 21:25:36 +00:00
rdbt e32b03ea27 Merge pull request #11064 from horizon-rust/roadmap/proposal-issues
Roadmap: attach 22 proposal issues to their items, two new Later items
2026-11-06 21:25:29 +00:00
rdbt c4d8c79e2b Merge pull request #11076 from voidsipp/fix/round4-5-10444
Add native client sound parameter interception
2026-11-06 21:21:31 +00:00
rdbt 8cc05f114c Merge pull request #11196 from arslanarm/shaderpacks/sky-part-blends
Shader packs: the sky's parts blend as vanilla draws them
2026-11-06 21:21:25 +00:00
rdbt b80e816a76 Merge pull request #10532 from PzmuV1517/shaders/perf-background
Shader packs: a pack builds on a thread of its own while the game draws
2026-11-06 21:21:20 +00:00
rdbt 9b8b755e4c Merge pull request #11183 from rison1337/perf/dynamic-light-selection
Stream dynamic-light sources and bound nearest-light selection
2026-11-06 21:21:14 +00:00
rdbt 5692713279 Merge pull request #10556 from ilguerro/perf/9996-light
Light a section from its palette's light info, not its state ids
2026-11-06 21:21:08 +00:00
rdbt ff31439c92 Merge pull request #9993 from MSedgeMC/fix/prediction-snap
A refused block change that comes back inside the player snaps it out
2026-11-06 21:20:30 +00:00
rdbt 02641599e8 Merge pull request #11221 from AlfaRojo/fix/player-boarding-cooldown
A player boards a vehicle again right after leaving it
2026-11-06 21:20:24 +00:00
rdbt 12030689f8 Merge pull request #11237 from NikitaMGrimm/pack-sequence-clock
Shader packs: a set frame clock for repeatable captures (screenshot-sequence 1/3)
2026-11-06 21:20:19 +00:00
rdbt ce68709f1f Merge pull request #11281 from ilguerro/perf/mesh-row-masks
Reduce repeated work in section meshing
2026-11-06 21:20:13 +00:00
rdbt 3e5b2ef194 Merge pull request #11268 from DUzzL/fix/11242-hanging-entity-data
Apply item custom data and entity data to placed frames, paintings and armor stands
2026-11-06 21:20:07 +00:00
rdbt 14bb1416d1 Merge pull request #11016 from DUzzL/fix/10549-per-level-border
Keep one world border per level
2026-11-06 21:20:02 +00:00
rdbt 1f666cfec4 Merge pull request #11044 from Scholiboi/fix/remote-pearl-bubble-column
Pearls on a server's client hover in bubble columns as vanilla's do
2026-11-06 21:19:56 +00:00
rdbt 8b578746ea Merge pull request #11205 from arslanarm/shaderpacks/float-into-integer-buffers
Shader packs: a float output into an integer buffer no longer refuses its program
2026-11-06 21:19:50 +00:00
rdbt af55aad580 Merge pull request #11204 from arslanarm/shaderpacks/r11f-images
Shader packs: an R11F_G11F_B10F image is stored into and read back
2026-11-06 21:19:44 +00:00
rdbt b11e8a54b6 Merge pull request #10571 from Skr1zzzz/text-display-cull
Text displays: keep their glyphs, and skip the ones whose text is off screen
2026-11-06 21:19:38 +00:00
rdbt 0ef93c96b9 Merge pull request #11092 from rison1337/perf/cull-behind-camera-falling-blocks
Skip falling-block mesh preparation behind the camera
2026-11-06 21:19:33 +00:00
rdbt 5fca014076 Merge pull request #11227 from daMpoff/fix/launcher-instance-menu-width
Fix overflow in translated launcher instance menus
2026-11-06 21:19:27 +00:00
rdbt c1c985ce5b Merge pull request #10606 from Hiuoy/feat/entity-interact-specific
Native hooks: PlayerInteractEvent.EntityInteractSpecific, the point a player right-clicks on an entity
2026-11-06 21:19:22 +00:00
rdbt af24ac05bb Merge pull request #10564 from Alysia42/ghast-fireball-pickable
Arrows and ender pearls now redirect ghast fireballs instead of passing through them
2026-11-06 21:19:16 +00:00
rdbt 1a92fe7767 Merge pull request #11042 from Vaspyyy/launcher-updates-in-updates
Launcher: update status and Check now move from About to Updates
2026-11-06 21:19:10 +00:00
rdbt 079ed1a010 Merge pull request #11276 from apeirs/fix/mob-block-layers-cull
Mob block layers cull back faces like BlockModelRenderState
2026-11-06 21:19:05 +00:00
rdbt e55180e7dd Merge pull request #10018 from ilguerro/fix/player-death-stats
A player's death makes a vibration and counts toward Killed By
2026-11-06 21:18:59 +00:00
rdbt da4fb17df7 Merge pull request #11099 from Delete228/native-living-item-use-upstream
Native mods: item use after a mob passes interaction
2026-11-06 21:18:53 +00:00
rdbt 114e9920ac Merge pull request #11275 from TheMrZZ/fix/command-enchantment-map-validation
Validate command enchantment map entries before execution
2026-11-06 21:18:47 +00:00
rdbt ad2c0d2ecd Merge pull request #11355 from TheMrZZ/fix/vehicle-damage-command
Route generic damage commands to boats and minecarts
2026-11-06 21:18:41 +00:00
rdbt 976afe97e2 Merge pull request #11065 from voidsipp/fix/11038-server-property-numbers
Match Java numeric server properties
2026-11-06 21:18:34 +00:00
rdbt 7c58203441 Merge pull request #9668 from GabrielNichols/codex/recipes-hmod-server-fill
Keep .hmod Recipes searchable and placeable on the server
2026-11-06 21:18:28 +00:00
rdbt cb201c59e4 Merge pull request #10527 from Akirabane/feat/transfer-status
dedicated: transfer-status, the counts a proxy reads before it sends players
2026-11-06 21:18:22 +00:00
rdbt d22ecb0b52 Merge pull request #11299 from pehe1/fix/3109-airborne-knockback
Apply a guest jump before knockback at takeoff
2026-11-06 21:18:16 +00:00
rdbt 62075a5b32 Merge pull request #11070 from Delete228/native-block-clone-stack-upstream
Native mods: select the stack returned by Pick Block
2026-11-06 21:18:10 +00:00
rdbt aea2bdadb1 Merge pull request #11114 from Delete228/content-item-block-alias-upstream
Native content: place a block through an item alias
2026-11-06 21:18:04 +00:00
rdbt f59035948c Merge pull request #10177 from anthra123x/fix/ultrawide-fullscreen-taskbar
fix(client): prevent taskbar overlap and window shift in borderless fullscreen
2026-11-06 21:17:58 +00:00
rdbt d05f889fe1 Merge pull request #10129 from xFedeT/xfedet-launcher-transparent-topbar
Make launcher titlebar match Glass UI
2026-11-06 21:17:19 +00:00
rdbt c96af77eb6 Merge pull request #11144 from xxaxio/fix/11082-linux-audio-buffer
audio: support HORIZON_AUDIO_BUFFER override for Linux buffer underruns
2026-11-06 21:17:13 +00:00
rdbt d4df5dbf59 Merge pull request #10572 from GIGABAIT93/feat/launch-api-content
Content API: --launch-api says what content a build loads, and docs/api/content.md (#10562)
2026-11-06 21:16:35 +00:00
rdbt 66e6b23fb8 Merge pull request #11243 from AlfaRojo/fix/lead-hold-old-rotation
The lead held in first person turns with the mouse, without jitter
2026-11-06 21:16:29 +00:00
rdbt 918a4c8434 Merge pull request #11247 from Joansitoh/fix/11178-wither-skull-save
Keep wither skulls in flight through a chunk save
2026-11-06 21:16:23 +00:00
rdbt a3c540e8c0 Merge pull request #11255 from AlfaRojo/fix/screen-over-playing-releases-keys
A book, sign editor or prompt over the world releases the held buttons
2026-11-06 21:16:17 +00:00
rdbt e243cbac06 Merge pull request #11046 from voidsipp/fix/round4-2-10408
Honor remapped function keys from XKB
2026-11-06 21:16:11 +00:00
rdbt 4c5f37de8d Merge pull request #11199 from arslanarm/shaderpacks/noise-texture-resolution
Shader packs: noiseTextureResolution sizes the default noisetex
2026-11-06 21:16:05 +00:00
rdbt ba7cdd4d8c Merge pull request #11188 from TheMrZZ/fix/entity-scores-predicate
Evaluate named entity_scores predicates for bound holders
2026-11-06 21:15:58 +00:00
rdbt b61394f396 Merge pull request #11206 from arslanarm/shaderpacks/runtime-array-length
Shader packs: the length of a buffer block's runtime array reads back
2026-11-06 21:15:53 +00:00
rdbt a13866fff3 Merge pull request #10537 from arslanarm/shaderpacks/end-boss-fog-sky
The dragon fight's world fog leaves out the sky, as vanilla does
2026-11-06 21:15:46 +00:00
rdbt f9302c0736 Merge pull request #11023 from ilguerro/fix/9776-item-vibrations
Casting and reeling a fishing rod and throwing an eye of ender make the vibrations vanilla's do
2026-11-06 21:15:40 +00:00
rdbt 44e4124142 Merge pull request #10005 from UMCEKO/fix/projectile-owner-any-entity
Projectiles owned by any non-player entity need mob_griefing
2026-11-06 21:15:34 +00:00
rdbt dc040a9dd7 Merge pull request #11127 from Delete228/discarded-mob-lookup-upstream
Hide discarded mobs from immediate lookup and passenger saves
2026-11-06 21:15:29 +00:00
rdbt d54d736546 Merge pull request #11030 from ilguerro/fix/illager-target-memory
Merge remote-tracking branch 'origin/main' into fix/illager-target-memory
2026-11-06 21:15:23 +00:00
rdbt e4928503ac Merge pull request #11224 from TheMrZZ/fix/text-floating-zero
Canonicalize floating zero when parsing text tags
2026-11-06 21:15:17 +00:00
rdbt 2a83a524fc Merge pull request #11343 from TheMrZZ/fix/tnt-live-data-adoption
Adopt live TNT command data without replacing its identity
2026-11-06 21:15:11 +00:00
rdbt cfd68c41cf Merge pull request #11155 from TheMrZZ/fix/h125-mob-name-predicates
Fix live mob custom-name component predicates
2026-11-06 21:15:04 +00:00
rdbt 2c149ab9e6 Merge pull request #10096 from yahya-nusseibeh/fix/rcon-one-guess
RCON: one password guess per connection
2026-11-06 21:14:59 +00:00
rdbt aebabfb5c6 Merge pull request #11139 from TheMrZZ/fix/pillager-trader-saved-inventory
Preserve Pillager and WanderingTrader saved inventories
2026-11-06 21:14:53 +00:00
rdbt 90a09f5b25 Merge pull request #11346 from AlfaRojo/fix/creeper-explosion-radius
A creeper blows with its ExplosionRadius
2026-11-06 21:14:48 +00:00
Nikita Maximilian Grimm 1d1f36972c Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	docs/api/shaders.md
2026-11-06 22:14:42 +01:00
rdbt ea2986d57b Merge pull request #11223 from AlfaRojo/fix/release-use-before-move
An arrow leaves from where the player was when the bow was released
2026-11-06 21:14:42 +00:00
rdbt 7093191256 Merge pull request #11043 from voidsipp/fix/11034-protocol-identifiers
Validate resource identifiers when reading packets
2026-11-06 21:14:36 +00:00
rdbt d774839f1f Merge pull request #11202 from arslanarm/shaderpacks/compute-shadow-reads
Shader packs: a compute stage reading a shadow buffer turns the shadow pass on
2026-11-06 21:14:30 +00:00
rdbt 6cfb046532 Merge pull request #11203 from arslanarm/shaderpacks/celestial-alpha-blend
Shader packs: the sun and moon leave their own alpha, as vanilla's celestial blend
2026-11-06 21:14:24 +00:00
rdbt fb0866ef3f Merge pull request #11278 from TheMrZZ/fix/h035-loot-mine-pot-components
Preserve pot decorations in loot mine results
2026-11-06 21:14:18 +00:00
rdbt eec2eb3b32 Merge pull request #11067 from voidsipp/fix/11039-nbt-path-reader
Match NBT path quoted escapes and index tokens
2026-11-06 21:14:12 +00:00
rdbt 4e8ed32e62 Merge pull request #10173 from MSedgeMC/fix/10071-auto-update-enabled
launcher: an automatic update keeps a disabled mod off
2026-11-06 21:14:06 +00:00
rdbt 8f55a6c245 Merge pull request #10542 from TheMrZZ/fix/player-default-effect-amplifier
Omit the default amplifier from saved player effects
2026-11-06 21:14:01 +00:00
rdbt 13664f3799 Merge pull request #11157 from TheMrZZ/fix/terminal-execute-fork-errors
Honor fork state for terminal execute condition errors
2026-11-06 21:13:55 +00:00
rdbt 1fb80a5d79 Merge pull request #11354 from MSedgeMC/fix/explosion-item-damage
fix: a blast takes its damage off a dropped item's health
2026-11-06 21:13:49 +00:00
rdbt 3d8521b429 Merge pull request #11209 from TheMrZZ/fix/inline-slot-range-admission
Reject unknown inline slot-range names before execution
2026-11-06 21:13:42 +00:00
rdbt 6181c89ed6 Merge pull request #11246 from TheMrZZ/fix/item-reference-uuid-codec
Decode Item Owner and Thrower with numeric UUID streams
2026-11-06 21:13:37 +00:00
rdbt 2018af984c Merge pull request #11284 from TheMrZZ/fix/mob-command-equipment-slots
Respect mob body-slot admission and saddle commands
2026-11-06 21:13:31 +00:00
rdbt f31d16a2c6 Merge pull request #11201 from TheMrZZ/fix/h154-unless-function-fallthrough
Fix unless-function continuation after fallthrough
2026-11-06 21:13:25 +00:00
rdbt c7d5881fdc Merge pull request #10996 from ilguerro/fix/sleeping-mob-push
A sleeping villager, fox or player isn't pushed by the mobs that walk into it
2026-11-06 21:13:19 +00:00
rdbt c815a0f81c Merge pull request #11162 from TheMrZZ/fix/modern-sign-literal-quotes
Preserve literal quotation characters in modern sign messages
2026-11-06 21:13:13 +00:00
rdbt 08c3b12345 Merge pull request #11032 from ilguerro/fix/piglin-admired-drop
Merge remote-tracking branch 'origin/main' into fix/piglin-admired-drop
2026-11-06 21:13:07 +00:00
rdbt 0925060e6f Merge pull request #11079 from horizon-rust/platforms/intel-mac-native-mods
Native mods on Intel Macs: the launcher brings Rust for x86_64 macOS
2026-11-06 21:13:01 +00:00
rdbt 08f1c4f6c6 Merge pull request #11244 from Gabbotampo/fix/11222-negative-holder-ids
Item components: refuse negative holder ids and holder set sizes
2026-11-06 21:12:56 +00:00
rdbt 838375d4c2 Merge pull request #11363 from Skr1zzzz/older-servers-unknown-types
Older servers: an entity or block entity type the pinned version lacks is dropped, not renumbered
2026-11-06 21:12:50 +00:00
rdbt 8bb2525e31 Merge pull request #11009 from ilguerro/fix/trade-display-drop-chance
A villager's held-up trade item never drops when it dies
2026-11-06 21:12:44 +00:00
rdbt ef6be13a1c Merge pull request #11056 from TheMrZZ/fix/interaction-saved-defaults
Save default Interaction dimensions and response
2026-11-06 21:12:39 +00:00
rdbt 014ad04d1c Merge pull request #11179 from TheMrZZ/fix/h113-command-damage-vibration
Emit mob command damage vibrations before returning
2026-11-06 21:12:33 +00:00
rdbt f2ec4e9c01 Merge pull request #11171 from TheMrZZ/fix/arrow-owner-command-nbt
Expose loaded arrow Owner references in command NBT
2026-11-06 21:12:27 +00:00
rdbt 0d840d54e3 Merge pull request #10219 from TheMrZZ/fix/selector-unknown-criterion
Reject missing advancement selector criteria
2026-11-06 21:12:21 +00:00
rdbt 43528d198c Merge pull request #10476 from TheMrZZ/fix/h220-macro-source-type
fix: report actual macro argument NBT type
2026-11-06 21:12:15 +00:00
rdbt 3d92e52a83 Merge pull request #10565 from TheMrZZ/fix/entity-nbt-boolean-byte
Match entity NBT boolean loads to numeric byte conversion
2026-11-06 21:12:09 +00:00
rdbt 0ef3df34d6 Merge pull request #10373 from xxaxio/fix/10317-narrate-incompatible-server
multiplayer: narrate incompatible server in server list
2026-11-06 21:12:02 +00:00
rdbt e4872aeba7 Merge pull request #11071 from TheMrZZ/fix/tnt-saved-default-power
Omit stale default power from saved TNT
2026-11-06 21:11:56 +00:00
rdbt d08f9ce9d5 Merge pull request #11108 from TheMrZZ/fix/animal-stale-love-cause
Remove stale Animal LoveCause when no reference is modeled
2026-11-06 21:11:51 +00:00
rdbt 162dd10147 Merge pull request #11305 from ilguerro/test/widen-harness-inputs
Widen four parity harnesses' inputs
2026-11-06 21:11:45 +00:00
rdbt 5919e28e0a Merge pull request #11341 from MSedgeMC/test/explosion-edge-ring
test: pin the bed explosion harness's blast edge to the item's own age
2026-11-06 21:11:08 +00:00
rdbt 8985543344 Merge pull request #10042 from NikitaMGrimm/hdr-note-serve-0-1-until-step-5
HDR design note: serve shader API 0.1 until 0.2 is on by default
2026-11-06 21:11:02 +00:00
rdbt 29f5c3504f Merge pull request #10540 from M0-kii/fix/changed-file-paths
Keep exact changed paths and rename sources in tooling checks
2026-11-06 21:10:57 +00:00
rdbt 06c3ba2173 Merge pull request #10019 from IsT3RiK/traduction-fr
Launcher French: translate the strings left in English, vous throughout
2026-11-06 21:10:51 +00:00
rdbt 57dcec2e2d Merge pull request #11173 from SamsVT/thai-translation
Launcher: rewrite the Thai translation (th.json)
2026-11-06 21:10:46 +00:00
rdbt a3b0891fc2 Merge pull request #11254 from MSedgeMC/fix/ghast-still-timeout
A happy ghast keeps its still timeout across a save
2026-11-06 21:10:40 +00:00
rdbt 68d5e2ec6c Merge pull request #11057 from DUzzL/fix/11018-marker-common-nbt
Save common Entity fields for registered Markers
2026-11-06 21:10:33 +00:00
rdbt 909a8123d0 Merge pull request #10546 from TheMrZZ/fix/h074-happy-ghast-ambient-position
Fix HappyGhast ambient sound position
2026-11-06 21:10:27 +00:00
rdbt da3a5aff7e Merge pull request #11265 from NikitaMGrimm/still-crystal-phase
HORIZON_STILL: end crystals start their spin from their position
2026-11-06 21:10:21 +00:00
rdbt e1d2186ee1 Merge pull request #11142 from voidsipp/fix/ci-shared-parity-invocation
Let dolphin and spell parity harnesses use runner defaults
2026-11-06 21:10:16 +00:00
rdbt e500989785 Merge pull request #11262 from MSedgeMC/test/enchant-gate
enchanting: the stack's damage and existing enchantments at the table
2026-11-06 21:10:10 +00:00
rdbt 57ad8718c3 Merge pull request #11198 from MSedgeMC/test/creative-removal
test: a creative player's break leaves the block, but no drops or statistic
2026-11-06 21:10:03 +00:00
rdbt c12fe63d33 Merge pull request #11109 from Detimes/native-refresh-commands
Native mods: native_refresh_commands rebuilds the players' command trees at run time
2026-11-06 21:09:57 +00:00
rdbt 71e18e8c0a Merge pull request #11054 from TaquitoSuabe/fix/haswell-vulkan-fallback
gfx: skip broken Haswell Vulkan driver (hasvk) and fall back to OpenGL
2026-11-06 21:09:51 +00:00
rdbt 880b690d6c Merge pull request #10086 from horizon-rust/pvp-safe/sneak-input
Multiplayer: sneaking for a use is the input of the last tick, as vanilla's isShiftKeyDown
2026-11-06 21:09:45 +00:00
rdbt d55b7f8594 Merge pull request #10551 from ilguerro/fix/9759-golem-peaceful
Peaceful drops a mob's player target and a golem's anger
2026-11-06 21:09:39 +00:00
rdbt b849bc3dab Merge pull request #11007 from ilguerro/fix/use-swing-attack-reset
A use that swings the hand resets the attack charge
2026-11-06 21:09:33 +00:00
rdbt 85d5ce37f0 Merge pull request #10908 from DUzzL/fix/10550-trident-hurts-players
A thrown trident hurts other players on a served world
2026-11-06 21:09:26 +00:00
rdbt 5395de1ab4 Merge pull request #10967 from ilguerro/fix/mob-block-fall-sound
A mob's damaging fall plays the fall sound of the block it lands on
2026-11-06 21:09:19 +00:00
rdbt 24459551e6 Merge pull request #11100 from Delete228/native-held-break-slot-upstream
Native mods: choose the break slot for held-item durability
2026-11-06 21:09:12 +00:00
rdbt 848ffed94d Merge pull request #11136 from DUzzL/fix/11088-slime-split-delay
Split slimes, magma cubes and sulfur cubes when the body is removed
2026-11-06 21:09:06 +00:00
rdbt 66ee01b737 Merge pull request #11143 from Delete228/frozen-sprint-tick-boundary-upstream
Restore the frozen tick decision before ending a sprint
2026-11-06 21:09:01 +00:00
rdbt 7227485ca2 Merge pull request #11211 from AlfaRojo/fix/elytra-glide-saved
A player saved while gliding comes back gliding
2026-11-06 21:08:55 +00:00
rdbt d914334acc Merge pull request #11226 from Horizon-is-vibe-coded/main
fix(checkerboard): clamp negative scale before shift to avoid pathological shift distance
2026-11-06 21:08:49 +00:00
rdbt 07b8f97138 Merge pull request #11229 from AlfaRojo/fix/own-model-freeze-shiver
The player's own third-person model shivers when fully frozen
2026-11-06 21:08:44 +00:00
rdbt 5e8d557b17 Merge pull request #11292 from Ragbie/fix/snbt-trailing-comma
SNBT: a compound, list or array may end with a comma, as vanilla's grammar allows
2026-11-06 21:08:38 +00:00
rdbt 07a162cb30 Merge pull request #11272 from echo-ilovech3ss/fix/tp-passenger-followups
game: cover passenger rotation through /tp in rider test
2026-11-06 21:08:32 +00:00
rdbt 8146a586da Merge pull request #11279 from DUzzL/fix/11271-smithing-trim
Draw trims on the smithing preview armour
2026-11-06 21:08:27 +00:00
rdbt dc8aba8bee Merge pull request #11259 from DUzzL/fix/11252-autosprint-sign
Keep a latched sprint toggle out of the text-field Ctrl shortcuts
2026-11-06 21:08:21 +00:00
rdbt f1f352044f Merge pull request #11348 from d3xc0/native-default-state
Native mods: a block default state by name (native_default_state)
2026-11-06 21:08:15 +00:00
rdbt 83feefe860 Merge pull request #11368 from Vaspyyy/docs/claimable-issues-query
AGENTS.md: one query for the issues free to claim
2026-11-06 21:08:09 +00:00
rdbt 2132e6126f Merge pull request #11313 from DUzzL/fix/11290-beacon-beam-view
Cull back faces of beacon beams
2026-11-06 21:08:03 +00:00
rdbt 97b36f8e1b Merge pull request #11362 from AlfaRojo/fix/mob-protection-order
A mob's Protection runs after Resistance and not against a sonic boom
2026-11-06 21:07:58 +00:00
rdbt 521c26d6ca Merge pull request #11357 from AlfaRojo/fix/bone-meal-spreader-particles
Bone meal on a spreading block sparks over the block above it
2026-11-06 21:07:52 +00:00
rdbt 218624479d Merge pull request #11352 from AlfaRojo/fix/damage-taken-stat-threshold
Damage Taken is not awarded for a huge hit
2026-11-06 21:07:46 +00:00
rdbt 4dc1282f65 Merge pull request #9953 from Hiuoy/feat/9921-port-pack-events
Port pack: `events`, a mod's NeoForge listeners against the hooks
2026-11-06 21:04:33 +00:00
rdbt b351455eae Merge pull request #10245 from Hiuoy/feat/anvil-update
Native hooks: AnvilUpdateEvent, a mod's say in an anvil's result
2026-11-06 21:04:28 +00:00
rdbt 217e87e8f3 Merge pull request #10240 from MSedgeMC/fix/item-cull-frame
Dropped items: cull at the box the frame draws, not the tick's
2026-11-06 21:04:22 +00:00
rdbt e3356386d0 Merge pull request #11153 from DUzzL/fix/11055-cushion-sit-remote
Seat other players on a cushion they sit on
2026-11-06 21:04:15 +00:00
rdbt bf17581664 Merge pull request #11152 from DUzzL/fix/11074-dismount-hint
Show the dismount hint when the player starts riding
2026-11-06 21:04:09 +00:00
rdbt 02e62fbedf Merge pull request #11182 from DUzzL/fix/11137-golem-target-mounts
Keep golems off zombie horses, camel husks and zombie nautilus
2026-11-06 21:04:03 +00:00
rdbt e0f9934254 Merge pull request #11240 from DUzzL/fix/10287-shulker-piston-align
Snap piston-pushed shulkers to the block centre
2026-11-06 21:03:57 +00:00
rdbt 76bda62793 Merge pull request #11169 from DUzzL/fix/11130-mod-worldgen-feature-dir
Read mod features from worldgen/feature/, 26.3's folder
2026-11-06 21:03:51 +00:00
rdbt b12c89bf04 Merge pull request #11129 from Delete228/native-container-provisioning-upstream
Avoid inventory notifications when provisioning empty native slots
2026-11-06 21:03:45 +00:00
rdbt 345e6efdc9 Merge pull request #11151 from voidsipp/fix/gateway-passenger-player-ticks
Run the mounted player tick in the gateway parity scene
2026-11-06 21:03:38 +00:00
rdbt 77ec563892 Merge pull request #11112 from ilguerro/tooling/use-item-on-interact-start
use_item_on's oracle posts item_interact_start when a use starts
2026-11-06 21:03:32 +00:00
rdbt 5bd038ed99 Merge pull request #11156 from xxaxio/docs/11154-index-design-notes
docs/README.md: index live mod reloading design note
2026-11-06 21:03:26 +00:00
rdbt 06e2041d43 Merge pull request #11149 from xxaxio/docs/11145-platform-scope
docs/native-client: update stale platform scope note
2026-11-06 21:03:19 +00:00
rdbt 2643b0e246 Merge pull request #11134 from xxaxio/docs/stale-notes-cleanup
docs/server-levels: remove stale End credits for server players note
2026-11-06 21:03:13 +00:00
rdbt f235e95518 Merge pull request #11131 from xxaxio/fix/11120-test-harness-args-windows
tests/test_harness_args: match system path separator for default work dir
2026-11-06 21:03:07 +00:00
rdbt 1e68c9db3d Merge pull request #11010 from Hiuoy/docs/neoforge-labels-tooltip
Native hook catalog: tooltip_render_pre is RenderTooltipEvent.Pre; the Next hooks have all landed
2026-11-06 21:03:00 +00:00
VaspyyyandClaude Opus 5.5 ee88e8c870 AGENTS.md: one query for the issues free to claim
Agents had to open issues one by one to see if they were taken, and the rule
did not mention Discord claims (Kira's `in progress` label). Give them the
ready-made search and name both claim signals.

Fixes #11367

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:48:25 +01:00
rdbtCVSandClaude Opus 5.5 18eb428ffb Drop the agent marker from a code comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:35:38 +00:00
rdbtCVSandClaude Opus 5.5 cd99e67d1c Native client doc: macOS is a target too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:35:11 +00:00
Kevin RamirezandClaude Fable 5.1 71622aabb3 A mob's Protection runs after Resistance and not against a sonic boom (#11308)
LivingEntity.getDamageAfterMagicAbsorb applies Resistance, returns 0 when
nothing is left, returns the damage as is for #bypasses_enchantments (the
warden's sonic_boom), and only then the enchantment protection. Horizon's
Mob::hurt_server ran the protection first and never read the tag, so a
zombie in a Protection IV chestplate kept 1.6 of a 10-point sonic boom.

DamageKind::bypasses_enchantments (SonicBoom, or a NamedDamage with the
new BYPASSES_ENCHANTMENTS tag) gates the protection, which now follows
Resistance as vanilla orders them.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:33:17 -06:00
rdbtCVSandClaude Opus 5.5 e70f5347ba Realms: finish the merge of main (frontend helper, tier fields)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:33:13 +00:00
Skr1zzzzandClaude Opus 5.5 ad8a713d95 Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
A server seating the player on a block display (sit mods, NPC plugins) took the vehicle for a minecart; none was
found, so riding was dropped and the player fell into the slab under the seat (y -61.0 where the server keeps
-60.1). The seat is the vehicle's riding position less the player's vehicle attachment (0.6, scaled).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:31:46 +03:00
rdbtCVSandClaude Opus 5.5 9884fc3af1 Keep #[cfg(test)] on the test module the merge of main took it from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:30:45 +00:00
rdbtCVSandClaude Opus 5.5 b25dbdab4e Restore the #[test] the merge of main took from this test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:29:53 +00:00
rdbtCVSandClaude Opus 5.5 dc175fe838 Hoppers restart a furnace's cooking when they fill its empty input
AbstractFurnaceBlockEntity.setItem resets the time; without the early reset a hopper-fed furnace would smelt its first item in one tick. Review feedback on #10170.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:23:55 +00:00
rdbtCVSandClaude Opus 5.5 d447274d79 Merge origin/main into PR #10170
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:22:15 +00:00
rdbtCVSandClaude Opus 5.5 c99811b0a7 /say: no captured copy of the broadcast line
A guest's /execute run say and the dedicated console saw it twice; review feedback on #10015.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 883d53f647 Merge origin/main into PR #10015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 1e137ff7f3 Launcher: DRAWN is only read off Linux now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:19:06 +00:00
rdbtCVSandClaude Opus 5.5 dcc21109ac Say what the automatic grant does for an advancement with no criteria
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:41 +00:00
rdbtCVSandClaude Opus 5.5 daf2f4debc Statistics tabs read the raw key, as Screen.keyPressed does
Review feedback on #10124.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:16 +00:00
rdbtCVSandClaude Opus 5.5 c612f19826 Merge origin/main into PR #10124
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:59 +00:00
rdbtCVSandClaude Opus 5.5 8c820a5994 Chat: clearing a draft runs onEdited, so its old command info goes too
EditBox.setValue fires the responder; review feedback on #10013.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:17 +00:00
rdbtCVSandClaude Opus 5.5 377650f3ee Merge origin/main into PR #10013
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:16 +00:00
rdbtCVSandClaude Opus 5.5 7fbe88924e Keep AdvancementProgress.isDone false for an advancement with no criteria
AdvancementRequirements.test returns false when its list is empty; review feedback on #10193.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:51 +00:00
MSedgeMC bae2b17a0d fix: a blast takes its damage off a dropped item's health
`ServerExplosion.hurtEntities` damages every entity in reach, and
`ItemEntity.hurtServer` writes the result back:

    this.health = (int)((float)this.health - amount);
    if (this.health <= 0) { this.getItem().onDestroyed(this); this.discard(); }

Horizon's item branch only asked whether the damage reached 5 and destroyed the
item, so an item that lived through a blast kept all 5 points of health: a
power-5 bed blast that leaves vanilla's item at 1 left Horizon's at 5.

The damage is now subtracted in f32 and truncated after the subtraction, as the
cast does, and the item is destroyed at zero health as before. The nether star
stays immune through `ItemStack.canBeHurtBy`'s explosion exception, and a blast
whose source is an entity with `mobGriefing` off never reaches the branch
(`items_ignore`).
2026-11-06 20:15:40 +00:00
MSedgeMC 6f5be3bbf8 test: the blast edge ring reads the survivor's health too
A dropped item starts with 5 health and `ItemEntity.hurtServer` takes the
blast's damage off it, so a survivor's `Health` says how much of the blast it
took. The pinned compound the ring already reads carries it, so the compared
line grows one fact:

  data push -0.03 -0.00 -0.05 health 1 keep 0.9800

Red on main: Horizon reports `health 5` where vanilla reports `1`.
2026-11-06 20:15:40 +00:00
rdbtCVSandClaude Opus 5.5 42948e3f17 Merge origin/main into PR #10193
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:26 +00:00
Florian ERNSTandCodex 627617ee6b Route damage commands to actual boat and minecart damage
Co-Authored-By: Codex <[email protected]>
2026-11-06 21:15:20 +01:00
Kevin RamirezandClaude Fable 5.1 9343b3c357 Bone meal on a spreading block sparks over the block above it (#11310)
BoneMealItem.addGrowthParticles places a NEIGHBOR_SPREADER's happy
villager sparks at BonemealableBlock.getParticlePos, which is the block
above for grass, nylium, netherrack and moss; a GROWER keeps the block
itself, rooted dirt and mangrove leaves the one below. Horizon used the
bone-mealed block for spreaders too, a block lower than vanilla.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:13:58 -06:00
rdbtCVSandClaude Opus 5.5 b85a07d423 Merge origin/main into PR #10129
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:11 +00:00
rdbtCVSandClaude Opus 5.5 8761658a18 Merge origin/main into PR #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:10 +00:00
rdbtCVSandClaude Opus 5.5 9b788418c3 Merge origin/main into PR #10219
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:08 +00:00
rdbtCVSandClaude Opus 5.5 06a0f118e2 Merge origin/main into PR #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:07 +00:00
rdbtCVSandClaude Opus 5.5 19eff5eb79 Merge origin/main into PR #10532
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:07 +00:00
rdbtCVSandClaude Opus 5.5 6218257d19 Merge origin/main into PR #10118
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:06 +00:00
rdbtCVSandClaude Opus 5.5 41d3a0cbb7 Merge origin/main into PR #10009
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:05 +00:00
rdbtCVSandClaude Opus 5.5 5393956e8a Merge origin/main into PR #9995
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:04 +00:00
rdbtCVSandClaude Opus 5.5 af5731d305 Merge origin/main into PR #9993
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:13 +00:00
Kevin RamirezandClaude Fable 5.1 53c13c0cd9 combat: no JDK method named in the damage_taken docs (the ledger reads them as citations)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:09:37 -06:00
rdbtCVSandClaude Opus 5.5 57a7b16d5a Merge origin/main into PR #9968
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:08:41 +00:00
rdbtCVSandClaude Opus 5.5 76c095290f Custom skies: a fade out ending at the fade in's start ramps down over the rest of the day
Review feedback on #10002.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:07:41 +00:00
rdbtCVSandClaude Opus 5.5 7581d1fbe3 Merge origin/main into PR #10002
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:06:52 +00:00
rdbtCVSandClaude Opus 5.5 33cd3992bf Merge origin/main into PR #10019
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:06:12 +00:00
Kevin RamirezandClaude Fable 5.1 a419cc37c7 Damage Taken is not awarded for a huge hit (#11339)
Player.actuallyHurt awards DAMAGE_TAKEN as Math.round(dmg * 10.0F) only
while dmg < 3.4028235E37F. Horizon awarded every hurt with Rust's round,
so /damage @s 3.4e38 added a saturated i32::MAX where vanilla adds
nothing. A damage_taken_award helper applies the threshold and
jmath::round_f32 (Math.round(float): ties up, saturating).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:06:03 -06:00
Skr1zzzzandClaude Opus 5.5 ec534edc6c Older servers: an entity or block entity type the pinned version lacks is dropped, not renumbered
A modded 1.21.11 server's own entity type (Sit's sit:entity_sit is 157) kept its number, which in 26.3 is
the zombie villager: every sitting player showed one under them. Chunks already dropped such block entities;
their updates now go too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:05:51 +03:00
rdbtCVSandClaude Opus 5.5 c25f5a1f49 Merge origin/main into PR #11081
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:05:45 +00:00
rdbtCVS 8c5025d3d9 Merge origin/main into PR #10476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 20:00:41 +00:00
rdbtCVS 7b21af9519 Merge origin/main into PR #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_client_services.rs
2026-11-06 19:59:43 +00:00
rdbtCVS 67fa3d1bb3 Merge origin/main into PR #10065
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-06 19:59:37 +00:00
rdbtCVS d6113ce7e5 Merge origin/main into PR #10018
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-06 19:59:36 +00:00
rdbtCVS 1c389c3758 Merge origin/main into PR #10606
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 19:59:35 +00:00
rdbtCVS 8d5e529b46 Merge origin/main into PR #10005
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 19:59:01 +00:00
rdbtCVS 6f82bf74b1 Merge origin/main into PR #10173
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/browser/horizon/auto/tests.rs
2026-11-06 19:59:00 +00:00
Kevin RamirezandClaude Fable 5.1 e7c082ee6f A creeper blows with its ExplosionRadius (#11332)
Creeper.readAdditionalSaveData reads ExplosionRadius as a byte (default
3), addAdditionalSaveData writes it back, and explodeCreeper blows with
it, twice it when powered. Horizon used a fixed 3 or 6 and never read the
key, so a summoned creeper with ExplosionRadius:12b blew with 3.

Extra.explosion_radius holds it (3 from Mob::new), entity_nbt reads any
numeric tag narrowed to a byte and writes the byte, and creeper_tick
scales it by the powered multiplier.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 13:56:10 -06:00
Florian ERNSTandCodex 5efccc59f0 Adopt live TNT command data without replacing its identity
Co-Authored-By: Codex <[email protected]>
2026-11-06 20:53:04 +01:00
MSedgeMC cdf61801bb test: pin the bed explosion harness's blast edge to the item's own age
The survivor the harness gained at the edge of the blast is read through the
item's own tick counter. A push on an item decays every tick
(`Entity.getAirDrag`, 0.98), and the two servers do not advance the same number
of game ticks between the same commands: at three script points of one case the
vanilla survivor was 31, 33 and 48 ticks old and Horizon's 38, 41 and 75, so a
`Motion` read there measures the pacing rather than the blast.

The edge item's `Age` is now read before the blast, and its whole NBT (`Age`,
`Pos` and `Motion` from one snapshot) at two pinned ages after it. The case
compares the push carried back over the ticks since the blast, to two decimals
because a tick of slack between the blast and the read is worth 2%, and the
decay the two snapshots show per tick, to four, which is `getAirDrag`'s 0.98.
Both agree with vanilla's in all twelve cases.
2026-11-06 19:50:58 +00:00
f7190cff67 Widen four parity harnesses' inputs
Cover all block states, registered enchantments, lore styles and
representative Lunge tiers using the existing vanilla oracles.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
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 20:32:02 +01:00
DUzzL 02896862e8 Cull back faces of beacon beams 2026-11-06 20:29:32 +01:00
Pedro CavalcanteandCodex 87761cd5b0 Apply a guest jump before subsequent combat packets
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:14:13 -03:00
Arthur Black ff80bb806c Native mods: a block default state by name (native_default_state) 2026-11-06 11:08:00 -08:00
Evrim CicekandClaude Opus 5.5 d41641f85d SNBT: a compound, list or array may end with a comma
SnbtGrammar accepts one trailing comma before the closing brace or
bracket; a comma alone and two in a row stay errors. Both readers take it:
horizon-world's parse_snbt (NBT arguments, item components) and the text
component reader (tellraw and other component arguments).

Fixes #11285

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:06:09 +03:00
ed0780a03a Count collidable entities in auto-jump like vanilla
LocalPlayer.updateAutoJump reads Level.getCollisions, which returns the
collidable entities' boxes (EntityGetter.getEntityCollisions, query
inflated by 1e-7) before the block shapes. Horizon's auto-jump only saw
blocks, so walking into a boat, a shulker or a happy ghast never
auto-jumped onto it.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:48:41 +01:00
Florian ERNSTandCodex 9bc728f303 Respect mob body-slot admission and saddle commands
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:24 +01:00
TheMrZZandCodex gpt-6.1-sol 58fc786742 Preserve pot decorations in loot mine results
Record the actual block entity or its existing pure save projection in a local Pending, then attach existing component copies to the unchanged block drops. Keep source blocks and loot replacement policy unchanged.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 19:22:40 +01:00
Ian WorleyandClaude Opus 5.5 cd86e8f45d docs(native-mods): entities a client sees
Document native_client_entities with its new fields,
native_client_items, native_client_texture and skins by UUID, which
entities the list leaves out, and an example loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 eecfdf2f7b feat(native-mods): list the client's entities and dropped items
Client mods such as radars, name tags and spectator ESP need the
entities the client shows, with what they look like, without reaching
into Game's internals. API 411's native_client_entities had ids,
positions and player profiles, but no type, babyhood or texture, and no
dropped items.

- NativeClientEntity gains kind, yaw, baby, spectator and the texture
  its body draws with (mobs::render::body), plus position_at, the
  renderer's interpolation. The struct is non_exhaustive, so existing
  mods keep building.
- native_client_items lists the dropped items the client shows.
  native_item_entities stays server-only, so server logic never acts on
  a client's copies.
- native_client_texture returns TexturePixels by the name texture and
  NativeClientProfile::skin give: a downloaded skin from memory, else
  native_client_resources().texture(name).

Pending fragment client_entity_list. Tests: position_at between two
ticks, and a client fed the server's play packets reading a zombie, a
calf, a spectating player with its default skin and a dropped item
through Host, plus texture pixels and a refused path out of the packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 6d9ed21235 feat(native-api): read the client's resource files and texture pixels
Add the `native_client_resources()` helper. It returns `ClientResources`
over the resource tree the game loaded, which is vanilla's with the
resource packs turned on and the content mods' files over it. A client
mod fetches it on the game thread and can then use it on any thread.

`read(path)` returns a file's bytes for a plain path under `assets/`,
such as `assets/minecraft/textures/entity/cow/cow_temperate.png`.
`texture(name)` decodes a texture by name (`block/stone`,
`entity/cow/cow_temperate`) into `TexturePixels`. Paths outside
`assets/` return none, so Horizon's own prepared tables stay private.

Mods stop reading `options.resources` with `atlas_sources::texture_path`
and `prepared_files::read`. The tests build a folder resource pack over
a fake vanilla tree and check that the pack's texture wins, and that
paths outside `assets/` are refused.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:01 -06:00
Ian WorleyandClaude Opus 5.5 908903a58e feat(native-api): block states' facts and looks for client mods
Add the `native_block_states()` helper. It returns `BlockStates`, a copy
of every block state keyed by its `BlockStateId`. A client mod fetches it
on the game thread and can then use it on any thread.

Each state's `BlockStateFacts` holds the block name, air, invisible,
`FluidState`, waterlogged, light emission, outline boxes, map colour and
whether the state can collide at all. `sprites(id)` returns what the
state's model draws on each side, as `native_block_sprites` does for a
block name. `texture(sprite)` names the texture behind a sprite, the
inverse of `native_texture_sprites`. `mod_api` now also exports
`FluidKind`, the type `FluidState::kind` returns.

The table copies the game's internal facts into stable public types.
Client mods that draw blocks their own way, such as minimaps, world maps
and radars, no longer need `res.facts()`, `res.collision()` or
`block_sprites::Lookup`, which change from commit to commit.

Tests run on real prepared resources (ignored without
HORIZON_TEST_RESOURCES): grass block facts, air and light, slab
outlines, and a grass block's face texture names.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:00 -06:00
Florian ERNSTandCodex 3d2be9b1bd Validate command enchantment map entries against active definitions
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:12:28 +01:00
DUzzL 258dee9c8d Generate the trim texture of the smithing preview's result 2026-11-06 19:12:15 +01:00
apeirs 24036af2fb Mob block layers cull back faces like BlockModelRenderState
A mooshroom's mushrooms, an iron golem's poppy, a snow golem's pumpkin and a
copper golem's antenna block are submitted through BlockModelRenderState.submit,
whose block-item sheets (ITEM_CUTOUT, ITEM_TRANSLUCENT) cull back faces. Only the
enderman's carried block culled, so the coplanar faces of the other layers' cross
models z-fought as the mob moved (#5301).

The cull now goes by each quad's winding, as the GPU's does, rather than the baked
facing, which is the nearest axis and 45 degrees off for a cross model's planes.

Fixes #5301
2026-11-06 19:02:50 +01:00
65ee18174a Reduce repeated work in section meshing
Precompute row and direction masks, speed adjacent palette reads, and bypass
random model selection for a single unweighted variant.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 18:53:25 +01:00
DUzzL 10e24b4f32 Apply an item's custom data and entity data to a placed frame, painting or armor stand 2026-11-06 18:43:23 +01:00
echo-ilovech3ss 5303bf09af game: cover passenger rotation through /tp in rider test
Root cause: #9335 follow-ups from #8733 left passenger rotation after /tp covered only by parity corpora (tools/passenger_teleport_corpus.py), which cannot run without vanilla data. The Game-level rider test exercised offset carry (sub-item 1) but not the rider keeping its angle to the vehicle through absolute and relative turns (sub-item 2).

What changed: extend teleport_rider_tests::a_vehicle_carries_its_riders_and_a_rider_leaves_its_vehicle with a second horse/villager pair: absolute turn 0->40 leaves rider at 70 (30-degree offset kept), relative turn to 50 shifts rider 70->80, and facing (turn_riders=false) leaves rider at 80. No runtime change; the teleport/passenger writers already implement Entity.teleportSameDimension/calculatePassengerTransition via vehicle_teleport::passenger_rotation.

Vanilla reference: 26.3 Entity.calculatePassengerTransition, PositionMoveRotation.calculateAbsolute, Entity.setXRot (pitch clamp).

Refs #9335
2026-11-06 23:08:34 +05:30
MSedgeMC e524b6a114 test: an enchanted or damaged stack at the enchanting table
tools/test_enchanting_parity.py always built a fresh ItemStack for its offer
corpus, so EnchantmentMenu.slotsChanged's ItemStack.isEnchantable gate (the
enchantable component and an empty minecraft:enchantments) was never
exercised, and the oracle skipped the gate entirely. Fixture lines now carry
damage and a pre-existing enchantments entry; the oracle applies the gate and
the probe builds the same stack. The default corpus is unchanged and
--no-components reproduces it.

68992/68992 enchanting cases match.
2026-11-06 17:22:55 +00:00
DUzzL 533dd084ae Keep a latched sprint or sneak toggle out of the edit shortcuts' modifiers 2026-11-06 18:11:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f1fac7cc6f HORIZON_STILL: end crystals start their spin from their position
A crystal's spin and bob start from a random draw. Which crystal gets which
draw, and a server's entity ids that seed a client's copies, follow the order
chunks load in, so crystals stood at different angles in each launch of a
frozen scene. Under HORIZON_STILL the start now comes from the position; the
draw is still made, and normal play is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:05:51 +01:00
SenjinandClaude Opus 5.5 7b3be8d0b9 Shader packs: gbuffers_hand_water for translucent held items
The hand's quads from a translucent sheet (stained glass, ice...) go after the opaque ones in
its buffer and draw after deferred with gbuffers_hand_water (or its fallbacks: gbuffers_hand,
textured_lit, textured, basic), blended, as translucent terrain does; through gbuffers_hand
they came out solid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:04:31 +01:00
Kevin RamirezandClaude Fable 5.1 408f38d62a tests: the key-release fixture stubs screen_over_playing, which mapped_key_event asks now
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:52:52 -06:00
Kevin RamirezandClaude Fable 5.1 e68a0653a0 A book, sign editor or prompt over the world releases the held buttons (#9729)
Gui.setScreen runs KeyMapping.releaseAll for every screen and
Minecraft.tick skips handleKeybinds while one is up, books, the sign
editor, dialogs and prompts included. Horizon keeps Screen::Playing under
those, and its release-all (track_toggle_screens) and the client input
gate (remote_handle_input) only knew the server dialog, while the book's
and sign's mouse arms eat both edges of a click: a dig held into a book
went on after the book closed, until the next click.

gameplay_has_keyboard now takes "a screen is over the world"
(screen_over_playing) in place of the dialog alone, and remote_handle_input
is not ready while one is up.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:46:37 -06:00
TheMrZZandCodex gpt-6.1-sol 686d897f59 Normalize Mob Tags when adopting NBT
Adopt the bounded String input before duplicate removal in the two Mob NBT load paths. Keep the canonical retained state visible to real tag commands and saves, and pin cap, duplicate, scalar and live rewrite behavior against Java.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 17:38:11 +01:00
Florian ERNSTandCodex a8201731a1 Decode Item Owner and Thrower through numeric UUID streams
Co-Authored-By: Codex <[email protected]>
2026-11-06 17:27:10 +01:00
Kevin RamirezandClaude Fable 5.1 ff4fad66c9 The lead held in first person turns with the mouse, no jitter (#10276)
LocalPlayer.getRopeHoldPosition eases the lead's hand end from the view
to yRotO/xRotO by half the partial tick. Entity.turn moves yRotO and xRotO
by the mouse's delta too, and AbstractBoat.clampRotation moves yRotO with
the clamp, so only what the tick turned (a boat's deltaRotation) eases in.
Horizon's rope_rot_old was a once-a-tick snapshot the turn never moved:
while the mouse moved, the hand end lagged by half the turn since the
tick began and snapped back at the next tick, 20 times a second.

turn_player now moves rope_rot_old through the same Rotation::turn as the
view, and both boat clamps (the turn's and the tick's) move it with the
view. A test pins that a mouse turn leaves no gap, a boat turn eases, and
xRotO clamps at 90.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:22:24 -06:00
MSedgeMC a13fa8c13c port_map: HappyGhast's two save methods
`HappyGhast.addAdditionalSaveData` writes `still_timeout`, the timer
`still_tick` keeps in `counter`; `readAdditionalSaveData` reads it back and
a save without it is 0.
2026-11-06 16:17:03 +00:00
MSedgeMC 5c86d98a9b test: a happy ghast's still timeout survives the save
0, 7 and 10 through `mob_to_nbt` and `mob_from_nbt`: the Int vanilla writes,
the timer back on the mob, and `on_still_timeout` following it. Without the
writer the saved tag has no `still_timeout` (the test fails on `left: None,
right: Some(0)`).
2026-11-06 16:17:03 +00:00
MSedgeMC 9004c6c8d6 fix: a happy ghast keeps its still timeout in the world save
`HappyGhast.addAdditionalSaveData` writes `still_timeout`, the
`serverStillTimeout` behind `staysStill`/`isOnStillTimeout`, and
`readAdditionalSaveData` reads it back:

    protected void addAdditionalSaveData(ValueOutput out) {
        super.addAdditionalSaveData(out);
        out.putInt("still_timeout", this.serverStillTimeout);
    }

Horizon keeps that timer in `Mob.extra.counter` (`happy_ghast::still_tick`
counts it down after the first 60 ticks, `player_above` refreshes it to 10)
but neither `mob_to_nbt` nor `mob_from_nbt` touched it, so a ghast saved
while it was held still came back with 0. For the ticks vanilla restores, a
dropped item resting on its back had no box to rest on
(`EntityGetter.getEntityCollisions` takes a happy ghast only while
`isOnStillTimeout`), and a rider had no control (`getControllingPassenger`
asks `isOnStillTimeout`).
2026-11-06 16:17:03 +00:00
GabbotampoandClaude Opus 5.5 5fe1090401 Item components: refuse negative holder ids and holder set sizes
ByteBufCodecs.holder in vanilla 26.3 reads a VarInt and, when it isn't 0,
looks the entry up with `byIdOrThrow(id - 1)`, which no negative id passes.
skip.rs's `holder` accepted any nonzero id, so a slot whose kinetic_weapon
sound holder was negative was kept as a raw component, and hashing it for an
inventory click computed `id - 1` on i32::MIN: a panic with overflow checks
on, a wrapped id otherwise.

ByteBufCodecs.holderSet reads the size plus one and builds
`new ArrayList(size)`, which refuses a negative size. `holder_set` computed
`n - 1` before checking the sign, the same overflow for i32::MIN.

Both readers now refuse a negative value with a protocol error, so every
component that reads a holder or a holder set gets the same boundary.
Valid direct and registry holders read as before (their vanilla hashes in
hash/raw.rs's tests are unchanged).

Fixes #11222

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:07:33 +01:00
daMpoffandOpenAI Codex 0ab59f64d4 Fix overflow in translated launcher instance menus
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-06 19:02:25 +03:00
Kevin RamirezandClaude Fable 5.1 b0d94ed2c0 The player's own third-person model shivers when fully frozen (#11217)
LivingEntityRenderer.isShaking is isFullyFrozen (DATA_TICKS_FROZEN at or
over 140), and setupRotations wobbles the body while it is. Mobs and
other players already shiver, but the window's own model, rebuilt each
tick by tick_player_model, never got the player's frozen ticks, and the
window ignored the DATA_TICKS_FROZEN its server sends it, so a player in
powder snow stood still in third person.

The own model takes ticks_frozen from the player's body, and own entity
data index 7 sets it on the window's player as LocalPlayer reads it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:01:55 -06:00
Horizon-is-vibe-coded a4c8dc296d fix(checkerboard): clamp negative scale before shift
Guard against negative scale values producing excessive shift
distances in CheckerboardBiomeSource::noise_biome by clamping
scale to a non-negative lower bound before adding the +2 offset.

This keeps the shift distance within a sane range and avoids
surprising behavior for out-of-range runtime scale inputs.
2026-11-06 17:00:42 +01:00
Florian ERNSTandCodex 1d800a7655 Canonicalize floating zero when parsing text tags
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:57:32 +01:00
DUzzL 776bb4f4d5 Snap pistons-pushed shulkers to the block centre (Shulker.setPos) 2026-11-06 16:53:06 +01:00
Kevin RamirezandClaude Fable 5.1 9fb818013c An arrow leaves from where the player was when the bow was released (#8423)
ServerGamePacketListenerImpl handles a client's packets in order: the
RELEASE_USE_ITEM a client sends from handleKeybinds comes before the move
LocalPlayer.tick sends in the same client tick, so the server shoots from
the position of the move it handled the tick before, one tick of strafe
behind the camera. Hub::process_inbox applies moves at once and hands a
PlayerAction to the game for after the pass; it held a queued move behind
a dig, UseItem, UseItemOn and Interact, not behind the release, the drops
or the stab. The move ran first, and the arrow left a tick ahead of the
camera: strafing left, it seemed to come from the left instead of the
right.

Every PlayerAction now holds the queued move for the next tick. A test
drains a release followed by a move and checks the game gets the release
alone with the guest's position and known movement unchanged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:53:03 -06:00
Kevin RamirezandClaude Fable 5.1 efc682f297 A player boards a vehicle again right after leaving it (#11158)
Entity.removePassenger sets the leaving passenger's boardingCooldown to
60, but Player.removeVehicle overrides Entity.removeVehicle and zeroes it
right after super.removeVehicle(), so a player never waits to board again;
only mobs do. #8733 (from #8586, which missed that override) gave the host
and every guest the 60 ticks, checked by may_board before boarding a boat,
cart, cushion, ghast, llama, mount or nautilus: the delay the issue
reports.

The player's cooldown goes: the boarding_cooldown and riding_at_tick
fields of Game and Guest, their swap for a guest's turn, the per-tick
count-down and may_board at its eight call sites. Each interaction still
refuses a sneaking player, the other half of Entity.canRide. Mobs keep
extra.boarding_cooldown.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:44:13 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 0bd8f4c89c Shader packs: the scripted clock's step in decimal milliseconds
A 60 fps clock steps 16.666667 ms a frame, which a whole number of
milliseconds can't give. FrameTiming::nth takes the step as f64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:13 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e173055d5d Shader packs: a scripted clock that comes before STILL's ticks
PackInputs::frame_timing gives the frame number, the seconds since frame 1
and the seconds since the frame before. Iris-format packs take it for
frameCounter, frameTimeCounter and frameTime and smooth wetness,
eyeBrightnessSmooth and centerDepthSmooth by its delta; custom uniforms
start over on its frame 1 and carry their state after. Horizon packs take
it for time, frame_index and delta_time, ease by it, and do not treat a
slow frame as a gap. Nothing sets it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:13 +01:00
Florian ERNSTandCodex 5b44880bfb Reject unknown inline slot-range names before execution
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:26:05 +01:00
Kevin RamirezandClaude Fable 5.1 b2a6c25a6e A player saved while gliding comes back gliding (#11193)
LivingEntity.addAdditionalSaveData writes FallFlying as isFallFlying(), and
readAdditionalSaveData sets shared flag 7 from it, so a player who saves
mid-glide resumes the glide on the next load. Horizon wrote a fixed
FallFlying of 0 and never read it back: the player came back standing in
mid-air.

Level now keeps fall_flying, read from any numeric FallFlying tag (as
getBooleanOr does) and written from the guest's glide; playerdata's store
and apply carry it between the Guest and the file. The server's next
sync_player_state sends the 0x80 shared flag to the client, and
updateFallFlying's port ends the glide if it can't go on.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:24:52 -06:00
TheMrZZandCodex gpt-6.1-sol 942e212ca7 Fix unless-function continuation after fallthrough
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 16:20:21 +01:00
JoansitohandGrok <grok-4.7> e380e88edb Keep wither skulls in flight through a chunk save
Entity.shouldBeSaved is true for every projectile in 26.3, and
WitherSkull adds `dangerous` on top of the hurting projectile's
fields. Horizon listed WitherSkull as not saved, so a skull still in
the air when its chunk unloaded was gone when the chunk came back,
and a blue skull came back as nothing.

Write and read the skull the same way as the fireballs: `dangerous`
as a byte (absent means a black skull), and no `Fire` since
WitherSkull.shouldBurn is false. The fire rule now keys on the
fireballs proper (is_item_fireball) instead of "not blast-immune",
which would have lit a saved skull.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-06 16:18:52 +01:00
Florian ERNSTandCodex 5201202a30 Evaluate named entity_scores predicates for bound holders
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:59:55 +01:00
rison1337andCodex GPT-6 f5c1c04245 Stream dynamic light sources and bound nearest-light selection
Avoid owning held-item names and intermediate lists on every frame.
Keep stable nearest-16 lighting output and document FPS and image checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 19:48:30 +05:00
TheMrZZandCodex gpt-6.1-sol fcb378d2dd Emit mob command damage vibrations before returning
Post the existing damage event for accepted health-changing mob command hits so a same-tick teleport cannot move its origin. Preserve the existing duplicate marker and reinforcement handling; pin the real command/listener route against Java.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 15:42:35 +01:00
MSedgeMC 4062a09766 test: mine a block in creative too, where nothing drops
tools/test_block_removal_parity.py only ever mined as a survival player:
every mine case ran ServerPlayerGameMode.destroyBlock in SURVIVAL, so the
creative branch (Player.preventsBlockDrops, no mined statistic, no tool
wear) was never compared even though game.rs::break_block_as_player splits
on it. Half the mine cases now run in creative, with a tool that can destroy
blocks in creative (a sword and a mace cannot, which the creative-mining
predicate corpus already owns).

VanillaDispenserOracle's Miner gains a creative flag (isCreative and
hasInfiniteMaterials) and the real CREATIVE game mode; the game's dispense
fixture sets the same mode for the case. --survival-only reproduces the
corpus as it was before this change.

4000/4000 cases match on the new corpus.
2026-11-06 14:29:14 +00:00
Slavik-85andClaude Opus 5.5 791e721843 F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
The overlay formatted its lines and laid out its glyphs every frame. It now
paints at most 20 times a second and copies those vertices in between, and
repaints at once for another window size, GUI scale or font epoch.

- The refresh rate is read at most once a second: on Windows, winit calls
  EnumDisplaySettingsExW each time. The Window line uses the surface size.
- Passes are timed only while the overlay lists their times, at most 10 frames
  a second. gpu-passes and HORIZON_SHADER_PACK_TIMING keep every sample.
- The CPU name is read in the background when the window opens: on Windows it
  starts reg.exe, which the first F3 frame waited for.
- DebugEntries::any_enabled and status no longer build a String per entry; the
  game asks every frame, F3 closed or not.
- Closing the overlay stops the server thread computing its lines each tick.
- The Mem, Allocation rate and Allocated lines now show on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:44 +02:00
Florian ERNSTandCodex 48791d642e Expose loaded arrow Owner references in command NBT
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:26:21 +01:00
DUzzL 7f87be741b Keep golems off zombie horses, camel husks and zombie nautilus 2026-11-06 15:22:15 +01:00
Arslan ArmanulyandClaude Opus 5.5 5a44d6dd7f Shader packs: the sky's parts blend as vanilla draws them
gbuffers_skybasic drew every part of the sky with the translucent blend.
Vanilla 26.3 draws the sky disc and the dark disc with RenderPipelines.SKY
(ColorTargetState.DEFAULT, no blending), the sunset fan translucent and
the stars with RenderPipelines.STARS (BlendFunction.OVERLAY: colour
SRC_ALPHA ONE, alpha ONE ZERO). Iris 1.11.7 drew them so with probe packs
of ours: the sky disc kept exactly what the program wrote, (0.3, 0.6,
0.9, 0.25) in buffers of four formats, and the stars added 0.25 x 0.5 to
the disc's 1.0 and left their own alpha 0.5. Without a
blend.gbuffers_skybasic of the pack's own, the disc and the void, and
the stars, now draw with pipelines of the same program that blend so,
keeping a blend.gbuffers_skybasic.<buffer> the pack sets (Iris blended
the disc into that buffer only).

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:28 +05:00
Arslan ArmanulyandClaude Opus 5.5 54c4a07e61 Shader packs: a float output into an integer buffer no longer refuses its program
A program writing a float vec4 into an integer colortex (RGBA32UI...) was
refused: wgpu has no float output into an integer target, so the whole
program, and every buffer it writes, was lost. Iris 1.11.7 drew such a
program with a probe pack of ours: its other buffers kept what it wrote,
and the integer buffer read the floats' bits back (GL leaves that value
undefined; these are NVIDIA 615.78.08's). The output now writes the
float's bits as the buffer's integer type.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 1fd49b9082 Shader packs: noiseTextureResolution sizes the default noisetex
Horizon always made its default noise texture 256 wide. Iris 1.11.7
made it the size a pack's `const int noiseTextureResolution` gives, when
declared in a fragment shader (composite, deferred, gbuffers, final, or
shadow.fsh with no shadow pass), and ignored it in a vertex shader, in
probe packs of ours. The setting is now read like a colortex format.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:10 +05:00
Arslan ArmanulyandClaude Opus 5.5 892f24b15d Shader packs: the length of a buffer block's runtime array reads back
`.length()` of a runtime-sized array in a named buffer block made naga's
validator refuse the program: its GLSL frontend loads the array and takes
the length of the loaded value. After parsing, the length is now taken of
the array's pointer. Iris 1.11.7 ran such a program in a probe pack of
ours and read the buffer's length (bufferObject size / stride).

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 dde8c502ca Shader packs: a compute stage reading a shadow buffer turns the shadow pass on
Horizon decided whether to run the shadow pass from the samplers of the
programs' vertex and fragment stages only. A pack whose only reader of
shadowtex0 or shadowcolor0 was a compute stage (prepare.csh alone, or a
composite.csh beside a composite.fsh that reads none) got an empty map
(1.0), while Iris 1.11.7 drew it for probe packs of ours (0.3956 depth,
0.251 shadowcolor0 at our noon scene). Compute stages now count as the
other stages do.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 b31e63adc8 Shader packs: an R11F_G11F_B10F image is stored into and read back
Horizon made an R11F_G11F_B10F custom image as RGBA16F, then refused
every program that declared it layout(r11f_g11f_b10f), since the shader's
storage format no longer matched the texture. Iris 1.11.7 stored into such
an image and read it back, with a probe pack of ours: (0.25, 1.5, 3.0)
read back as written, alpha 1, also filtered between texels. The image is
now Rg11b10Ufloat where the device stores into that format; elsewhere it
stays the RGBA16F stand-in, and the shader's image declares that format.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:33 +05:00
Arslan ArmanulyandClaude Opus 5.5 e4bb7bf7ae Shader packs: the sun and moon leave their own alpha, as vanilla's celestial blend
The default blend of gbuffers_skytextured added the sun's alpha over the
sky's (One, OneMinusSrcAlpha). Vanilla 26.3 draws the sun and moon with
RenderPipelines.CELESTIAL, BlendFunction.OVERLAY: colour (SRC_ALPHA, ONE),
alpha (ONE, ZERO). Iris 1.11.7 left the sun's own alpha (2.0) in an
RGBA32F buffer the sky had written 1.0 to, with a probe pack of ours;
Horizon left 1.0.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:12 +05:00
DUzzL f7b204a231 Read mod features from worldgen/feature/, 26.3's folder 2026-11-06 15:17:45 +01:00
SamSu 6635e7a46a Launcher: rewrite the Thai translation (th.json)
Thank you for adding Thai to the launcher in #8306. We were really
happy to see it. When we used it, though, many lines were hard to
understand for us as Thai players. The translation looks
machine-made, and many words have their everyday meaning instead of
their computer meaning. We rewrote it so that Thai players can use the
launcher comfortably. 731 of the 1225 strings changed. The others
were already good, so we kept them as they were.

Below is each word we changed, what it is now, and how a Thai reader
understands the old word. We hope this makes our reasons clear, even
if you don't read Thai:

- build: อาคาร / โครงสร้าง -> บิลด์
  อาคาร means a building (a house, an office). โครงสร้าง means the
  structure of something. "Native build" read as "the original
  building". Thai developers and players say บิลด์, written as it
  sounds.
- instance: ตัวอย่าง -> อินสแตนซ์
  ตัวอย่าง means "example" or "sample", so "Instance" looked like a
  demo copy of the game, not a separate game setup. อินสแตนซ์ is the
  English word written in Thai, as Thai players say it.
- native (mod, build): ดั้งเดิม -> เนทีฟ
  ดั้งเดิม means traditional or original. "Native mod" read as "the
  original mod", which says nothing about compiled code. เนทีฟ is the
  usual Thai programming word.
- launcher: ตัวเรียกใช้งาน / ตัวเปิด / (left in English) -> ลันเชอร์
  The same thing had three names, so a player could not tell they
  were one program. ลันเชอร์ is what Thai players call it.
- jar: ขวด / โหล -> ไฟล์ jar
  ขวด is a bottle and โหล a glass jar for food. "Port gets the jars"
  read as "Port gets the bottles". It is a file type, so ไฟล์ jar.
- Loader: รถตักดิน -> ตัวโหลด
  รถตักดิน is a wheel loader, the construction vehicle.
- Client: ลูกค้า -> ไคลเอนต์
  ลูกค้า is a shop customer. Here it means the game client.
- Running / run: วิ่ง -> กำลังทำงาน / รัน
  วิ่ง is running on foot. "Running" read as "jogging". A program
  that runs is ทำงาน (working) or รัน.
- Proxy: หนังสือมอบฉันทะ -> พร็อกซี
  หนังสือมอบฉันทะ is a legal power-of-attorney letter.
- port: ท่าเรือ -> พอร์ต
  ท่าเรือ is a harbour for ships. A network port is พอร์ต.
- cape: แหลม -> ผ้าคลุม
  แหลม is a headland on a coast (or "sharp"). A Minecraft cape is a
  cloak, ผ้าคลุม.
- cosmetics: เครื่องสำอาง -> คอสเมติก
  เครื่องสำอาง is make-up (lipstick, powder). Thai gamers call
  in-game items to wear คอสเมติก.
- seed: เมล็ดพันธุ์ -> seed
  เมล็ดพันธุ์ is a plant seed for farming. Thai Minecraft players use
  the English word for a world seed.
- Home (page): บ้าน -> หน้าแรก
  บ้าน is a house. The Home page is the first page, หน้าแรก.
- Ban: บ้าน -> แบน
  Ban was also translated as บ้าน ("house"), so the ban button said
  "house". Thai players say แบน.
- founder (set): ผู้ก่อตั้ง -> ผู้เล่นรุ่นแรก
  ผู้ก่อตั้ง is the person who founded a company, so "Free for
  founders" read as "free for the people who started Horizon". The
  shop page says the set is for "the players who were here first", so
  it is ผู้เล่นรุ่นแรก, "early players". It is not "sponsors" either:
  the set is free.
- player counts: คน -> ผู้เล่น
  "{online} of {max} players" used คน (person). In a server list Thai
  players expect ผู้เล่น (player), as the game itself says.
- Java Edition: รุ่นจาวา -> Java Edition
  A product name, like Minecraft and Microsoft, which stay in
  English. รุ่นจาวา ("the Java version") reads as a Java release.
- 78 strings still in English -> translated
  They showed English in the middle of a Thai screen.
- Polite particle ครับ removed
  ครับ is the spoken "sir" that ends a sentence when a man talks. In
  UI text it sounds like a person talking, not a program.

We kept every {placeholder} and every middle dot of the English, and
we used one Thai word for each English term across the whole file.

The two of us, both native Thai speakers, read every changed line
(SamsVT and NotNiceKun). If anything still reads wrong,
please tell us and we will gladly fix it. Thank you for Horizon!
2026-11-06 21:11:40 +07:00
Florian ERNSTandCodex b127089760 Preserve literal modern sign string messages
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:04:48 +01:00
Delete228andCodex e575a22cb2 Use the isolated game environment for mob discard parity
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
AxxioandAntigravity 4e6b887535 multiplayer: narrate incompatible server version and motd
Narrate incompatible server list entries matching vanilla narration order: select -> incompatible -> version -> motd. Also remove the note about incompatible server status from docs/narrator.md known differences.

Closes #10317

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:49:21 +01:00
Florian ERNSTandCodex 4d3f9944eb Honor fork state for terminal execute condition errors
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:44:04 +01:00
AxxioandAntigravity 4e36ae1e4e docs/README.md: index live mod reloading design note
Link docs/design/mod-hotload.md under the Design notes section.

Fixes #11154.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:39:41 +01:00
TheMrZZandCodex gpt-6.1-sol 253a694871 Fix live mob custom-name component predicates
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 14:37:37 +01:00
AxxioandAntigravity bfdff7afb6 docs/native-client: update stale platform scope note
macOS and Windows are supported platforms with dedicated documentation under docs/platform/. Replace the stale 'macOS and Windows are out of scope' sentence with a reference to docs/platform/.

Fixes #11145.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:36:19 +01:00
60742e9c49 Save villager brain memories like vanilla
A villager's Brain.memories now keep golem_detected_recently, last_woken
and potential_job_site, and each memory's remaining ttl, through a save
and load (Brain.pack, MemoryMap.CODEC, ExpirableValue.codec). The brain
built at the first tick starts from the loaded memories.
last_worked_at_poi stays unsaved: the villager brain does not register it.

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
0e0fa342b8 Keep wandering trader homes like vanilla
Use the shared mob home for spawn, movement restrictions and persistence.

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
fa76ed4d94 Match vanilla hanging entity coexistence checks
Reject conflicting overlaps between paintings and item frames.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:36 +01:00
voidsip ff416a1889 Tick the gateway passenger player in the command oracle scene 2026-11-06 07:33:50 -06:00
AxxioandAntigravity 89dc5664de audio: support HORIZON_AUDIO_BUFFER override for Linux buffer underruns
On Linux, Horizon asks ALSA for a 1024-frame fixed buffer to keep sound effects low-latency. When PipeWire is configured with min-quantum >= 1024 (common for Bluetooth audio or in VMs), the 1024-frame buffer repeatedly underruns with POLLERR and produces no sound.

Allow setting HORIZON_AUDIO_BUFFER=default or HORIZON_AUDIO_BUFFER=<frames> (e.g. 2048) to override the fixed buffer request and use a larger or system-default buffer. Document the environment variable in native-client.md.

Fixes #11082.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:21:51 +01:00
voidsip a98ba02730 Allow parity runner defaults in dolphin and spell harnesses 2026-11-06 07:19:21 -06:00
Delete228andCodex 36232d8820 Restore the frozen tick decision before ending a sprint
Co-authored-by: Codex <[email protected]>
2026-11-06 16:17:27 +03:00
Florian ERNSTandCodex 85b299df9d Preserve Pillager and WanderingTrader saved inventories
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:13:19 +01:00
Delete228andCodex c968fb0d7d Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
Delete228andCodex 1bb6912a4b Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
DUzzL 7a46ba1f27 Show the dismount hint when the player starts riding 2026-11-06 14:09:41 +01:00
DUzzL f3d842d846 Seat other players on a cushion they sit on 2026-11-06 14:08:47 +01:00
Delete228andCodex 8f73091827 Format the native compatibility changes
Co-authored-by: Codex <[email protected]>
2026-11-06 16:05:48 +03:00
AxxioandAntigravity 03d9b39fc4 tests/test_harness_args: match system path separator for default work dir
DEFAULT is a Path object that formats with backslashes on Windows, while harness_args.py appends str(default). Pinning against str(DEFAULT) instead of the hardcoded POSIX string lets the test pass across all platforms.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 13:52:31 +01:00
Delete228 a5fa6f0d09 Keep empty native container provisioning out of inventory notifications 2026-11-06 15:51:42 +03:00
Delete228andCodex 04448803c8 Hide discarded mobs from immediate lookup and passenger saves
Keep removed mob entries out of command enumeration and UUID/ID lookup before
the normal sweep, and prevent a discarded passenger from saving inside its
vehicle. Preserve existing death-time and health rules.

Validate frozen Game commands/API37/save assembly against real26.3 discard
and entity-manager behavior with both client and server JARs.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:51:39 +03:00
AxxioandAntigravity 8f39c8afd2 docs: remove stale server End credits note
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 13:48:49 +01:00
Horizon agentandClaude Opus 5.5 34af7009f5 Multiplayer: drag a saved server by its row to move it in the list
Reordering a long server list took many clicks on the icon's small arrows or
Shift+arrow presses. The press that selects a saved server now also holds it:
moving the pointer puts it on the saved server's row underneath, the others
close up, and the release saves the order. A press on the selected server
still joins, as in vanilla, and holds nothing.

Fixes #10060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:38:53 +01:00
DUzzL 0f992292b7 Split slimes, magma cubes and sulfur cubes when the body is removed
Horizon split a dying cube at the moment of death. Vanilla
AbstractCubeMob.remove runs the split when the entity is removed, which
LivingEntity.tickDeath does after the death animation (deathTime 20),
so the smaller variants appear about one second later.
2026-11-06 13:37:46 +01:00
Delete228andCodex 21d66471d4 Bind content item aliases to ordinary block placement
Compile place_block declarations into the existing BlockItem binding table and load secondary item-to-block mappings for client prediction and server placement. Preserve alias item identity, component defaults and existing primary block-item mappings.

Validation: 49 compiler, 38 declaration and 63 item tests pass. Explicit prepared-resource tests verify original placement context IDs, real survival consumption, adventure refusal and dry client prediction. Strict clippy, formatting and public-tree checks pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:35:03 +03:00
Florian ERNSTandCodex 6e302fab48 Remove stale Animal LoveCause when no reference is modeled
Co-Authored-By: Codex <[email protected]>
2026-11-06 13:34:29 +01:00
Delete228andCodex b55e8616c1 Keep native entity data through creative Pick Block
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:32:57 +03:00
Horizon agentandClaude Opus 5.5 3365d72927 Native mods: client_attack and client_hurt hooks for the local player's combat
A client mod (battle music, a combat HUD) couldn't hear its own player's
fights on a remote server: the attack and damage hooks run where Horizon is
the server. native_client_attack(target, player) fires when the attack packet
leaves; native_client_hurt(attacker, player) when the server's damage event
for this player arrives, with the causing entity it names and whether this
client knows it as a player.

Fixes #10064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:31:31 +01:00
Riccardo GuerrieroandClaude Opus 5.5 f7af3f420f use_item_on's oracle posts item_interact_start when a use starts
The oracle's stand-in player overrode LivingEntity.startUsingItem and only recorded the item, so
the real vanilla vibration never ran: ItemStack.causeUseVibration posts ITEM_INTERACT_START when
the stack's use effects ask for interaction vibrations, which every item's UseEffects.DEFAULT
does (DataComponents.COMMON_ITEM_COMPONENTS). Since #10272 Horizon posts it, so the harness
reported 284 false mismatches (4916/5200). It now calls causeUseVibration under the same guard
as vanilla: 5200/5200.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:29:30 +01:00
Horizon agentandClaude Opus 5.5 ee55bb1e75 Dropped items fall through minecarts, as in vanilla
An item's move takes getEntityCollisions: the entities whose
canBeCollidedWith(item) holds. AbstractMinecart overrides only
canCollideWith (for its own moves), so a cart keeps Entity's false and
an item dropped on it falls through; the item list chained the carts in.
Boats, living shulkers and a held happy ghast still hold items (#11091).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:28:51 +01:00
Horizon agentandClaude Opus 5.5 1b221b01da Native mods: native_take_screenshot takes a screenshot as F2 does
A client mod (timed or repeated screenshots, as fabrishot does) asks for the
next frame's screenshot through F2's own path: screenshots/, vanilla's file
name, the chat line. It returns the file it will write; an error without a
window, or while another screenshot waits for the frame, which it would
otherwise replace.

Part of #9606

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:27:06 +01:00
Horizon agentandClaude Opus 5.5 3815013235 Native mods: native_open_chat opens the chat with text typed in
A client mod (a command macro such as CommandKeys) can open the chat screen
with text pre-filled for the player to finish, as T and / open it. Nothing is
sent. An error with no player in a world or while another screen is open.

Part of #10120

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:23:56 +01:00
Delete228andCodex ec74bd066b Add item fallback after native mob interactions pass
Keep entity actions and API 29 ahead of item fallback, reuse normal packet and crosshair selection, and let the first response consume with or without an acting-hand swing. Cover bounded native Mob entries with a pending fragment, generated dispatch and actual Game priority/filter tests; leave other living interaction tables explicit.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:46 +03:00
Delete228andCodex 3de12c0c7c Allow held-item break effects to name an explicit equipment slot
Keep ordinary wear, Unbreaking, durability criteria, stack removal and break handling in one path. Existing damage_held callers retain MainHand behavior; callers that model an offhand use may select OffHand without editing stack damage directly.

Validation: native prepared-resource Game test verifies offhand wear, break event48, unchanged main-hand stack, legacy event47 and creative immunity. Strict gameplay clippy, formatting and public-tree checks pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:44 +03:00
Horizon agentandClaude Opus 5.5 c960a329ce Detect the machine's architecture in horizon-telemetry
horizon-client-render forbids unsafe code, which the Windows ARM64 build
reported (IsWow64Process2 and sysctlbyname are FFI). The detection lives with
the other system probes in horizon_telemetry::system now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:20:50 +01:00
Riccardo Guerriero 01954d87c5 Merge remote-tracking branch 'origin/main' into fix/illager-target-memory 2026-11-06 13:19:46 +01:00
Ludovic Marie 41f822b659 Merge pull request #10301 from foudretbr/feat/screen-transparent-background-hook
🤖 Agent: foudretbr's rebase onto main (a1ff75a4a) drops the stray `);` that broke `tests/mod_api_hooks.rs`, and the file-wide import reorder. The hook change is the same 9 files: with no mod loaded, every translucent screen background is still the vanilla gradient. The 10 hook tests and tests.test_stamp_native_hooks pass.
2026-11-06 13:19:07 +01:00
Ludovic Marie d6fd737e31 Merge pull request #10382 from voidsipp/fix/10255-slow-event-note
🤖 Agent: merged main so this builds again (its old base still called `mob_to_nbt` without the UUID lookup that #10544 fixed). Burst notes are unchanged: one group per packet kind, each naming the player who spent the most time; the 7 burst and event-timing tests pass on the merged head.
2026-11-06 13:19:03 +01:00
rison1337andCodex GPT-6 52766de1c4 Skip falling-block mesh preparation behind the camera
Cache conservative envelopes of every baked model alternative, invalidating
on mutable model access. Reject rear-origin falling blocks only when their
complete padded model is outside the posed camera's depth clip planes.
Depth-only clipping preserves TAA jitter and screen-edge geometry; shader
packs keep their original path.

Include geometry/cache regression tests and separate ordinary/TAA FPS and
image comparisons, with front/empty controls and an oversized model fixture.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 17:19:01 +05:00
Ludovic Marie ce25432200 Merge pull request #11086 from horizon-rust/docs/readme-refresh
🤖 Agent: README/docs-media only; keeps the Project status block and '### Java parity' table the site and tests read, so playhorizon.dev's parity number is unaffected.
2026-11-06 13:18:59 +01:00
Delete228andCodex 15405e4ccf Route standard block fluid helpers through native-owned storage
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:16:38 +03:00
Horizon agentandClaude Opus 5.5 bf4657da26 README: a cleaner page with fresh screenshots of today's game and launcher
The README's pictures were from mid-October (launcher 0.2.30, the old
menus), so this retakes them and tidies the page around them.

- Pictures: Nightly 2026-11-05 (66f3981) windowed on the Mac mini's M4
  Pro (Metal) with seed 8675309: a river valley with a village and a long
  view, a cherry-grove sunset, a crimson forest with a piglin, survival
  with the HUD, and two players over LAN; the launcher 0.2.47's Home (with
  those screenshots in its instance) and Mods pages. WebP, 712 KB in all,
  under docs/media/readme/. The five README-only images they replace are
  removed; village/nether/underwater.webp stay (the launcher embeds them).
- Text: a short pitch, what works in the current release, a download
  table with each platform's file and requirements (Windows x64 and Arm,
  macOS Apple Silicon and Intel from 12, Linux glibc), the Microsoft
  sign-in requirement, mods and shader packs (Java mods don't run;
  modpacks import), hosting, agent control, build from source,
  contributing, and Mojang's "NOT AN OFFICIAL MINECRAFT PRODUCT" line.
- Fixed claims: macOS Intel and Windows on Arm are out (not "from the next
  Nightly"); the known-issues line points to docs/known-issues.md.
- Two badges instead of seven: the parity and gap badges the tests and
  tools/parity_gaps.py keep in sync. "Project status", its gap block and
  the "### Java parity: N%" table are unchanged, so the website still
  reads its number and #build-from-source/#project-status still resolve.

Builds on #11040 by Web-mc1, who first noticed the launcher pictures were
outdated and retook them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:14:09 +01:00
Horizon agentandClaude Opus 5.5 594552fec4 Hosting: an AMP template for horizon-server, beside the Pterodactyl egg
packaging/server/amp/ holds a template for CubeCoders AMP's generic module.
Its update stage runs the egg's own install script (the release asset checked
against its SHA-256), the start runs the same horizon-start.sh, and its
settings are the egg's variables with the same defaults. Tests keep both in
step and check the console patterns against the lines the server logs.

Fixes #8238

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:13:17 +01:00
Horizon agentandClaude Opus 5.5 3c6c890993 Drop the selector criterion run-log doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:20 +01:00
Horizon agentandClaude Opus 5.5 0efe1c2c13 Player effects also leave out ambient false and show_particles true
MobEffectInstance.Details.MAP_CODEC uses optionalFieldOf("ambient", false)
and ("show_particles", true), so like amplifier 0 those defaults are not
written (the mob writer already does this). The harness gets a default
work dir, so tools/parity.py can run it with no arguments; the run-log
doc is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:18 +01:00
Horizon agentandClaude Opus 5.5 a2090d0b0f Entity boolean harness: default work dir; drop the run-log doc
tools/parity.py runs each harness with no arguments, and this one exited
asking for --work-dir; it now uses a fresh folder under .local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:18 +01:00
Horizon agentandClaude Opus 5.5 5b12729b5f Happy ghast ambient goes through Mob::voice; drop the capture harness
Mob::voice already plays at the feet (Entity.playSound), stays quiet for a
Silent entity, uses getSoundVolume and HappyGhast's pitch 1.0, so the
special arm is not needed and a Silent happy ghast no longer sounds. The
test checks the Silent case too. The 238-line capture harness ran a Java
server, a native server and a client for 1200 ticks on every ai.rs
change; the resource test covers the position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:14 +01:00
Horizon agentandClaude Opus 5.5 d3d24aca55 Optimize World: cancel within a chunk, one sync per region file
- Each worker looks at the cancel flag before every chunk, not only before
  every file, so Cancel stops within a chunk on large region files. A
  canceled file is never swapped in by recreate_region_files.
- The writes of a region file reach the disk once, when the file is done,
  instead of a sync_data after every chunk. On the ThinkPad (NVMe, ext4) the
  per-chunk sync was half of a 1.21.1 world upgrade (187 s -> 89 s, one
  thread); on macOS sync_data is F_FULLFSYNC and costs far more.
- The calling thread is worker 0 and helpers are named World Upgrader #N;
  a helper that fails to start leaves its files to the others.
- Tag modifiers are no longer cloned per chunk; the recreate folder is made
  once per file.
- Tests: the parallel/one-thread comparison also checks vanilla counts
  computed from the fixture; a new test cancels from the first chunk with 4
  workers and checks at most 4 chunks were looked at and that a canceled
  recreate leaves the world unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:08:06 +01:00
Horizon agentandClaude Opus 5.5 cf20ccc4f9 rustfmt the new toolchain test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:07:27 +01:00
Horizon agent 4f7fadfd9e Merge remote-tracking branch 'origin/main' into pr-11049 2026-11-06 13:06:39 +01:00
voidsip 58a5690ec6 Add native client sound parameter interception 2026-11-06 06:05:23 -06:00
Horizon agentandClaude Opus 5.5 11d40ff1b9 Native mods: quinn 0.11 (QUIC) in Cargo.lock through horizon-mod-deps
Mods that speak QUIC (e4mc's relay) couldn't build: their dependencies must
already be in Horizon's lock. quinn joins horizon-mod-deps as an optional
dependency (feature quic: runtime-tokio + rustls-ring, so TLS stays on the
rustls + ring already in the lock). Nothing in the game builds it.

Fixes #9602

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:04:20 +01:00
Horizon agentandClaude Opus 5.5 33c431e3ae Docs: Intel Macs and Windows ARM64 have builds now
The README and the hosting guide still said Intel Macs come "from the next
Nightly" and listed no Windows ARM64 launcher; both ship in every Nightly and
launcher release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:02:53 +01:00
Florian ERNSTandCodex d2b61ade91 Omit stale default power from saved TNT
Co-Authored-By: Codex <[email protected]>
2026-11-06 13:01:50 +01:00
Horizon agentandClaude Opus 5.5 c19d97699d Windows ARM64: the x64 build emulated there also starts on DirectX 12
Auto already tries DirectX 12 before Vulkan on Windows ARM64, where Vulkan lost
the device (#7513, #6320 on Adreno), but it read the build's architecture: the
x64 game, which the x64 launcher installs on a Snapdragon PC, still started on
Vulkan. The order now follows the machine's architecture (IsWow64Process2), and
crash reports name the emulation ("x86_64 on aarch64"), on Windows and under
Rosetta 2 on macOS (sysctl.proc_translated).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:00:51 +01:00
DetimesandClaude Opus 5.5 cdd4ffe95d Format native_bots.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:00:29 +00:00
Horizon agent f217a34593 Merge main into #10240
# Conflicts:
#	tools/port_map/client-004.tsv
2026-11-06 13:00:17 +01:00
Horizon agent f15541c0fe Merge remote-tracking branch 'origin/main' into pvp-2/sneak-input
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-06 12:59:58 +01:00
Horizon agent 965c9d9aed Merge main into #10219
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-06 12:59:46 +01:00
Horizon agent 97f85f4530 Merge main into #10540 2026-11-06 12:57:33 +01:00
Horizon agent 4ce74ca81c Merge main into #10565 2026-11-06 12:56:03 +01:00
Horizon agent 2dcb671e13 Merge main into #10546 2026-11-06 12:55:54 +01:00
Horizon agent 964d919d96 Merge main into #10542 2026-11-06 12:55:19 +01:00
Horizon agentandClaude Opus 5.5 904e1d5597 Video settings: LOD Distance gets fine stops from 16 to 256 chunks
A small render distance (8 chunks on a phone or an integrated GPU) could only
add far terrain out to 64 chunks, the slider's first stop. The slider now stops
at 16, 24, 32, 48, 64, 96, 128, 192 and 256 before doubling as before.

Fixes #9694

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:55:06 +01:00
Delete228andCodex fd61fa626e Expose block clone stacks to native mods
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:53:55 +03:00
voidsip db25d1d092 Match NBT path quoted escapes and index tokens 2026-11-06 05:53:36 -06:00
Ludovic Marie 398da21689 Merge pull request #10372 from Skr1zzzz/tab-shown-10108
🤖 Agent: merged #10312 (with main) in. The cache compares the whole Scoreboard twice per frame (displayed_ids and layout) and clones it on every change, so a server ticking a sidebar score every tick rebuilds and clones each frame; still cheaper than the old double sort, and tab_list parity passes on a union holding this head.
2026-11-06 12:50:14 +01:00
Ludovic Marie 297a718908 Merge pull request #10312 from Skr1zzzz/mp-fps-4664
🤖 Agent: merged main in: #10328 (merged meanwhile) now caches the layouts of the tags your collect_name_tags keeps, and #10098's new hide_gui argument is passed in your early tab_list::visible check too, so F1 still hides the list on the skip path. Skipping a tag whose entity box (inflated 0.5) is off the frustum matches vanilla, where shouldRender gates the name tag with the entity. 194 selected harnesses run on a union holding this head: tab_list and hud pass, the 8 failures are identical without it.
2026-11-06 12:50:08 +01:00
DUzzL 8a78ad3315 Apply death_protection death_effects for players and mobs, read mob hands by component 2026-11-06 12:48:18 +01:00
voidsip 82dc682cbc Parse server properties numbers like Java 2026-11-06 05:48:05 -06:00
Evrim CicekandClaude Opus 5.5 09160dc1e2 Merge upstream/main into fix/mounted-attack-box
Minecarts and primed TNT now seat their riders from the riders map;
the branch's seated box is kept on that path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:46:14 +03:00
voidsip f1ef5cb53a Expose local particle spawning to native client mods 2026-11-06 05:43:33 -06:00
Ludovic Marie 28a389b7a3 Merge pull request #10557 from Detimes/feat-10404-older-clients-1b
🤖 Agent: shelf mushroom stands in as brown_mushroom_block (solid, not the walk-through brown mushroom), explorer maps as filled_map; 26.2 refusal from #10477 still in place
2026-11-06 12:42:26 +01:00
Horizon agentandClaude Opus 5.5 8ead30efe3 Native mods on Intel Macs: the launcher brings Rust for x86_64 macOS
The x64 macOS launcher (#1161) installed and ran the game, but native mod builds
stopped with "the launcher has none for macos x86_64": its managed toolchain
had no x86_64-apple-darwin host. Add Rust 1.99.1's official rustc, cargo and
rust-std for it, pinned by the SHA-256 static.rust-lang.org publishes; the
linker is the system's, as on Apple Silicon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:41:15 +01:00
atsauban 052d9ebf20 fix(launcher): support responsive wrapping on narrow windows
Prevent vertical letter-by-letter text stacking and control clipping
when running in narrow tiling or compact displays:
- Top bar switches to compact tab padding and hides long subtitle
- Home actions and subtabs adaptively scroll and wrap without overflowing
- Mods and Versions headers stack title and filters vertically
- Settings navigates via top scrollable pill bar on narrow displays
- About tiles and Account cards stack cleanly
- Setting rows wrap controls instead of shrinking labels to 0 width
2026-11-06 18:40:50 +07:00
Evrim CicekandClaude Opus 5.5 d1ce5ddfd9 Merge upstream/main into fix/iron-golem-toss
Upstream's UUID bridge (#10304) now writes a mob anger target's UUID
through mob_to_nbt's uuid_of. The branch keeps its two additions on top:
a target read from the save that is not in the world keeps the loaded
UUID, and the loaded angry_at goes once the anger is over.

A mob only gets its UUID when it is first saved, so an anger target
not saved yet came out with no angry_at (wolves too). mob_tree_nbt now
gives a target that is still in the world its UUID first; the new
anger_at_a_mob_not_saved_yet_names_it test failed without that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:37:40 +03:00
Horizon agentandClaude Opus 5.5 25ce3945c8 Roadmap: link 22 proposal issues to their items; launcher accessibility and local builds as Later items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:37:18 +01:00
Florian ERNSTandCodex 40b1fc9100 Save default Interaction dimensions and response
Co-Authored-By: Codex <[email protected]>
2026-11-06 12:35:47 +01:00
Detimes a61d6fa54f Merge remote-tracking branch 'upstream/main' into hooks-bots 2026-11-06 11:34:12 +00:00
TaquitoSuabe 38d23bc13c gfx: skip broken Haswell Vulkan driver (hasvk) and fall back to OpenGL
- Mesa's hasvk Vulkan driver for Intel Haswell (Gen 7.5, vendor 0x8086) reports conformance version 0.0.0.0 and suffers from severe vertex buffer corruption and polygon tearing across 3D terrain (#9912).
- Check is_broken_haswell_vulkan in open_with_retry to skip non-conformant hasvk on Haswell and fall back cleanly to Mesa crocus OpenGL 4.6 Core Profile.
- Preserve Ivy Bridge (IVB) on Vulkan (#4059).
2026-11-06 04:31:50 -07:00
Delete228andCodex c4c0e6fc6b Expose dropped-item block contact to native mods
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:25:58 +03:00
DUzzL 1fc4fa2529 Upgrade region files in parallel in Optimize World 2026-11-06 12:21:55 +01:00
voidsip d2d046b1be Honor mapped function keys in keyboard input 2026-11-06 05:18:42 -06:00
DetimesandClaude Opus 5.5 964454e1f8 Test that a mod's health 0 kills a guest, in and out of their turn
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +00:00
foudretbr ae2e74af51 Native mod API: transparent screen background hook and menu blur
Fixes #10131

# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 12:17:57 +01:00
DUzzL 62028656e0 Save common Entity fields for registered Markers 2026-11-06 12:15:42 +01:00
VaspyyyandClaude Opus 5.5 6f2b33ff95 Launcher Settings: update status and Check now move to Updates
Horizon's update status (Up to date, Install) and the launcher's version with
Check now / Update sat under About, while the Updates section held only the
two auto-update switches. They now open Updates, in both looks: the Glass
tiles above the switches, the classic rows at the top of its Updates group.
About keeps the data folder (now a row like Logs), logs and the applications
menu.

Fixes #11035

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:14:33 +01:00
voidsip 5fd9d40d4c Validate packet resource identifiers when decoding 2026-11-06 05:14:09 -06:00
DetimesandClaude Opus 5.5 270d067afa Keep the feedback of a captured vanilla command
A capturing server source is not silent: it takes every sendSuccess and
sendFailure line and informs no operator, so run it that way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:13:31 +00:00
Detimes d0a80d6eb8 Merge remote-tracking branch 'upstream/main' into native-refresh-commands 2026-11-06 11:06:23 +00:00
DUzzL 3a0d345f06 Use the death-protection item, not the totem id, for the totem animation
The integrated server matched the used item by id (minecraft:totem_of_undying)
and used it up before the client looked for it, so any other item with
death_protection showed a plain totem in the activation animation.

Vanilla LivingEntity.checkTotemDeathProtection takes the first hand whose
stack has DataComponents.DEATH_PROTECTION, keeps a copy before shrink(1), and
uses that copy for the stat, the used_totem trigger and the animation.

Part of #10587
2026-11-06 12:01:57 +01:00
ScholiboiandClaude Opus 5.5 09ac779466 Pearls on a server's client hover in bubble columns as vanilla's do
ThrowableProjectile.tick runs on the client too: handleFirstTickBubbleColumn
before gravity, then applyEffectsFromBlocks' BubbleColumnBlock.entityInside
after the move, with Projectile's unclamped pushes (+0.06 inside, +0.1 at the
surface, -0.03 dragging down). The client's remote_projectile_tick had none of
it, so a pearl held in a stasis chamber on a server sank between the tracker's
updates and snapped back on each one.

Projectile::tick_remote adds both for ThrowableProjectiles, through a new
ProjectileWorld::bubble_column hook (bubble_column_at for the game's world).
The integrated and dedicated servers keep pushing their pearls in the
creature tick, so their projectiles are not pushed twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:27 +05:30
Riccardo Guerriero cd7ce3024a Merge remote-tracking branch 'origin/main' into fix/piglin-admired-drop 2026-11-06 11:58:16 +01:00
DetimesandClaude Opus 5.5 962d7c6a22 Native helpers: per-player feedback, SNBT and map helpers move out of the hook points
They sat in the hook-point section, so the catalog test took them for hooks and hook_points_stay_hookable saw world_items!; now in the helpers' impl block of the same file (targets unchanged), the macro written out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:57:17 +00:00
Riccardo Guerriero 8eb1e7cf4f Merge remote-tracking branch 'origin/main' into fix/illager-target-memory 2026-11-06 11:55:42 +01:00
4e94325ba5 Evokers and illusioners keep an unseen target for 15 seconds
Their target goals call setUnseenMemoryTicks(300): the evoker's player
and villager goals, the illusioner's player, villager and iron golem
goals. Pillagers and vindicators keep the default 60.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:55:42 +01:00
Riccardo Guerriero 225bcde8b9 Merge remote-tracking branch 'origin/main' into fix/trade-display-drop-chance 2026-11-06 11:52:39 +01:00
1dd1969640 Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:52:39 +01:00
Horizon agentandClaude Opus 5.5 7e13949bcf 26.2 stand-ins: a solid block for the shelf mushroom, filled_map for explorer maps
The shelf mushroom is a solid shelf players stand and bounce on (ShelfMushroomBlock's shape); the brown
mushroom it stood in as has no collision, so a 26.2 client would fall through what the server stands on and
be pulled back. Its block now shows as brown_mushroom_block, whose collision holds the shelf's; the item stays
brown_mushroom. The new explorer maps are filled maps (MapItem), so they show as filled_map, which draws their
map_id, not as an empty map. v26_2_ids.rs regenerated with --substitutes. TooltipDisplay's writer no longer
allocates a list on every write.

Review follow-up for #10557 (Detimes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:51:51 +01:00
Ludovic Marie a1ff75a4a5 Merge pull request #11021 from horizon-rust/fix/audit-issue-flood
🤖 Agent: tooling only. When more than 20 harnesses newly fail at once, the audit now opens one issue instead of 421, and the open-issue lookup no longer stops at 200.
2026-11-06 11:51:49 +01:00
Ludovic Marie bedc6fbeda Merge pull request #11012 from Web-mc1/main
🤖 Agent: tooling only. ensure_work_dir now returns the folder argparse picks, so the --work-dir=PATH spelling no longer crashes four harnesses on sys.argv.index.
2026-11-06 11:51:46 +01:00
DetimesandClaude Opus 5.5 304340fa9f native_boss_bar: check the bar's ranges without a negated call in the hook points
hook_points_stay_hookable reads `!(` as a macro.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00:00
DetimesandClaude Opus 5.5 830ee7c7f7 Catalog test: chunk_generate and lod_column live in horizon_world::worldgen::native_generate
The test maps each free hook to its module; the two world generation hooks are free functions in horizon-world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00:00
Riccardo Guerriero ad6e268383 Merge remote-tracking branch 'origin/main' into fix/sleeping-mob-push 2026-11-06 11:49:31 +01:00
445ba1168e Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:49:31 +01:00
Horizon agentandClaude Opus 5.5 31310a69f6 harness_args: return the work dir the run uses
Four harnesses looked the directory up with sys.argv.index('--work-dir'), which raises
ValueError for the --work-dir=PATH spelling this PR keeps. ensure_work_dir now returns the
directory argparse will pick (the last one given) and they use that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:48:41 +01:00
Horizon agentandClaude Opus 5.5 e517343add Nightly audit: one issue when many harnesses newly fail at once
The 2026-11-06 audit tested a main commit that did not compile (mob_to_nbt arity in raids.rs) and opened
421 "Parity regression" issues, one per harness. Above 20 new failures the audit now opens one issue with
the list and the first compiler error, and keeps those harnesses' previous results so the next audit
compares them again. The open-issue lookup no longer stops at 200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:46:50 +01:00
Riccardo Guerriero d49f1a0444 Merge remote-tracking branch 'origin/main' into fix/mob-block-fall-sound 2026-11-06 11:46:33 +01:00
d2fbe234ba Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:46:33 +01:00
DetimesandClaude Opus 5.5 ba4d3d471f Document the player bot helpers in native-mods.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:44:19 +00:00
DetimesandClaude Opus 5.5 a6a93455e8 Add native_simulate_player_move and native_collision_boxes for server-side player bots
One tick of a server player's own movement from a bot's input, played on a
copy of the body with the movement prediction's replay code, and the block
collision boxes in a box (Level.getBlockCollisions). Read-only; nothing runs
unless a mod calls them. Helper fragment player_bots, docs, unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:44:05 +00:00
DUzzL c545bd4cb1 Keep one world border per level 2026-11-06 11:34:26 +01:00
DetimesandClaude Opus 5.5 a0bb275b62 Native mods: native_refresh_commands rebuilds the players' command trees at run time (#10812)
A Game helper (Host::refresh_commands for trait mods): at the start of the
next server tick every mod's native_register_commands is asked again,
LanHost::native_commands and the hub's mod tree are rebuilt, and every
placed player is sent their tree again after native_player_commands_sent,
as PlayerList.sendPlayerPermissionLevel / Commands.sendCommands after
/reload. Hub::store_mod_commands keeps the new tree without sending it, so
each player gets one filtered tree. Servers whose mods never call it are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:28:45 +00:00
SenjinandClaude Opus 5.5 d8160a3ded Shader packs: at_velocity for entities
Each entity's vertices are matched with last frame's by entity (Frame::entity_motion, now with
the GPU-posed start at each mark): last frame's pack buffer stays bound at set 1, binding 2, and
binding 3 gives each vertex its index there. at_velocity is this frame's view position less last
frame's (gbufferPreviousModelView joins the block when a pack reads it); a vertex with no match
reads zero, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:26:13 +01:00
Detimes fce1fb23d6 Merge remote-tracking branch 'upstream/main' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 10:26:00 +00:00
Detimes ef66a2db57 Merge remote-tracking branch 'upstream/main' into hooks-b-all 2026-11-06 10:26:00 +00:00
DetimesandClaude Opus 5.5 4198a4de7f Tests: dispatch-test stubs for the new hook types (EntityDataValue, NativeGenerate, NativeProtoChunk, NativeLodColumn, TickSpan)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:21:12 +00:00
Detimes d1288fc34b Catalog test: log_line's target is horizon_client_lib::native_client_hooks
The test maps each free hook to its module; log_line (hooks-10461-a) lives in native_client_hooks.rs.
2026-11-06 10:18:21 +00:00
HiuoyandClaude Opus 5.5 3eda4b1b14 Native hook catalog: tooltip_render_pre is RenderTooltipEvent.Pre; Next hooks have all landed
`tooltip_render_pre` (#10302) is labelled `RenderTooltipEvent.Pre`, so
`horizon-port-pack events` counts the 10 ATM10 mods that listen to it.
The "Next hooks" table in docs/native-hooks-neoforge.md listed
`player_tick_pre` and the client tick pair, which have now landed: the
section lists them with the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:16:33 +04:00
DetimesandClaude Opus 5.5 7750a91768 Tests: the respawn guest test from main fills Joined.configured (player_channels)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 0580048c9d Clippy: then_some for the closing native store's slots (menu_input)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 82f9fbf1ad Clippy: no explicit deref and sort_by_key in the generation hooks (chunk_generate, lod_column)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:13:59 +00:00
Web-mc1 8ccf1404e7 harnesses: keep the --work-dir a caller gives
Five command-oracle harnesses looked for a bare `--work-dir` and appended
their default anyway when the caller wrote `--work-dir=PATH`. argparse takes
the last value, so the caller's directory was silently replaced and the run
wrote its scratch output somewhere they did not ask for. They now share the
guard test_boat_leasher_parity.py already used, and tests/test_harness_args.py
pins both spellings.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 10:13:20 +00:00
1de40d7cd3 A use that swings the hand resets the attack charge
ServerPlayer.swingAndResetAttackStrength: the guest's block, item, leash
knot and entity uses reset the attack ticker when their swing starts.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:12:32 +01:00
Horizon agentandClaude Opus 5.5 4fa3bf699b Merge main into #10501 (main builds again after #10544)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:09:00 +01:00
e3ca63226c Casting and reeling a fishing rod and throwing an eye of ender make the vibrations vanilla's do
- FishingRodItem.use posts cast and retrieval vibrations using the stack's use effects.
- EnderEyeItem.use posts a projectile-shoot vibration.

Part of #9776.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:07:40 +01:00
77f00c606d A villager's held-up trade item never drops when it dies
ShowTradesToPlayer sets the main hand's drop chance to 0 when it shows
an offer (displayAsHeldItem) and back to 0.085 when it clears the hand
(clearHeldItem).

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:07:25 +01:00
5751bbd65f Native hooks: PlayerInteractEvent.EntityInteractSpecific, the point a player right-clicks on an entity
Add the native hook `entity_interact_specific` (NeoForge's `PlayerInteractEvent.EntityInteractSpecific`): a right-click on an entity hears the point clicked, measured from the entity's position, before `native_interact_entity`; a claim swings the hand and ends the click. Server-side.

Co-Authored-By: DeepSeek Pro via OpenRouter <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:58:31 +04:00
Detimes 624ac54cdc Dispatch tests: stub horizon_client_core::text and horizon_protocol::packets::login
player_pre_login and player_profile name those paths in their hook signatures; the generated
dispatch code compiled in the test crate needs them, as the existing horizon_client stubs.
2026-11-06 09:56:23 +00:00
Horizon agentandClaude Opus 5.5 1c0fabcd7e Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:56:05 +01:00
aba1b1eca4 Interpolate whole-cell volumes four columns at a time; skip air runs at once
Part of #9996.

A chunk's noise volume is made of whole cells, and InterpolatedSampler
filled it one cell at a time: per cell eight corner loads and four Z
lerps, per column two X lerps and a bounds-checked index, then a scalar
loop of stores and additions (fill_cell alone was 6.4% of the Overworld
proto phase's instructions). For a volume aligned to its cells,
fill_whole_cells now walks the four columns of one Z row of a cell from
the bottom of the volume to the top. The X lerps at a level of corners
are computed once (they are the top of one cell and the bottom of the
next: the same lerps of the same samples), the four columns' repeated
additions run as four lanes, and four Ys of the four columns are stored
as four vectors. Each value comes from the same operations on the same
operands in the same order as vanilla's InterpolatedFunction fillCell;
only the order between columns changes. The top cell may be cut short
(the 16x114x16 volumes); a volume that is not aligned, or whose top
corners would clamp, keeps the general path.

fill_noise_inner spent about ten instructions on each air block above
the aquifer's skip height. A downward run of non-positive densities that
stays above it is now skipped in one step: nothing was written for those
blocks and note_skipped only clears a flag, so one call covers the run.

Exact: chunkgen_bench --snapshot files (terrain and decorated) are
byte-identical to main in 16 cases: the Overworld at seeds 12345, -1,
987654321 and -7, the Nether at seeds 12345 and 42, the End at seeds
12345 and 7, sizes 3 to 12. A new test compares sample_volume and the
new path with a transcription of vanilla's sampleWithBlockStep and
fillCell, on an input with NaNs, infinities, signed zeros and
subnormals, for 4x8, 8x4, 4x4 and 8x8 cells, and checks which volumes
the new path takes.

Proto-phase instructions (callgrind, 64 chunks, one thread):
Overworld 5.970 G -> 5.615 G (-5.9%), the Nether 2.868 G -> 2.853 G
(-0.5%).

A first draft by GLM-5.3 and DeepSeek V4.1 Flash, via pi, reviewed and
reworked (the air-run skip).

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: pi coding agent (GLM-5.3) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:56:01 +01:00
Detimes bce9527c43 player_client, server_player_state fragments: mod_api.rs is not a comment path
The stamp tool fills mod_api.rs's markers itself and refuses it in "comments".
2026-11-06 09:55:22 +00:00
Detimes 402534f462 run_command_as: run_guest_command_told takes the hub with ? (clippy::question_mark) 2026-11-06 09:55:15 +00:00
Detimes fb9ce454ed Build fix: one more entity_nbt test call of mob_to_nbt gets uuid_of
The same #10304 argument as in the earlier build fix; this call sits in a test only pr_ready's test run compiles.
2026-11-06 09:55:15 +00:00
Detimes f830672067 Merge remote-tracking branch 'upstream/main' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 09:50:14 +00:00
Detimes 0df8f65eda Merge remote-tracking branch 'upstream/main' into perf-region-inflate-in-place
# Conflicts:
#	crates/horizon-world/src/compression.rs
2026-11-06 09:49:51 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol c667277fcd chore: merge the updated shader identity core
Preserve the launcher layer while carrying main and the replacement shader screen from the game PR.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:49:49 +01:00
GIGABAIT93andClaude Opus 5.5 10ec872cc1 docs/api/content.md: where a launcher puts content and what it checks
The install side of the Launch API, experimental: what a build loads (the
new `content`), the folder and the on/off file of each kind under
--game-dir, when a change applies, native mods needing a rebuild, what to do
with a .hpack, and the checks before installing as mod-index.md specifies
them (where it comes from, size and SHA-256, the minisign key, revoked[] and
installed copies, the manifest). Listed in docs/api/README.md. Part of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:49:14 +02:00
a2b395d91a A piglin always drops the item it admires, and one that isn't an ingot stops it despawning
Piglin.holdInOffHand equips the item with setItemSlotAndDropWhenKilled,
a guaranteed drop even without a player's kill, and makes the piglin
persistent unless the item is a gold ingot.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:46:42 +01:00
ae31791c33 A sleeping villager, fox or player isn't pushed by the mobs that walk into it
LivingEntity.push(Entity) does nothing while the entity sleeps, and
doPush reaches the other entity through it. The mob snapshot carries
sleeping (a villager in bed, Fox.isSleeping) and push_entities skips the
push. The sleeper's own tick still pushes the others.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:46:41 +01:00
Horizon agent 1fa86c919f Merge remote-tracking branch 'origin/main' into review-10557 2026-11-06 10:46:37 +01:00
makscoder b7dbeda41a A brush stops on a mob in the crosshair
BrushItem.onUseTick aims with getHitResultOnViewVector, so a mob between the
player and the block is the nearer hit and the use releases without a stroke.
tick_brush used the block-only pick and brushed through the mob.
2026-11-06 12:45:11 +03:00
GIGABAIT93andClaude Opus 5.5 2710305be4 --launch-api says what content this build loads
`content` in horizon-client's reply, from the constants the build checks
content against: content mods' manifest api lines (APIS without the removed
WebAssembly 0.1), native mods' hooks level and that they need a rebuild, the
shader schema and the line it draws, the resource and data pack formats, the
modpack format and the index schema. A launcher offers only what works. The
generated reference shows the hooks level as a placeholder, since it rises
with every hook. Part of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:44:04 +02:00
makscoder c1de12ab86 A brush or goat horn that uses itself up sends the interaction vibration
LivingEntity.stopUsingItem, reached when a use runs out or when the brush hits
no block, causes the ITEM_INTERACT_FINISH vibration. The brush and instrument
paths cleared the use directly instead, so only letting go of the button made a
sculk sensor hear them.
2026-11-06 12:42:48 +03:00
Ludovic Marie f4dcf8a2fb Merge pull request #10545 from horizon-rust/fix/revert-10416
🤖 Agent: main merged in (the old base had the mob_to_nbt test breakage, since fixed on main); still only the revert of #10416.
2026-11-06 10:42:19 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 1295602deb fix(shaders): integrate the new settings screen
Merge main at 0873f24d2, retain its replacement shader screen, and route native selections and options through stable manifest IDs.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:40:49 +01:00
Florian ERNSTandCodex c87e5a6893 Omit the default amplifier from saved player effects
Co-Authored-By: Codex <[email protected]>
2026-11-06 10:39:48 +01:00
Ludovic Marie 0b2f2fda73 Merge pull request #10475 from Hiuoy/feat/player-tick-pre
🤖 Agent: the pre-tick hook swapped each guest's whole state into Game every tick even with no mod loaded; I pushed a guard so `as_guest` runs only when `mod_api::mods()` isn't empty. The fragment passes tests.test_stamp_native_hooks.
2026-11-06 10:39:30 +01:00
DetimesandClaude Opus 5.5 f416696038 cargo fmt: native_npcs before native_packets in game.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:38:41 +00:00
Horizon agent ef64ea9d3e Merge remote-tracking branch 'origin/main' into fix/revert-10416 2026-11-06 10:38:23 +01:00
Florian ERNSTandCodex 54cdfe00fe Load entity boolean flags through NBT byte conversion
Co-Authored-By: Codex <[email protected]>
2026-11-06 10:37:37 +01:00
Detimes 03ded99473 Merge remote-tracking branch 'upstream/main' into feat-10404-older-clients-1b 2026-11-06 09:31:26 +00:00
Ludovic Marie 0873f24d20 Merge pull request #10525 from Web-mc1/main
🤖 Agent: verify failed because bake_bricolage.py imports fontTools before it reads its arguments, and CI has no fontTools. I pushed a change that checks the arguments first, so the new usage test passes without fontTools installed.
2026-11-06 10:30:01 +01:00
Ludovic Marie 823cf263ff Merge pull request #10510 from megabytesme/fix/windows-icon-required
🤖 Agent: #10398 asked that a local build in a folder with a space still builds, so I pushed a fallback for GNU windres when the volume has no 8.3 names: outside CI it warns and builds without the icon instead of failing. With the taskbar-icon commit, `cargo check --target x86_64-pc-windows-gnu` passes on the desktop for horizon-client and horizon-client-input.
2026-11-06 10:29:57 +01:00
Hiuoy e5d96bad14 Merge remote-tracking branch 'origin/main' into feat/player-tick-pre 2026-11-06 13:29:54 +04:00
Ludovic Marie 296ac7fbf6 Merge pull request #10379 from voidsipp/fix/10102-prune-region-transactions
🤖 Agent: a failed region already restores its originals, so blocking the server start on any prune error took a server down for one corrupt chunk record. I pushed a change that starts it when no journal is left, with a supervisor test for it. Note that delete mode now writes regions one at a time with fsync, so a large prune takes longer than the old parallel rewrite. 169 horizon-server tests pass on Linux.
2026-11-06 10:29:53 +01:00
Hiuoy a6661aab7e Merge remote-tracking branch 'origin/main' into feat/9921-port-pack-events 2026-11-06 13:29:26 +04:00
Hiuoy 2c92825807 Merge remote-tracking branch 'origin/main' into feat/anvil-update 2026-11-06 13:29:23 +04:00
Riccardo Guerriero ed739050ae Merge remote-tracking branch 'origin/main' into fix/9759-golem-peaceful 2026-11-06 10:27:42 +01:00
Riccardo Guerriero 1caa6c8931 Merge remote-tracking branch 'origin/main' into perf/9996-startup 2026-11-06 10:27:23 +01:00
Riccardo Guerriero ddb365e6b4 Merge remote-tracking branch 'origin/main' into perf/9996-light 2026-11-06 10:27:23 +01:00
Ludovic Marie 64f691f9f7 Merge pull request #10544 from martipops/fix/raids-mob-to-nbt
🤖 Agent: raids.rs now passes the same `persist.uuid_of` lookup persist.rs gives `mob_to_nbt`, and the four entity_nbt.rs test calls get `|_| None`, so main builds again after #10304 (verify green on this head).
2026-11-06 10:26:17 +01:00
Ludovic Marie 0167f90c29 Merge pull request #10347 from PzmuV1517/shaders/menu-3-translations
🤖 Agent: translations only; %s placeholders kept and every file parses. The languages you flagged as uncertain (ba_ru, cv_cu, sah_sah...) stay worth a native speaker's look.
2026-11-06 10:26:13 +01:00
Ludovic Marie 28d84f2078 Merge pull request #10346 from PzmuV1517/shaders/menu-2-drop-old-pages
🤖 Agent: removes the old pack pages, their two slider kinds and four lang keys in all 87 files; nothing else read option_label_within, checked with clippy -D warnings on the stack over current main.
2026-11-06 10:26:09 +01:00
Ludovic Marie 5b16680739 Merge pull request #10337 from PzmuV1517/shaders/compat-6-paste-flat-matrix-offset
🤖 Agent: The non-constant texel offset becomes offset / textureSize(level 0) added to the coordinate: right for the base level, but at a sampled lower mip the shift is in base-level texels, not that level's (GL moves by texels of the level read). Fine for Arc's FXAA at level 0; worth a note if a pack hits it with textureLodOffset.
2026-11-06 10:26:05 +01:00
Ludovic Marie 086633c199 Merge pull request #10336 from PzmuV1517/shaders/compat-5-iris-macros
🤖 Agent: CAT_* macros come from biomes::CATEGORIES, so they pick up #10499's CAT_MOUNTAIN 17 and CAT_UNDERGROUND 18 with no second table to keep in sync; your test comparing every macro with constants() covers that.
2026-11-06 10:26:00 +01:00
Ludovic Marie fb75cde631 Merge pull request #10335 from PzmuV1517/shaders/compat-4-glsl-leniency
🤖 Agent: The constant evaluator now refuses a non-finite F32 fold with Overflow, and only GLSL function bodies turn that into a run-time expression, so WGSL input still rejects 1.0/0.0 at compile time. Vendored naga's own 133 lib tests pass with the chain.
2026-11-06 10:25:56 +01:00
Ludovic Marie 869d1aa812 Merge pull request #10334 from PzmuV1517/shaders/compat-3-integer-samplers
🤖 Agent: The integer-sampler helpers texelFetch the texel under the point with the coordinate clamped to the edge, which is what GL's nearest read of an integer texture gives; a bias argument is accepted and ignored, as GL does for these. Stack 3/6, checked with the rest of the chain on current main (clippy -D warnings, horizon-shaders and render tests).
2026-11-06 10:25:52 +01:00
2af85dcc84 A mob's damaging fall plays the fall sound of the block it lands on
LivingEntity.playBlockFallSound: the block 0.2 below the feet, at half
its volume and three quarters of its pitch, not for a silent mob or air.
Most mobs play it after their own fall sound, horses and llamas after
the damage, and a llama's damaging fall under six blocks plays it alone.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:24:38 +01:00
DUzzL 6285cca37c A thrown trident hurts other players on a served world (ThrownTrident.onHitEntity) 2026-11-06 10:19:18 +01:00
Horizon agentandClaude Opus 5.5 44a12076d5 Swap a guest in for player_tick_pre only when a native mod is loaded
as_guest swaps a player's whole state into Game: running it for every
player every tick with no mod to call cost the server for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:17:18 +01:00
DetimesandClaude Opus 5.5 fd2ec97a17 Native hooks: chunk_generate and lod_column reach mods through a callback table
horizon-world cannot call horizon-client's dispatch, so the two generation hooks
call a NativeGenerateHooks table that entry.rs installs (as native_load_hooks
does for the loot hooks). mod_api re-exports NativeGenerate, NativeProtoChunk
and NativeLodColumn for the generated events. Without the table, and without
mods, both hooks do nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
DetimesandClaude Opus 5.5 9b08e99433 Native hooks: mod_api contracts for the 16 new hook fragments, dispatch calls filled
Since #6253 the mod API generator needs a mod_api entry for every pending hook
fragment. Cancel flags get {cancel, out: cancel}, menu_input_closed collects its
slots, every other hook listens. The placeholder bodies are filled by
`cargo run -p xtask -- mod-api`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
Skr1zzzz 079d82f968 Text displays: keep their glyphs, and skip the ones whose text is off screen
Every frame each text display built a batch, emitted its styled glyphs and
transformed every vertex, and a display of width or height 0 (what plugins
summon for holograms) is noCulling, kept to the distance rule because its
transformation can put the text anywhere: 200 of them cost ~0.3 ms a frame
with none on screen (#4664).

Each display's local-space text quads are now kept, rebuilt when the text,
its style, opacity, background, shadow, see-through, the font epoch or the
GUI atlas revision change (obfuscated text every frame), and only
transformed per frame. A noCulling display is skipped when the box of its
quads, through this frame's full matrix, misses the frustum: it can't draw a
pixel. Displays with a size keep vanilla's box test; the view range stays.
2026-11-06 12:13:04 +03:00
Detimes a93e07da89 Merge branch 'b-bundle-18' into hooks-b-all 2026-11-06 09:12:35 +00:00
Detimes 37dfe7d880 Merge branch 'b-bundle-17' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/mod_api.rs
#	docs/native-mods.md
2026-11-06 09:12:34 +00:00
Detimes 58ba651c7b Merge branch 'b-bundle-16' into hooks-b-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 09:12:15 +00:00
Detimes 950cd1058f Merge branch 'b-bundle-15' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:12:08 +00:00
Detimes 93ff841913 Merge branch 'b-bundle-14' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:11:56 +00:00
Arslan ArmanulyandClaude Opus 5.5 6199a21cbe Merge origin/main into shaderpacks/same-size-buffer-declarations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:11:26 +05:00
Detimes 4cd44b1bfa Merge branch 'b-bundle-13' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 09:11:11 +00:00
Detimes 3dc1eac9a1 Merge branch 'b-bundle-12' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/lan/hub.rs
#	docs/native-mods.md
2026-11-06 09:10:59 +00:00
Detimes 1065005412 Merge branch 'b-bundle-11' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:10:36 +00:00
PzmuV1517 f4146eea7c Shader packs: rustfmt the pack loading call 2026-11-06 11:10:17 +02:00
Detimes 71e2f6b0f5 Merge branch 'b-bundle-10' into hooks-b-all 2026-11-06 09:10:07 +00:00
Detimes 702055471b Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 09:10:01 +00:00
Hiuoy 503727b594 Merge remote-tracking branch 'origin/main' into feat/anvil-update
# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 13:08:39 +04:00
Hiuoy 9f4b5e0a80 Merge remote-tracking branch 'origin/main' into feat/9921-port-pack-events
# Conflicts:
#	crates/horizon-port-pack/src/lib.rs
2026-11-06 13:08:29 +04:00
Hiuoy 41eec6978b Merge remote-tracking branch 'origin/main' into feat/player-tick-pre
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-06 13:07:52 +04:00
Detimes bf7e616ebf hooks-all: integration fixes for the merged hook branches
- NativeEffect (player_effects) and NativeInventoryKind (set_inventory) are pub and re-exported in
  mod_api: the generated mod API refused both fragments.
- native_teleport_player's cross-level teleports fill LevelTeleport's new cause (hooks-move) with
  "unknown"; player_teleport's docs name that case.
- lan_vehicles: the vehicle_moved_v2 call closure is FnMut.
- cargo fmt (game.rs mod lines in order).
2026-11-06 09:00:30 +00:00
Detimes 8887870ec3 Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 08:58:30 +00:00
martipopsandClaude Opus 5.5 88cddcec82 Tests: pass mob_to_nbt its UUID lookup in entity_nbt's tests too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:55:48 -05:00
Alysia Zappavigna bc6b668820 Format the pickable projectile tests 2026-11-06 19:54:27 +11:00
Detimes 21d7853cc9 Merge branch 'hooks-pread' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:10 +00:00
Detimes b2ec69a247 Merge branch 'hooks-vehicle' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:09 +00:00
Detimes 23bb236cfc Merge branch 'hooks-pstate' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:08 +00:00
Detimes af12d6d443 Merge branch 'hooks-login' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:07 +00:00
Detimes b74f04855d Merge branch 'hooks-inv' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 010504d207 Merge branch 'hooks-move' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 24876b536b Merge branch 'hooks-cmds' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:04 +00:00
Horizon agentandClaude Opus 5.5 aad7542283 Keep the local no-alias fallback for GNU windres
#10398 asked that a local build in a folder with a space still builds: a
volume without 8.3 names gives windres no usable path, so outside CI
such a build warns and goes on without the icon, as before. CI and
every other case still fail without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:50:46 +01:00
2a931e3e63 Peaceful drops a mob's player target and a golem's anger
On Peaceful, vanilla never lets a player be a mob's target
(`LivingEntity.canAttack` refuses a player there), so a mob that already
held one gives it up. A neutral mob also stops being angry and forgets
who hurt it (`NeutralMob.stopBeingAngry` calls `setLastHurtByMob(null)`).
Horizon dropped a player target only for creative and spectator players,
so an iron golem that had been angered kept attacking after the
difficulty was set to Peaceful.

Fixes #9759.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
Co-Authored-By: Codex gpt-6.1-terra <[email protected]>
2026-11-06 09:50:35 +01:00
Detimes 5699b862d6 Merge branch 'hooks-perm' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:52 +00:00
Detimes 90a7a7d47b Native mods: a compound mods keep on a player (player_persistent_data)
native_player_persistent_data(uuid) reads and
native_set_player_persistent_data(uuid, data) replaces the compound mods
keep on a player, as NeoForge's Entity.getPersistentData() and Bukkit's
PersistentDataContainer. It is saved in the player's file as
NeoForgeData, beside the keys Horizon keeps without modelling them, and
kept over a death and a level change; an empty compound takes the key
away. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 08:49:26 +00:00
Detimes 66b3059251 Merge branch 'hooks-10461-d' into hooks-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 08:49:13 +00:00
Detimes 341896762f Merge branch 'hooks-10461-c' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:11 +00:00
Detimes 513b1fe43d Merge branch 'hooks-10461-b' into hooks-all
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/menu.rs
#	crates/horizon-client/src/native_client_hooks.rs
#	docs/native-mods.md
2026-11-06 08:48:54 +00:00
Alysia Zappavigna be4ce23d67 Let projectiles strike ghast fireballs, as Projectile.isPickable does (fixes #10547) 2026-11-06 19:48:44 +11:00
Detimes b99b9e13f7 Native hooks: item_dropped_v2 can cancel a throw (unproven on a server)
native_item_dropped_v2(player, stack, cancel) is now cancellable, as
NeoForge's ItemTossEvent is: a canceled throw puts the stack back into
the player's inventory (Inventory.add, the held slot first); only what
does not fit is thrown, unheard, and native_item_dropped hears nothing.
The drop statistics and the swing still happen, as in NeoForge. Wanted
by the combat log, freeze and inventory-lock ports.

Unproven: this commit was neither compiled nor run (builds were stopped for
the integration test); `cargo run -p xtask -- mod-api` filled the dispatch.
The listen-only v2 drop hook before it was proven on a server.

Part of #10461.
2026-11-06 08:48:11 +00:00
Detimes c2ec210fea Merge branch 'hooks-10461-a' into hooks-all 2026-11-06 08:47:51 +00:00
Detimes 9edbea0a6d Native hooks: thrown projectiles at the world border (projectile_world_border)
native_projectile_world_border(dimension, entity, kind, pos, velocity,
collide) is heard each tick a thrown projectile (pearl, snowball, egg,
potion...) inside the world border would end its next step outside it,
before 26.3 stops it on the border like a wall (MC-310476). A mod may
change its position and velocity, or set `collide` false: that tick's ray
is then clipped without the border, so it flies through as before 26.3.
Arrows and tridents, which Horizon does not stop at the border, are not
heard. Static (no self), called from tick_projectiles.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes c6ed7f75a0 Native hooks: farmland trampling, cancellable (trample)
native_trample(dimension, entity, kind, pos, fall_distance, cancel) is
heard when a player or mob that fell onto farmland is about to turn it to
dirt, once FarmlandBlock.fallOn's chance and checks have let it (NeoForge's
FarmlandTrampleEvent, Bukkit's PHYSICAL PlayerInteractEvent and
EntityInteractEvent). Canceling keeps the farmland and its crop; the fall
damage is taken either way. The landing rules ask through a new
BlockLandingWorld::trample_allowed (true by default), which the player's
and the mobs' landing worlds answer from a function the game sets once
(block_contact::TRAMPLE); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 5caff4ecb4 Native hooks: where a dead player respawns (player_respawn_position)
native_player_respawn_position(player, name, dimension, pos, yaw, pitch)
is heard once per respawn, after the Respawn click and before the player
is placed (NeoForge's PlayerRespawnPositionEvent, Bukkit's
PlayerRespawnEvent.setRespawnLocation), with their respawn point: a bed
or anchor block's top, a forced point, else the world spawn. Left alone,
the point is resolved as before. A changed dimension or position places
the player exactly there, with no bed or anchor used and no free column
looked for; yaw and pitch set their look, which Hub::respawn now takes
instead of always 0, 0. An unknown dimension or a non-finite value
leaves that part unchanged.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 893eb8d636 Native hooks: a player's teleport before it reaches their client (player_teleport)
native_player_teleport(player, name, cause, from_dimension, from,
to_dimension, to, relative, cancel) is heard before a server player's
teleport is sent (Bukkit's PlayerTeleportEvent, NeoForge's
EntityTeleportEvent): commands (/tp within a level and into another),
ender pearls, chorus fruit, the spectator teleport menu, a dismount and
other server moves of the player. A mod may move `to`, cancel (the
player and their body stay where they are), or set the look and motion
bits of the position packet's relative flags, so a pearl can keep the
player's speed (0x1f8, what 26.2 sent); the position bits are ignored.

The call sites name the cause in a thread-local (lan::hub::teleport_cause)
that Hub::teleport and Hub::teleport_keeping_motion read; a teleport
without a named cause (respawns, portals, the End gateway, vehicles
carrying riders, waking up, refused moves) is not heard. Command and
spectator teleports into another level carry their cause in
LevelTeleport. The hub reaches the hook through a function the game sets
once (lan::hub::PLAYER_TELEPORT); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 8e83a2c898 Native hooks: a player's move before it is applied (player_move)
native_player_move(player, name, from, to, yaw, pitch, on_ground,
cancel) is heard for each position or look packet that changed either
and passed the server's checks (moved too quickly, moved wrongly, the
anticheat), before Hub::position_change applies it (Bukkit's
PlayerMoveEvent). Canceling sends the player back to `from`, as a
refused move does; a changed `to` puts them there and their client gets
a teleport. Riders, sleepers and moves while a teleport waits are not
heard. The hub reaches the hook through a function the game sets once
(lan::hub::PLAYER_MOVE), as the damage hooks do; there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 0c8fb31992 Native mods: respawn a dead server player without the Respawn click (respawn_player)
The helper native_respawn_player(uuid) respawns a dead server player as
their client's PERFORM_RESPAWN does (Spigot's player.spigot().respawn()),
after the players' packets of the server tick, through the same
guest_respawn. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes d117673fe9 Native mods: let a server player fly in any game mode (set_flight)
The helper native_set_flight(uuid, allow, flying) lets a server player fly
in any game mode or not and sets whether they fly now, as Bukkit's
Player.setAllowFlight and setFlying: the guest keeps the mod's mayfly
(allow_flight) until their game mode changes or they respawn, their
abilities packet and the server's body carry it, and the flying check does
not kick them. Without a mod allow_flight stays false and every check is
the game mode's, as before.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes 2efd4b8346 Native mods: seat an entity on a vehicle, take it off, empty a vehicle (mount)
The helpers native_mount(entity, vehicle), native_dismount(entity) and
native_eject_passengers(vehicle) seat a server player or mob on a boat,
minecart or mob, take it off, or take every rider off, as /ride does with
its checks and native_entity_mount (Bukkit's addPassenger, leaveVehicle,
eject). /ride's mount checks move into ride_mount, which both use; the
command's answers are unchanged. Nothing calls the helpers without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes a8c67d77aa Native hooks: a ridden vehicle's move, cancellable and with its id, mounts included (vehicle_moved_v2)
native_vehicle_moved_v2(player, dimension, vehicle, kind, from, to, cancel)
is heard right after native_vehicle_moved, with the same state swapped in,
and also for a mob a server player rides. A canceled move puts the vehicle
back at from with its riders, a changed to puts it there
(native_teleport_vehicle), as Bukkit's VehicleMoveEvent with a teleport.
v1 keeps its signature, firing point and vehicles. Without mods nothing
moves the vehicle.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 052443e2a5 Native mods: teleport a vehicle with its riders (teleport_vehicle)
The helper native_teleport_vehicle(dimension, id, pos) teleports a boat,
minecart or ridden mob of the level in place with its riders, as /tp does
a vehicle (Bukkit's Vehicle.teleport): the riders stay seated and a server
player riding it is told, as their client drives it. Only where nobody's
state is swapped in. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 38a9866a1f Native mods: teleport a server player from any server hook (teleport_player)
The helper native_teleport_player(uuid, dimension, pos, yaw, pitch)
teleports a server player as /tp does (Bukkit's Player.teleport): at once
when they are the acting player or nobody's state is swapped in, else
after the players' packets of the server tick, so a port no longer runs
execute in .. run tp from the console, which waits for the next tick and
does nothing inside a player's hook. Into another level it is a command's
level teleport. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes aa8f5fc12a Native mods: send a player's command tree again (resend_commands)
The helper native_resend_commands(player) sends a player their command
tree again, with native_player_commands_sent, its v2 and the permission
nodes of the mods' commands asked anew, as Bukkit's Player.updateCommands:
after a permissions mod changed what they may use. The tree goes out in
the next server tick, with no player's state swapped in, so the helper
works from any server hook, the player's own included. Nothing calls it
without a mod.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 329acc6c53 Native mods: the level one typed command runs at (player_command_level)
native_player_command_level(player, name, command, level) is heard in
Game::guest_command after native_player_command, before the server checks
the command: level comes in as the player's own and becomes the level this
one command runs at (GuestCommand.permission), so a permissions mod can
grant /gamemode to a player of level 0 or refuse a command to an operator,
as Paper's VanillaCommandWrapper does with minecraft.command nodes. A mod
command with a permission node falls back to this level. Unchanged, the
command runs at the player's level, as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 0bd927b7c1 Native mods: a permission node on a mod's command (NativeCommand::permission)
NativeCommand::new(usage).permission(node) gives a mod's command a node, as
Bukkit's plugin.yml permission: does. The server then asks
native_has_permission(player, node, level), the command's level as the
default, both before it puts the command in a player's command tree and
before a player's typed command reaches native_perform_command, so a
permissions mod can grant it below that level or refuse it to an operator.
Without a permissions mod the level decides, as for a command without a
node; a dedicated server's console runs every registered command as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 3cc7e3c822 Native mods: put a command the level hides back into a player's tree (player_commands_sent_v2)
native_player_commands_sent_v2(player, name, names, names_hidden) is heard
right after native_player_commands_sent, at the same times, with the root
commands the player's level hides (those a level 4 operator would get) in
names_hidden. A root a mod moves into names is sent with its subtree, as
for an operator of its level, so a command a permissions mod grants
completes and highlights in the player's chat box (Paper's
PlayerCommandSendEvent after a passed permission check). v1 keeps its
signature and firing point. Without mods nothing is granted and the tree
is unchanged.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
Detimes 25ac7a9841 Native mods: permission nodes, answered by a permissions mod (permission_check)
The hook native_permission_check(player, node, default_level, result) lets a
permissions mod (a LuckPerms port) say whether a player has a permission
node; the first mod to set result decides. The helper
native_has_permission(player, node, default_level) asks it, for every mod
and for the server, and falls back to the player's operator level against
default_level when no mod answers, as Bukkit's Permissible.hasPermission and
NeoForge's PermissionAPI.getPermission do. Without a permissions mod every
answer is the operator level, so nothing changes for unmodded servers.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
MegaBytesMe cfdca1dd6e fix: set Windows taskbar icon 2026-11-06 08:45:12 +00:00
DUzzL 379850ac5c Never build an exit portal for a spectator using a nether portal 2026-11-06 09:41:40 +01:00
Horizon agentandClaude Opus 5.5 e096a156a1 Check bake_bricolage's arguments before importing fontTools
CI has no fontTools: the import failed before main() could print the
usage, so the new test saw a traceback (verify failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:41:09 +01:00
DUzzL 99b969686e Stop a deflected potion at the breeze and use its own random 2026-11-06 09:41:05 +01:00
TheMrZZandCodex gpt-6.1-sol 4a20680924 Fix HappyGhast ambient sound position
Use the entity position for the special ambient caller and cover adult and baby natural events through the real Mob tick and recording route.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 09:40:23 +01:00
Horizon agentandClaude Opus 5.5 85a5195411 Revert "Natural spawning: a start's height ends at the column's first empty block" (#10416)
parity-main: natural_spawn first fails at f6a9444de (#10416), of 182 merges
since 66f3981f5. The spawn start heights it produces differ from vanilla 26.3
by up to tens of blocks (chunk/10 monster: vanilla y 99, Horizon 62), so
players would see mobs spawn at the wrong heights. Reverted until the change
passes the natural_spawn harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:38:57 +01:00
Detimes fe7635e545 Cargo.lock: the 12 packages of the new horizon-mod-deps entries (unbuilt)
Written by hand from a scratch resolution seeded with this lock: rusqlite,
libsqlite3-sys, fallible-iterator, fallible-streaming-iterator, hashlink,
vcpkg, yaml-rust2, arraydeque, gif, weezl, color_quant, snap. Existing
entries keep their versions; chrono gains serde and mysql_common gains chrono.
To confirm: cargo metadata --locked must not want to change it.
2026-11-06 08:38:14 +00:00
Detimes 71b36cca26 Native mods: a store menu the hub closes on its own gives the cursor back too (removed) (unbuilt) 2026-11-06 08:37:33 +00:00
DUzzL ac4045d83f Keep the worn-armor snapshot per LAN guest 2026-11-06 09:37:03 +01:00
Detimes ca623aae34 Native mods: another player's inventory in a server menu with vanilla click rules, live or on their file (native_open_inventory_menu) (unbuilt) 2026-11-06 08:35:30 +00:00
martipopsandClaude Opus 5.5 3aaa70563e Raids: pass mob_to_nbt its UUID lookup, so main compiles
#10304 gave mob_to_nbt a fifth argument after the raid observation's call to it
was written, and the two merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:35:09 -05:00
Detimes 8c7807e832 Native helpers: the name over a player's head (set_name_tag) (unbuilt) 2026-11-06 08:34:22 +00:00
DUzzL 98e1e2d67f Credit trident damage_dealt_absorbed to the throwing LAN guest 2026-11-06 09:33:17 +01:00
Detimes 296d765d78 Native helpers: a player's styled name in chat (set_display_name) (unbuilt) 2026-11-06 08:33:08 +00:00
Detimes ca660e8d1f Native mods: server menus with input slots, their contents, the click's cursor and the slots on close (native_open_menu_with_input, native_menu_input_closed) (unbuilt) 2026-11-06 08:32:15 +00:00
Detimes 450b6c8548 Native hooks: leave mod_api to the stamp step; cancel flags named cancel (unbuilt)
The bundle's fragments carry no mod_api entry: session A adds them in one
step (add_mod_api.py, which takes a cancel: &mut bool parameter for the
cancel contract). So interact_entity_v2's claimed and
right_click_block_v2's canceled are named cancel; they act as before.

Not built yet.
2026-11-06 08:32:11 +00:00
Detimes 4cf75b20d9 Native helpers: a player's styled Tab list name (set_list_name) (unbuilt) 2026-11-06 08:32:07 +00:00
Detimes 873480bddc Native hooks: damage with the players' UUIDs and the attacker (living_incoming_damage_v3) (unbuilt)
native_living_incoming_damage_v3(victim, victim_uuid, kind, attacker,
attacker_uuid, direct, damage_type, amount, cancel) fires everywhere v1
fires (health::HURT_SERVER), right after it with the amount v1 left and
the same cancel flag. It adds a player victim's UUID, the causing entity
where v1 leaves 0 (a mob's attacker, a player's melee, the synchronous
attack in progress as living_knockback_v2 falls back to), a player
attacker's UUID and the direct entity. Players are noted by entity id as
each guest's turn starts (game/native_damage.rs, only while a native mod
is loaded), since the hit carries ids only and no Game.

Not done here, as the plan asked: a &mut self host (the victim is
borrowed out of the game inside Mob/Player/Dragon hurtServer, behind the
HURT_SERVER fn pointer) and Bukkit's modifier breakdown (the reductions
run after the hook in horizon-world's living_hurt and Mob::hurt_server).

Sentinel (attacker matched within 500 ms), Wanted, HuskSync and
CombatLog (an id-to-player map each tick) need the names.

Not built yet.
2026-11-06 08:31:35 +00:00
Detimes 0072925aef Native mods: kick a player with a whole text as the reason (kick) (unbuilt)
native_kick(player, reason) disconnects a player with a Text reason in its
colours and translations (Adventure's Player.kick(Component)), through
Hub::kick as /kick does, so the log line and native_player_kicked follow.
It is queued and runs at the tick's end with the mod messages
(flush_native_net), so it works from any server hook, the kicked player's
own included; today a port sends a raw Disconnect and then runs /kick with
one plain line.

Part of #10461.
2026-11-06 08:31:11 +00:00
Detimes 342a45b57a Native helpers: entities shown to one player's client alone (client_entities) (unbuilt) 2026-11-06 08:31:04 +00:00
Detimes ca9437b79f Native hooks: resource pack answers with the pack's id, configuration included (resource_pack_status_v2) (unbuilt)
native_resource_pack_status_v2(player, name, pack, status) is an additive
version of native_resource_pack_status with the answered pack's id
(Bukkit's PlayerResourcePackStatusEvent.getID), so a mod's own pack's
answers are told apart from the server's. In play it is heard just before
v1, which keeps its signature and firing point. The answers to the
server's own pack given during configuration, which the connection thread
handled alone, are kept (lan::Configured::pack_answers) and heard once the
player has logged in, right after native_player_logged_in. The connection
answers the packets exactly as before.

Part of #10461.
2026-11-06 08:30:06 +00:00
Detimes 0c10886ec0 Native mods: horizon-mod-deps adds rusqlite, yaml-rust2, gif, snap and mysql/chrono (unbuilt)
rusqlite 0.41 (bundled + cache, no default features) reads SQLite files such
as a Bukkit plugin's .db; yaml-rust2 0.13 (no default features, safe Rust)
reads and writes config.yml; gif 0.14 and snap 1 are small and pure Rust;
mysql gains chrono for DATETIME columns. All optional, nothing builds them.
Cargo.lock not yet updated.
2026-11-06 08:29:39 +00:00
Horizon agentandClaude Opus 5.5 361766f621 Merge #10312 (with main) into #10372
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:29:35 +01:00
Detimes d7c1e71d45 Native mods: each server tick's time and spans, a shared span switch and mob costs a mod samples (native_server_tick_timed, native_spans, native_mob_cost_sampling, native_take_mob_costs) (unbuilt)
tick_server_spans calls native_server_tick_timed with the pass's wall and CPU time and merged spans (TickSpan, re-exported from spans::Span) before handing them to the report. Spans are switched through tick_profile::want_spans by who wants them (HORIZON_SPIKES, horizon-report, a mod), so a mod and a report no longer turn each other's spans off. tick_profile keeps a mod's mob cost sums apart from the report's (two bits, two maps), so both can sample at once; calibration stays the report's. Fragment server_tick_timed.
2026-11-06 08:29:28 +00:00
Detimes 36323e05ae Native mods: each player's play packets once a tick (native_server_packets, native_count_packets) (unbuilt)
While a mod turns counting on, the network threads keep each framed and read play packet's id and body size in the connection's Outbox (io_pool Conn::frame and Conn::read, behind one relaxed load of a static switch, capped at 65536 each way); the server tick's end hands them to native_server_packets per player and way. Off (the default) nothing is kept and the hook never fires. Fragment server_packets.
2026-11-06 08:29:28 +00:00
Detimes 8abf5298fc Native mods: hurt a mob or a player from an attacker (native_hurt_entity) (unbuilt)
native_hurt_entity(dimension, target, amount, damage_type, attacker)
hurts a mob or a player of the level as LivingEntity.hurtServer with
damageSources().mobAttack(attacker) does, the way /damage <target>
<amount> <type> by <attacker> goes: cmd_damage's hurt is moved unchanged
into damage_target, which both call. Returns whether it hurt; false for
another dimension, a target that is neither a mob nor a player of the
level, a missing attacker or a non-finite amount.

Sentinel's own damage (damage set, workaround damage, lightning weapon)
today runs a damage command by UUID.

Not built yet.
2026-11-06 08:29:25 +00:00
Detimes 7fbd43b6a9 Native mods: offer and take away a mod's own resource packs (resource_packs) (unbuilt)
native_send_resource_pack(player, id, url, sha1, prompt, required) sends
ClientboundResourcePackPushPacket with the mod's own pack id (Bukkit's
Player.addResourcePack), checked against the packet's limits (hash at most
40, URL at most 32767 characters); native_pop_resource_pack(player, id)
sends ClientboundResourcePackPopPacket for one pack or, with None, all
(removeResourcePack). Both go through native_send. A decline still kicks
only when the server's own pack is required, as vanilla's
handleResourcePackResponse does.

Part of #10461.
2026-11-06 08:29:23 +00:00
Detimes e5acbdde15 Native helpers: hide an entity from one player's client (hide_entity_from) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes b842df0899 Native hooks: a mob's target changing, cancellable (living_change_target) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes 7f91f765b2 Native hooks: the plugin channels a player registers, in configuration and play (player_channels) (unbuilt)
native_player_register_channels(player, name, channels, register) hears
the names a client registers (minecraft:register) or unregisters
(minecraft:unregister), NUL-separated as Bukkit's and Fabric's clients send
them; vanilla reads neither (Bukkit's PlayerRegisterChannelEvent). What a
client registered during configuration, which the connection thread
dropped, is kept (lan::Configured on Joined) and heard once right after
native_player_logged_in; a message in play is heard as it is handled, with
the player's state swapped in, and then goes on as before. The helper
native_player_channels(player) lists a guest's channels
(Player.getListeningPluginChannels). At most 128 names of at most 256
bytes are kept per client.

Part of #10461.
2026-11-06 08:28:35 +00:00
Horizon agentandClaude Opus 5.5 835f062e8e Merge main into #10312 (names.rs, scoreboard.rs)
names.rs: #10328 landed on main; its NameTagLayouts frame now wraps this
PR's frustum-culled collect_name_tags, both test sets kept.
scoreboard.rs: #10098 added the hide_gui argument to tab_list::visible;
the early visibility check here passes !self.ui.hide_gui too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:28:22 +01:00
Detimes 33e0f27a8d Native hooks: a button's, lever's or plate's signal change (block_redstone) (unbuilt)
native_block_redstone(dimension, pos, old, new) is asked, as Bukkit's
BlockRedstoneEvent, when a button is pressed (hand or arrow) or let go by
its release tick, a lever is pulled, or a pressure plate's weight check
changes its signal. A mod may change new; one equal to old keeps the
component as it is, and a held button or plate is asked again at its
next scheduled check. The seam is LevelAccess::redstone_change in
horizon-world (default: leaves the signal), which GameLevel forwards on
an authoritative level. Wire, torches, diodes, observers and the other
sources are not heard yet.

CoreSMPEssentials part B holds its power rods' component on without the
2-tick re-set that leaks a 1-tick off pulse. Test (not run):
a_level_can_keep_a_lever_as_it_is.

Not built yet.
2026-11-06 08:28:19 +00:00
Detimes ce01d5ffeb Native mods: native_set_compass_target, kept over respawns and level changes (unbuilt)
Bukkit's Player.setCompassTarget: Hub.compass_targets by UUID while the player is on; default_spawn sends the target in place of the world spawn wherever it is sent (join, respawn, level change). Without a target the packet is unchanged. Helper-only fragment compass_target.
2026-11-06 08:28:07 +00:00
Detimes 1ffc88650d Native mods: native_boss_bar, boss bars for one player (unbuilt)
Bukkit's BossBar.addPlayer/removePlayer: a NativeBossBar (mod_api) by player UUID in BossState, offered to that viewer by boss_events_for, so publish_boss_bars sends Add/Update/Remove as for the world's bars. Unmodded servers keep an empty map. Helper-only fragment boss_bar.
2026-11-06 08:28:07 +00:00
Detimes d66f3e02ea Native mods: native_title, a title and subtitle for one player (unbuilt)
Adventure's showTitle and /title: SetTitlesAnimation, SetSubtitleText, SetTitleText through native_send. Helper-only fragment title.
2026-11-06 08:28:06 +00:00
Detimes ebb19e976e Native mods: native_send_particles_to, particles for one player alone (unbuilt)
Vanilla's ServerLevel.sendParticles(player, ...) and Bukkit's Player.spawnParticle: within 32 blocks, 512 with force (overrideLimiter). Replaces the hand-built LevelParticles (dust id hard-coded) of plugin ports; same options and checks as GameLevel::native_send_particles. Helper-only fragment send_particles_to.
2026-11-06 08:28:06 +00:00
Detimes 6ad344bec9 Native mods: native_play_sound_to, a sound for one player alone (unbuilt)
Bukkit's Player.playSound for plugin ports, which sent a raw ClientboundSoundPacket through native_send. Queued in game/native_player_fx.rs and sent at the tick's end with the hub back, so it works from any server hook; helper-only fragment play_sound_to.
2026-11-06 08:27:51 +00:00
Detimes fe2ef8529c Native mods: native_mob_attack, a mob's own attack on a mob or player (unbuilt)
Sentinel's native attacks (its default) call Mob.doHurtTarget for the
body. Horizon's do_hurt_target needs the goal's world, and its player is
whichever guest the mob sees, so the port injected a goal and wrote
LanHost.mob_players to bind the body to its target. native_mob_attack
runs do_hurt_target from any server hook with the target guest's view as
the mob's player (a new lan_guest_view, the single-guest form of
lan_guest_views), then hands the hits on that player to the guest and the
rest to handle_mob_events, as a mob's tick does. Helper only: unmodded
servers never call it. Part of #10461.
2026-11-06 08:27:49 +00:00
Horizon agentandClaude Opus 5.5 1f00e33453 Group slow-tick bursts by packet kind and name the top player
The Team review (kacpherek) on #10382: groups filled in arrival order
under a 32 cap hid a late UseItemOn burst behind other players' moves;
count > 1 dropped 69 players sending one 6-9 ms use each; and one slow
event hid the rest of its group. Groups are now one per packet kind (a
bounded set) with the time of their short events only (the slow ones
are noted on their own), and each names the player who spent the most
of it: "213 x UseItemOn under 10 ms each; most from Steve: 140 of
them, 700.0 ms".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:26:36 +01:00
Detimes 21da267e5b Native mods: native_remove_entity takes projectiles and fishing bobbers (remove_projectiles) (unbuilt)
native_remove_entity(dimension, id) also removes projectiles (arrows,
tridents, snowballs, eggs, pearls, fireballs, wind charges, llama spit,
shulker bullets, firework rockets) as /kill does (kill_target), and a
server player's fishing bobber, as Entity.discard: the bobber of the
player in place, or outside a guest's turn any player's of the level
(remove_server_hook in fishing.rs). Displays, markers and interactions go
as before. Sentinel's arrow cleanup and CoreSMPEssentials part B's power
rods need it.

Not built yet.
2026-11-06 08:26:32 +00:00
Detimes 182345dc5e Native mods: mob navigation and look helpers for NPC ports (unbuilt)
Citizens' Navigator and Sentinel walk their bodies to points and
entities, stop them, ask whether they still walk and aim their heads.
Horizon offered this only to a goal (Mob::nav_move_to with the goal's
world), so ports injected a goal that read intents from a global map, or
moved bodies in straight /tp steps through walls. native_mob_navigate,
native_mob_follow, native_mob_stop, native_mob_navigating and
native_mob_look_at call the mob's own navigation and look control
(PathNavigation.moveTo, stop, isInProgress, LookControl.setLookAt) from
any server hook, for a mob of any loaded level, with the world its goals
see. Helpers only: unmodded servers never call them. Part of #10461.
2026-11-06 08:26:27 +00:00
Detimes a59c7468d1 Native mods: maps a server mod draws, kept by the world (native_new_map, native_paint_map) (unbuilt) 2026-11-06 08:26:00 +00:00
Detimes 07acadab21 Native mods: native_set_entity_rotation for mobs and mannequins (unbuilt)
native_move_entity turns only the entities Horizon keeps without
simulating them, and native_teleport_mob takes no rotation, so NPC ports
turned their bodies with execute .. rotate or tp .. facing every few
ticks. The helper is /rotate (Entity.forceSetRotation) for an entity of
any loaded level, with an optional head yaw (setYHeadRot) for a mob.
Helper only: unmodded servers never call it. Part of #10461.
2026-11-06 08:25:28 +00:00
Detimes 4855ac0767 Native hooks: the entity data one viewer is sent, changeable for them alone (entity_data_to) (unbuilt)
native_entity_data_to(viewer, entity, values) is a typed shortcut of
native_server_packet for ClientboundSetEntityDataPacket: the entries one
guest is about to get for one entity, changeable for that viewer alone, so
a mod can show one player glowing to one viewer without touching the
server's own flags (TAB's and Essentials' per-viewer glow, which resend
their own packets on a timer today). Heard just before native_server_packet
for the same packet. Switched on by native_watch_entity_data, which decodes
that packet only. EntityDataValue is horizon_protocol's entity_data::Value.

Part of #10461.
2026-11-06 08:25:27 +00:00
Detimes aad1954499 Native hooks: a server player's bobber on a block (fishing_hook_landed) (unbuilt)
native_fishing_hook_landed(player, hook, dimension, block, face) hears a
server player's flying fishing bobber meeting a block on its way, and
each tick it lies on one (on the ground as the tick began or ends: the
block under its centre, its top), as Bukkit's ProjectileHitEvent for a
FishHook. It is heard at the end of FishingHook.tick with the bobber back
in place, so a mod can remove it there. hook_block_hit keeps its result;
a sibling hook_block_hit_at also gives the block and face.

CoreSMPEssentials part B starts its power rods from the plate, button or
lever the bobber lands on; native_projectile_impact leaves bobbers out.

Not built yet.
2026-11-06 08:25:18 +00:00
Detimes e55c423833 Native mods: what each open server level holds (native_level_stats, native_level_entities, native_level_block_entities) (unbuilt)
NativeLevelStats counts a level's loaded and forced chunks, block entities and ticking ones (BlockEntities::ticking_len), and its entities (players included) and block entities by type. The entity list reuses the selector walk (level_candidates, now pub(in crate::game)) with a parked level swapped in as /execute in does; block entity types come from the block (lan::chunks::block_entity_type), which the enum's variants cannot tell apart, so this replaces the planned BlockEntity::type_id(). Fragment level_stats.
2026-11-06 08:25:07 +00:00
Detimes db26ac1679 Native mods: native_entity_by_uuid and native_merge_entity_nbt for any loaded level (unbuilt)
A stage plugin keeps its actors by UUID and moves, changes or removes them
in whatever level they are in; Horizon's entity handles reach the level in
place only, so ports ran tp/data merge entity by UUID on the console.
native_entity_by_uuid finds the entity (players included) in any loaded
level and returns its dimension, id and type; native_merge_entity_nbt is
native_entity_merge_nbt for a named loaded level. Helpers only: unmodded
servers never call them. Part of #10461.
2026-11-06 08:24:56 +00:00
Detimes 9bf6fb8323 Native mods: an item stack from SNBT (native_item_from_snbt, ItemStack::from_nbt, mod_api::parse_snbt) (unbuilt) 2026-11-06 08:24:47 +00:00
Detimes 4b1319096e Native hooks: a mod's terrain in the far terrain (lod_column) (unbuilt)
native_lod_column(dimension, column, seed, flat, column_out) is asked
for each column GeneratorWorker::lod_grid, surface_sample and
surface_column sample. A mod whose native_chunk_generate builds that
chunk answers the column's blocks (and its biome) with
column_out.replace, and the far terrain shows them, with the top, the
water over it and the spans derived as vanilla's sample derives them,
instead of vanilla's noise until the chunk generates.

Vanilla's far sample is computed first and only replaced when a mod
answers: without a mod, one empty call per column and the same columns
(#105).
2026-11-06 08:24:15 +00:00
Detimes 6c3dd1247f Native hooks: a player's fishing rod with its hand (player_fish_v2) (unbuilt)
native_player_fish_v2(player, name, state, hand, cancel) is
native_player_fish with the rod's hand: the hand the cast or reel-in uses
(Bukkit's PlayerFishEvent.getHand), and for a bite the hand holding the
rod, main hand first as FishingHook.shouldStopFishing looks. Asked right
after the unchanged v1 hook with the same cancel flag. CoreSMPEssentials
part B sees off-hand stasis and power rod casts.

Not built yet.
2026-11-06 08:24:02 +00:00
Detimes f4ccc08720 Native hooks: every play packet a player is sent, changeable and cancellable (server_packet) (unbuilt)
native_server_packet(player, packet, cancel) hears each play packet the
server is about to send one guest, decoded, a broadcast once for each
player, where PlayerSender::send hands it over (ProtocolLib's and
PacketEvents' outgoing listeners; NeoForge and Bukkit have no event).
A changed packet is encoded again, a canceled one is not sent. Nothing is
decoded or copied until a mod calls the helper native_watch_server_packets:
until then each packet costs one relaxed atomic load. The hook is a free
function the game sets once (lan::conn::SERVER_PACKET), as player_move is.

Part of #10461.
2026-11-06 08:23:51 +00:00
Detimes 91a7c31b12 Native mods: native_spawn_mob and native_summon reach any loaded level (unbuilt)
Both helpers were refused for any level but the one in place, so a mod
spawning from native_server_tick (where no level is defined) or into
the Nether from the Overworld's tick could not, and Stage-style ports ran
`execute in <dimension> run summon` instead. The named level is now put
in place for the call and put back afterwards, through the same
enter_command_level that `execute in` uses (npc_in_level in the new
native_npcs.rs). A level not loaded is still refused. Unmodded servers
never call these helpers, so nothing changes for them. Part of #10461.
2026-11-06 08:23:46 +00:00
Kacper d3f799f5d5 Merge pull request #10368 from TheMrZZ/fix/h331-arrow-hit-category
Play ordinary arrow hits in the neutral sound category
2026-11-06 09:23:40 +01:00
Detimes 14e39641c2 Native hooks: a right click on a block with the hand (right_click_block_v2) (unbuilt)
native_right_click_block_v2(pos, face, hand, canceled) is
native_right_click_block with the hand that clicks (NeoForge's
PlayerInteractEvent.RightClickBlock.getHand), asked right after the
unchanged v1 hook with the same flag, once per hand tried. A cancel does
what a v1 cancel does. CoreWG's guard refuses an off-hand flint and steel
or bone meal; today it sees the main hand only.

Not built yet.
2026-11-06 08:23:26 +00:00
Horizon agentandClaude Opus 5.5 0a1a3c6ffe Merge #10502 into #10501 (both edit pack_post.rs)
Keeps both test modules (mixed_size_tests, undrawn_terrain_tests) and
has_terrain with #10502's doc and undrawn flag after sizes_differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:24 +01:00
Detimes ff07b57b27 Native mods: the server's tick times and ticks a second (native_tick_times, native_tps) (unbuilt)
native_tick_times() returns the last 100 tick durations at vanilla's getTickTimesNanos indices (tick n at n % 100); native_tps() the ticks a second over one, five and fifteen minutes (Bukkit's getTPS), measured between recorded tick ends in FixedStep::record. Helpers only, fragment tick_times.
2026-11-06 08:22:55 +00:00
Detimes 9553a05bb8 Native hooks: a right click on an entity with the hand (interact_entity_v2) (unbuilt)
native_interact_entity_v2(entity, kind, hand, stack, claimed) is
native_interact_entity with the hand that clicks (NeoForge's
PlayerInteractEvent.EntityInteract.getHand), asked right after the
unchanged v1 hook with the same claimed flag: a click v1 claimed is not
asked again. Once per hand vanilla tries, so a mod claiming the main hand
hears one event per click (Citizens' click events and commandtrait
hands, CoreWG, CoreSMPEssentials part B).

Not built yet.
2026-11-06 08:22:47 +00:00
Detimes 6847ec0b7b Native hooks: a chunk's terrain from a mod (chunk_generate) (unbuilt)
native_chunk_generate(dimension, chunk, seed, flat, chunk_out,
generate) is asked on the generation workers for every chunk, after its
biomes and structure starts and before the noise (Bukkit's
ChunkGenerator.generateNoise). A mod sets the chunk's blocks through
NativeProtoChunk::set_block and answers NativeGenerate::Replaced with
the shouldGenerate* flags: vanilla then runs only the surface rule, the
carvers (on a superflat level too), the structures and the features it
asks for. set_biomes and clear_structures work with either answer.

Left at Vanilla, nothing changes: the hook is one empty call per chunk
and every vanilla step runs as before. Replaces the cancellable HEAD on
VanillaTerrain::proto that generated the vanilla chunk and overwrote it,
and the coregen_glue module (#104).
2026-11-06 08:22:18 +00:00
Kacper 918767ac08 Merge pull request #10286 from Ragbie/fix/dragon-damage-command
/damage hurts the Ender Dragon and End crystals as vanilla does
2026-11-06 09:21:37 +01:00
Detimes a58cb67f76 Native world seed read helper (native_world_seed) (unbuilt)
The world's seed as /seed prints it (Bukkit's World.getSeed), for a
generator mod's preview that must draw the terrain the chunks get.
None off a server, where only a hash of the seed is known. No
behaviour change. Part of the world generation bundle (#98).
2026-11-06 08:19:15 +00:00
M0-kiiandCodex bd0c8cb4fd Preserve changed filenames and rename sources in tooling checks
Read Git's NUL-delimited UTF-8 output without newline conversion, and
include deleted source paths when files move between check scopes.
Keep pr_ready's line accounting consistent with parity selection.

Fixes #10535

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-06 11:41:32 +03:30
Horizon agentandClaude Opus 5.5 3728c4b7d1 Play every arrow and trident tick sound on NEUTRAL
The crossbow hit, trident hit and trident hit_ground sounds come from the same
Entity.playSound as the arrow hit (AbstractArrow.onHitBlock / onHitEntity,
ThrownTrident.onHitEntity), so they take getSoundSource(), NEUTRAL, too; the
name-based guess put them on PLAYERS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:03:29 +01:00
Detimes 304276d8dc Region chunks: inflate zlib straight into the chunk's buffer
A saved chunk's zlib stream was inflated by a new Decompress, 8 KiB at a
time into a stack block, each block then copied onto the output, which
grew by doubling from empty. Now each thread keeps one inflater, reset
for every chunk, and a whole stream that fits is inflated straight into
the output, reserved at about eight times the stream (a chunk's usual
ratio) and never past one byte over the limit. Any stream that fails
(damaged, cut short, over the limit) is decoded again the old way, so the
error and what the output keeps are exactly as before. A new test
compares both on whole, cut and damaged streams around every limit.
2026-11-06 08:03:25 +00:00
Horizon agent 899d87e22a Merge remote-tracking branch 'origin/main' into w10368 2026-11-06 09:03:00 +01:00
Horizon agent 1fd348869c Merge branch 'w10339' into w10286
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-06 09:02:54 +01:00
Horizon agentandClaude Opus 5.5 ec6b1817a5 Start the server after a prune that rolled itself back
A failed region restores its originals before the error comes back, so
the world is whole: blocking the start then took a server down for a
corrupt chunk record. Only a recovery that cannot finish stops it now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:02:36 +01:00
Horizon agentandClaude Opus 5.5 07c3ef02d0 Dragon scanning turn: vanilla degree conversion in double
DragonSittingScanningPhase.doServerTick computes Math.acos(dot) * 180.0F / (float) Math.PI
and Mth.atan2(..) * 180.0F / (float) Math.PI in double, not a multiply by the float
RAD_TO_DEG constant. Also moves the Silent test doc comment back onto its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:01:37 +01:00
PzmuV1517 aff26bf9f9 Shader packs: a pack builds on a thread of its own while the game draws
Loading a shaders/ pack made its pipelines on the main thread, and the driver
compiles each program there: the game froze for 5 to 50 s on a big pack
(Complementary Reimagined, Photon), with no frame drawn.

The renderer now builds the pack on a "Shader pack" thread (load_shader_pack)
with its own handles to the device and the inputs, and the game keeps drawing
without a pack meanwhile; each frame poll_shader_pack checks the thread and
runs the pack once it is built, logging its programs and notes as before. A
pack the thread refuses or panics on is turned off as before. Picking another
pack, or none, drops one that is still building.
2026-11-06 09:58:20 +02:00
Detimes 3dd74dcc64 Native read-only chunk snapshots (native_chunk_snapshot)
A NativeChunkSnapshot of a loaded chunk of an open level (block states,
WORLD_SURFACE heights, noise biomes, the fuzzy biome zoom's cell) that
shares the chunk's sections copy-on-write and can be read on another
thread, with native_block_map_color, native_biome_name and
native_biome_tint to draw it. Replaces the engine internals a web map
reads today. No behaviour change. Part of #10461.
2026-11-06 07:57:06 +00:00
Horizon agent 0c47273286 Merge remote-tracking branch 'origin/main' into w10339 2026-11-06 08:56:14 +01:00
MegaBytesMe 017caac28c fix: track Windows resource tool changes 2026-11-06 07:46:17 +00:00
GabrielNichols ce90330576 Sync Recipes content scenario with official main 2026-11-06 04:32:30 -03:00
Detimes 3a20f23ee6 Native mods: read and set a player's attributes (player_attributes)
native_attribute(uuid, attribute) gives an attribute's base value and its
value with every modifier (Bukkit's AttributeInstance.getBaseValue and
getValue); native_set_attribute_base sets the base as
/attribute .. base set does; native_set_attributes loads a saved
attributes list onto the player as vanilla's AttributeMap.load reads a
player's file. A lower max_health caps the health, and the client gets
UpdateAttributes from the tick's sync. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 865e30ecb6 Native mods: read, give, take and replace a player's effects (player_effects)
native_effects(uuid) reads a player's effects as NativeEffect { id,
amplifier, duration, ambient, particles }; native_add_effect gives one as
vanilla's LivingEntity.addEffect (Bukkit's addPotionEffect), so a weaker
or shorter one waits under it; native_remove_effect takes one off as
/effect clear does; native_set_effects replaces them all, what a mod
restoring a saved player needs instead of writing PlayerEffects into the
hub's guests. The client gets UpdateMobEffect with the ambient and
particle flags from the tick's sync. Horizon keeps no hidden-icon flag on
an effect, so none is offered. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 3e6a1990ce Native mods: read a player's food, set health, food and experience (player_status)
native_player_food(uuid) reads a player's food level, saturation and
exhaustion; native_set_player_status(uuid, health, food, xp) sets each
that is given, as Bukkit's setHealth, setFoodLevel, setSaturation,
setExhaustion, setLevel, setExp and setTotalExperience. The write goes
through the player's turn as /kill and /effect reach a player
(for_player), so their client gets SetHealth and SetExperience with the
tick's sync. Health is capped at the maximum; 0 or less kills as /kill
does, with "<player> died" (Bukkit's setHealth(0), LivingEntity.die with
the generic source). The mod's food is not heard as the player's own
change (native_food_level_changed) outside their turn. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 56b7dcbe62 Native mods: a player's compound as the server saves it (player_compound)
The helper native_player_compound(uuid) gives a player's compound as the
server would save it now (players/data/<uuid>.dat, playerdata::player_tag),
vanilla's ServerPlayer.saveWithoutId: what a mod keeping players in step
across servers (a HuskSync port) stores, instead of calling player_tag on
the hub's guests itself. None inside a player's turn, where their live
state is out of their record, and for a player not on. The new module
native_player_state.rs holds this group's helpers. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 7c411b3a58 Renumber registry ids for a 26.2 client, with stand-ins for what 26.2 lacks
What a Horizon server sends a 26.2 client (accept_older_clients) now carries 26.2's
registry ids: tags, block states, entity, block entity, block, attribute, sound and
particle ids, and items and components through the existing Way::Down writers, which
now leave out components the client lacks. A creative slot or container click from the
client is read with its ids up.

generate_registry_id_adapter.py --substitutes writes, from both versions' data
reports, a stand-in for each 26.3 entry 26.2 lacks (poplar as birch, wool and concrete
slabs and stairs as stone ones of the same shape, ...); what has none (the cushion
entity) isn't sent. v26_2_ids.rs is regenerated with it.

Part of #10404.
2026-11-06 07:29:28 +00:00
Detimes 29a0f2dc72 Native mods: players the server knows, by name or id (profile_cache, #10461)
native_profile_by_name(name) gives the id and name, and
native_profile_by_uuid(uuid) the name, of a player who is on, else one
in a dedicated server's profile cache (usercache.json): vanilla's
GameProfileCache, Bukkit's getOfflinePlayer without its network
lookup. They ask no profile service and make up no offline player;
UserCache::find_by_name reads without changing the cache. native_player_head
names a player who is off from the cache too. Ports parse
usercache.json or ask Mojang themselves today.
2026-11-06 07:25:24 +00:00
Detimes bb4f25c694 Native mods: any player's held stack and equipment from any server hook (player_equipment)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2a86196e83 Native mods: the server's player limit, folder and log file (server_files)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes ea6382f2a1 Native mods: a player's statistics and advancements, read and replaced (player_records)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2f4294aba1 Native mods: what a player's client told the server (player_client)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes eacaa4db69 Native mods: more of a player on NativeServerPlayer (server_player_state)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:14 +00:00
AkirabaneandClaude Opus 5.5 db1cde61a8 dedicated: transfer-status, the counts a proxy reads before it sends players
A proxy that balances players between servers has to know, before it sends
one, how many players a server has and whether a login would be refused as
full. transfer-status answers with counts only: the players placed, held and
expected, the hand-overs under way, the places the player limit counts, the
limit, and whether the server is full. It names no player, changes nothing
and writes nothing to the log, since a proxy asks it often. Like the player-*
requests, it is refused while [transfer] is off.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:23:45 +01:00
Web-mc1 89bac30424 tests: pin how the dev tools take their arguments
Both tools above used to crash, or write into a flag-named path, instead of
asking for what they need. These tests run them in an empty folder and check
that they exit non-zero with a usage line and write nothing.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Web-mc1 cd5b93a54a bake_bricolage: ask for its two fonts
Both arguments were read unguarded, so running it with none, or with one, was an
IndexError rather than the command its docstring shows. Print the usage line.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Web-mc1 35bb6ede00 json_load_fixtures: ask for an output directory
The script read sys.argv[1] straight away, so a missing argument ended in an
IndexError traceback, and a mistyped flag created a directory named --help and
filled it with 66 MB of fixtures. Print the usage instead: a flag is not a path.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Detimes ca41bb4780 Packets: one zlib compressor per thread, reset for each packet
frame::contents made a new ZlibEncoder for every packet above the
compression threshold, which allocates and clears zlib's window and hash
tables (some 300 KiB) each time. A thread-local Compress, reset before each
packet and driven with the encoder's own calls (the input unflushed, then
finish), writes the same bytes. A new test checks them against a fresh
encoder for bodies from 256 bytes to 300 KiB, in both orders.
2026-11-06 07:19:47 +00:00
Arslan ArmanulyandClaude Opus 5.5 da06786848 The dragon fight's world fog leaves out the sky, as vanilla does
GameRenderer.renderLevel gives LevelRenderer.render shouldRenderSky false
while a boss bar makes world fog (the Ender Dragon's), so the frame is only
cleared to the fog colour. Horizon drew the End's sky box behind the island
during the fight. fog::sky_pass_skipped now holds vanilla's conditions:
LevelRenderer.addSkyPass's (lava, powder snow, blindness or darkness) and
the boss fog.

Part of #10491

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:19:07 +05:00
Detimes f1586af649 Native hooks: set one checked inventory slot, take exact stacks (helpers)
native_set_inventory_slot(uuid, kind, slot, expected, new) puts a stack
in any slot of a player's inventory or ender chest, armour and off hand
included, if it still holds exactly `expected`, and returns what was
there (Bukkit's setItem). native_take_stacks(uuid, slots) takes exact
stacks out of the given slots, all or none. Both are checked like
native_move_stack and work live or on the player's file. Wanted by the
InvSee, AdminKit and Wanted ports (armour slots, splitting, stakes);
Wanted edits LanHost guests directly for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes a7c6eff569 Native hooks: replace a player's whole inventory (set_inventory helper)
native_set_inventory(uuid, kind, slots, selected) replaces a player's 41
inventory slots or 27 ender chest slots (Bukkit's
PlayerInventory.setContents), live or in their file like
native_inventory. For the inventory it sets the held hotbar slot, empties
the cursor and the 2x2 crafting grid, and sends a player on the server the
whole inventory at once. The HuskSync port writes Guest fields directly
for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 8e61d452e0 Native hooks: a guest's block placement before it happens (block_place)
native_block_place(player, name, stack, pos, state, cancel) is heard
where BlockItem.place has found the position and the state and checked
survival, before anything is set (Bukkit's BlockPlaceEvent, NeoForge's
BlockEvent.EntityPlaceEvent). A canceled placement is a failed one: no
block, no use, no swing, and the client gets its blocks and inventory
back. The position and state come from the existing dry run
(Behaviours::placement_state). Ports cancel placements today by claiming
native_use_item_on, which also stops doors, chests and the item's other
uses.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 531a026a14 Native hooks: a guest's drag over slots, cancellable (inventory_drag)
native_inventory_drag(player, name, window, kind, slots, cancel) hears a
guest's quick craft in their own inventory or a vanilla menu once, at its
end, with the kind and the window slots the client dragged over (Bukkit's
InventoryDragEvent). A canceled drag's end is handled like a canceled
click: the drag ends empty and the client's prediction is answered with
the server's slots and cursor.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 96d477b41b Native hooks: a guest's inventory and menu clicks, cancellable (inventory_click)
native_inventory_click(player, name, window, slot, button, mode, cancel)
hears a guest's click in their own inventory or a vanilla menu they have
open before any handler applies it (Bukkit's InventoryClickEvent). A
canceled click becomes a drag start of no type, which every menu takes as
resetQuickCraft and nothing more, so nothing moves and the client's
predicted slots and cursor are answered with the server's, as for any
click the server refused. Drags and a mod's own menus are not heard.
Wanted by the HuskSync and Essentials ports (data lock, freeze).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 2e5d156644 Native hooks: a guest's throw, cancellable (player_launch_projectile_v2)
native_player_launch_projectile_v2(player, name, stack, cancel) is heard
where ProjectileItem.use creates a snowball, egg, ender pearl or a mod's
thrown item, before the projectile exists (Bukkit's ProjectileLaunchEvent
for a player). A canceled throw is a failed use: nothing is thrown, used
or put on cooldown, v1 hears nothing, and the guest's client, which took
the item at once, gets its inventory back. Wanted by the HuskSync port's
data lock (no ender pearls while locked).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 157208790d Native mods: an online player's textures and a head stack of a player (player_textures, #10461)
native_player_textures(uuid) gives the textures property (value and
signature) of a player who is on, as their player list entry carries
it (Paper's Player.getPlayerProfile). native_player_head(uuid) gives a
minecraft:player_head whose profile holds the player's id, and their
name and skin when they are on (Bukkit's SkullMeta.setOwningPlayer).
Ports look the current skin up at the session server today, or build
heads with a name-only profile.
2026-11-06 07:03:55 +00:00
Detimes 16750b8c90 Native hooks: a player logging in, held for the mods' answers (player_pre_login, #10461)
native_player_pre_login(player, name, address, defer) runs on the
connection's own thread once the session service or the proxy has named
the player and before the server's own checks (Bukkit's
AsyncPlayerPreLoginEvent). A mod that needs time, a permissions or
inventory load, or may refuse, pushes a Receiver<Result<(), Text>> onto
defer and answers from any thread: Ok lets the player on, Err refuses
them with the reason. The login waits for every answer, 20 s at most in
all; a missing or dropped answer refuses with a plain Disconnected.
Today such loads start in native_player_login, where a database failure
cannot refuse the player.
2026-11-06 07:02:40 +00:00
Detimes 6564832337 Native mods: set a profile property and show the player again (profile_refresh, #10461)
native_set_profile_property(player, name, value) sets or removes a
property of an online player's profile (Paper's Player.setPlayerProfile);
native_refresh_player(player) shows them again at the end of their
level's pass: everyone who sees them gets their list entry anew and
their entity again, and the player is respawned in place into the same
level with all data kept (Paper's hidePlayer/showPlayer plus the
respawn), so their own client draws a new skin too. Both work from any
server hook, native_server_tick included. They replace the raw HEAD
injection on Game::flush_native_net that edits Guest.properties and
sends the player info itself.
2026-11-06 07:01:16 +00:00
Detimes 413c2b1593 Native mods: command arguments that ask the server for completions, and the request split into label and arguments (tab_complete_v2) (unproven) 2026-11-06 07:00:51 +00:00
Detimes 78f3b6fca9 Native mods: look up a server command by name, aliases resolved (command_info) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 7495f4c56b Native mods: run a console command that reaches the mods' commands (run_mod_command) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 90a091b024 Native mods: run a command as a player, at their level, with its answer (run_command_as) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 41413878d0 Native mods: run a console command and keep its result and answer (run_command_captured) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 9b670c7cfc Native hooks: a joining player's profile on the connection thread (player_profile, #10461)
native_player_profile(player, name, properties) runs on the
connection's own thread once the server has let a player in and before
it sends LoginFinished. A mod may change, add or remove the profile's
properties (a skin's textures): the player's own client and every
player list get the new ones (Velocity's GameProfileRequestEvent,
Paper's PlayerProfile at AsyncPlayerPreLoginEvent). It replaces the raw
HEAD injections on lan::conn::login_and_configure and
Game::lan_guest_joined that skin and Floodgate ports use today.
2026-11-06 07:00:34 +00:00
Detimes 0da0001f38 Native mods: a player's permission level, also at login (player_permission, #10461)
The helper native_player_permission(player) gives the permission level of a
player on the server or joining it (0 to 4, ops.json's for an operator,
PlayerList.getProfilePermissions). It works inside native_player_login for the
player that hook hears, whose level the hub set on admitting them, so a login
gate that lets operators through needs no ops.json of its own. The hook's
contract is unchanged.
2026-11-06 06:51:55 +00:00
Detimes a4a64c560d Native mods: send a plugin channel message to a player (send_custom_payload_to_player, #10461)
The helper native_send_custom_payload_to_player(player, channel, data) queues
a ClientboundCustomPayloadPacket on any namespace:path channel outside
horizon: (at most ClientboundCustomPayloadPacket's 1 MiB) for one player's
client, vanilla or Horizon, sent at the tick's end with the mod messages, so
it works from any server hook. Bukkit's Player.sendPluginMessage.
2026-11-06 06:51:55 +00:00
Detimes 27200f64cd Native hooks: a player's plugin channel message (custom_payload_from_client, #10461)
native_custom_payload_from_client(player, channel, data) hears a guest's
ServerboundCustomPayloadPacket in play on any channel that isn't Horizon's own
(horizon:mod_net, horizon:quiet_commands, horizon:lod), which the server drops
today. Like mod messages it waits for the tick's end, with the hub back, so
every helper works; it is heard from a dead player too, as vanilla's
handleCustomPayload doesn't look at health. Horizon's own channels are handled
as before. The client side is #6774.
2026-11-06 06:51:55 +00:00
Detimes 01fec61d2d Handshake: a host of up to 32767 characters behind a proxy (#10461)
With proxy forwarding on (forwarding.secret, offline mode, not the integrated
server's own player) the handshake's host is read with Short.MAX_VALUE as its
limit, as Paper does behind a proxy, so a proxy's data after the host (a
Floodgate player's, often longer than 255 characters) no longer drops the
login. Without forwarding the limit stays vanilla's 255; it is the only string
read with that limit.
2026-11-06 06:51:55 +00:00
Detimes ac6ff90c34 Native hooks: a client's handshake on the connection thread (handshake, #10461)
native_handshake(address, host, intent, cancel, reason) is a free function in
horizon_client_lib::lan::conn, called on the connection's own thread for each
network connection's handshake, before the server acts on the intent. A mod may
rewrite the host (a proxy's data after it, Floodgate's, included) or refuse the
connection: a login or transfer is disconnected with the reason, a status ping
is closed unanswered. Wired through NativeClientHooks like player_chat. Nothing
changes without a mod.
2026-11-06 06:51:55 +00:00
MegaBytesMe 376be8985d fix: handle spaces in Windows resource paths 2026-11-06 06:51:09 +00:00
Detimes c87a60d013 Native hooks: creative slots with the whole stack (creative_slot_v2)
native_creative_slot_v2(player, name, slot, stack, cancel) is an
additive version of native_creative_slot with the whole ItemStack put in
the slot (none for an emptied one), components included. It is heard
just before v1, which keeps its signature and firing point and hears
nothing of a packet canceled here.

Part of #10461.
2026-11-06 06:30:14 +00:00
Detimes 960634a9ad Native hooks: throws with the whole stack (item_dropped_v2)
native_item_dropped_v2(player, stack) is an additive version of
native_item_dropped with the whole ItemStack thrown, components included
(enchantments, unbreakable, attribute modifiers...), heard just before
v1, which keeps its signature and firing point.

Part of #10461.
2026-11-06 06:30:13 +00:00
Detimes a425fe4152 Native hooks: pickups with the whole stack, thrower and position (item_picked_up_v2)
native_item_picked_up_v2(player, stack, thrower, position, canceled) is
an additive version of native_item_picked_up: the whole ItemStack with
its components, the item entity's thrower UUID (none for block and mob
drops) and its position. It is heard just before v1, which keeps its
signature and firing point and hears nothing of a pickup canceled here.

Part of #10461.
2026-11-06 06:30:11 +00:00
Detimes 192898e730 Native hooks: where a joining player is placed (player_spawn_location)
native_player_spawn_location(player, name, dimension, position,
rotation) is heard once per join after the saved data is loaded (or a
newcomer's spawn found) and before the level is chosen and the world is
sent (Spigot's PlayerSpawnLocationEvent). A mod that changes them places
the player there, in that level, with no teleport afterwards. An unknown
dimension or a non-finite value leaves that part unchanged.

Part of #10461.
2026-11-06 06:30:10 +00:00
Detimes 14af676a89 Native hooks: player-versus-player damage with both players (player_hurt_by_player)
native_player_hurt_by_player(attacker, attacker_entity, victim,
victim_entity, source, damage_type, amount, canceled) names both players
by UUID and network entity id, and how the hit came: melee, sweep, or the
projectile's entity type (arrow, spectral arrow, snowball, egg, a mod's
thrown type). It is heard where native_living_incoming_damage is, at the
victim's hurtServer past its guards (pvp rule and teams allow it, victim
not invulnerable or dead), and just before it: changing the amount
reaches v1, canceling skips the hurt and v1 alike. The PvP call sites
name the hit in a thread-local scope that hurt_server reads.

A snowball or egg does zero damage, where vanilla's Player.hurtServer
stops: it is heard there with amount 0 and nothing the mods set applies.
Thrown tridents hurt no players in Horizon yet and are not heard.

Part of #10461.
2026-11-06 06:30:09 +00:00
Detimes 3515e99feb Native hooks: a player logging out while still present (player_logging_out)
native_player_logging_out(player, name) is heard when a placed player's
connection is gone (lost, kicked, quit) but before their menu closes and
they are saved and removed (Bukkit's PlayerQuitEvent): they are still in
their level and listed in native_server_players. No state is swapped in;
the hub is back in self.lan for the call, so the UUID helpers and
native_run_command reach them. A death a mod causes there (`kill <name>`)
is died at once (death message, drops, native_player_died), since the
leaving player gets no further tick to die in; they are then saved dead.
Not heard when the server stops, where native_player_logged_out already
tells with them still listed. Unmodded servers are unchanged: the death
step only runs when the hook took the player from alive to zero health.

Part of #10461.
2026-11-06 06:30:08 +00:00
Detimes da8c2cfc55 Native hooks: each line written to the log (log_line)
native_log_line(level, thread, message, line) hears each message
written to logs/latest.log, on the log's writer thread after the file
has it, so the thread that logged never waits on a mod and a Discord
console bridge needs no tailing of latest.log. line is the text as the
file got it. The logger calls it through logging::set_line_hook,
installed before the log opens; trait mods are reached through
NativeClientHooks. A callback that panics is not called again, so its
own panic line can't loop.

Refs #10461
2026-11-06 05:42:47 +00:00
Detimes 2f2eecf40d Native hooks: every console and RCON line before it runs (console_line)
native_console_line(source, line, cancel, replies) hears each line the
dedicated server's console is about to run, before vanilla's commands,
the dedicated-only ones and mods' registered ones: source is console
(typed on the console, or sent by horizon-server as the console) or
rcon. Set cancel and it does not run; replies are logged as the
console's feedback, so they also reach the RCON or horizon-server
caller. Until now native_perform_console_command only heard commands
a mod registered, so an audit mod could not see ordinary console
commands (Bukkit's ServerCommandEvent / RemoteServerCommandEvent).

Refs #10461
2026-11-06 05:42:46 +00:00
TheMrZZandCodex gpt-6.1-sol 81cf3ef358 fix: name actual macro argument NBT type
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 06:41:16 +01:00
Detimes 6e4fbfa79f Native per-player view distance (native_set_player_view_distance)
A mod limits one player's view distance below the server's: only that
player is told its radius (SetChunkCacheRadius) and is sent and tracks
chunks within it; native_player_view_distance reads it. The guest's
distance is ChunkMap.getPlayerViewDistance under the limit; with no limit
(every unmodded server) it is exactly as before. Part of #10461.
2026-11-06 05:40:48 +00:00
Detimes 9c23922432 Native world border read helper (native_world_border)
A level's world border bounds as the server enforces it, for a web map's
border line and a pre-generator's border selection. Vanilla 26.3 keeps a
border per level (ServerLevel.getWorldBorder, each level's
data/minecraft/world_border.dat); Horizon keeps one for every level, so
the helper answers the same bounds for each level until that is fixed.
No behaviour change. Part of #10461.
2026-11-06 05:40:07 +00:00
Detimes 2d2936b9df Native chunk tickets in a level nobody is in (native_force_chunk_in)
native_force_chunk only reaches the level in place, so a mod could not hold
a chunk of the Nether or the End from a server-wide hook, and a level not
open yet never ticked: a pre-generator for those levels never advanced.
native_force_chunk_in takes the dimension; a level held this way opens at
the next server tick and ticks while held. Unmodded servers hold no mod
tickets, so nothing changes for them. Part of #10461.
2026-11-06 05:39:58 +00:00
HiuoyandClaude Opus 5.5 5c1d4d53f6 Native hooks: PlayerTickEvent.Pre, the start of a player's server tick (#10464)
native_player_tick_pre() runs once a tick for every living player in the
world, at the top of their ServerPlayer.tick, before their digging, item
use and cooldowns. native_player_tick (API 31) already ends the same tick.
It fires in tick_guests after the checks that skip a dead player and one on
a server trip, so the pair brackets the same players. Single player's own
player is a guest of its internal server.

The fragment player_tick_pre.json labels it PlayerTickEvent.Pre (side
server, not cancellable) for horizon-port-pack events. 23 of ATM10's mods
listen to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:36:24 +04:00
Arslan ArmanulyandClaude Opus 5.5 b1574b9721 Shader packs: a program writing buffers sized relative and in pixels draws while they match
A composite program writing two colortex buffers was skipped whenever their
`size.buffer` lines differed, even when one relative size and one pixel size
came to the same size (`0.25 0.25` and `320 180` at 1280x720). Iris 1.11.7
draws it (a probe pack of ours: texel (100, 50) of both reads 100.5, 50.5 and
texcoord 0.314, 0.281).

Sizes of one kind that differ still skip the program as the pack loads. A mix
of the two kinds is kept, and the pass is skipped only at render sizes where
the buffers it writes resolve to different sizes.

Fixes #10454

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:26:01 +05:00
HiuoyandClaude Opus 5.5 3e4828d791 port-pack events: ModConfigEvent counts as hooked when its three events are
37 of ATM10's mods listen to the parent ModConfigEvent, which hears the
three events NeoForge's config page documents under it: Loading, Reloading
and Unloading. config_loading, config_reloading and config_unloading answer
them, so the parent now counts as hooked rather than as another phase's:
93 of 326 names, up from 92.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:24:40 +04:00
HiuoyandClaude Opus 5.5 e925e25781 port-pack events: ModelEvent.RegisterAdditional is looked up as RegisterStandalone
NeoForge's documentation describes one event under two names. 1.21.1's
"Registering Additional Models" (ModelEvent.RegisterAdditional, one
ModelResourceLocation per model) is "Registering Standalone Models"
(ModelEvent.RegisterStandalone, a key and a baker per model) from 1.21.5 on.
Both versions use the same sentence, and the example method is still
registerAdditional. standalone_models (API 151) answers it, so the 47 ATM10
mods that listen to it now count as hooked: 92 of 326 names, up from 91.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:46:39 +04:00
repositorykreml1nandCodex f4b9bc1471 Merge upstream main to resolve PR #10015 conflicts
Keep the command-block /say LAN regression and upstream legacy PvP checks,
with both registrations intact. Preserve upstream function bodies and the
original say regression unchanged.

Validation: Python syntax/AST and registrations checked; public-tree check
passed; Python unittest suite passed (41 skipped). pr_ready could not start
Rust checks because Cargo is unavailable in this restored environment.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:37:01 +03:00
08393ef6f7 Parse entity-models.json once at startup
Part of #9998.

Startup parsed entity-models.json (2.4 MB) into a serde_json::Value six
times: for the handoff's GameCpuResources, MobTypes, the player type, both
player arm models and the spin-attack model. The rest worker did five of
those one after another. Nothing in the document changes during a loader
generation, and a pack change starts a new one.

load_resources now parses it once on its own step beside load_core and
shares it as an Arc<EntityModelDocument> with every model loader. A
missing or malformed document leaves each caller on its old path, so
errors and fallbacks are unchanged; a dedicated server draws no models and
skips the parse.

Callgrind, the whole resource load in the game (to "resources loaded"):
21.576 G -> 20.631 G instructions (-4.38%); the entity-model path alone
1.594 G -> 0.349 G (-78.1%). A new test builds MobTypes from a shared
document and from its own parse and compares every type, model and layer.

A first draft by DeepSeek V4.1 Flash (pi coding agent), reviewed by Claude.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 03:04:20 +01:00
ebe3818eb7 Light a section from its palette's light info, not its state ids
light_chunk filled its 48x48xheight region from every non-air section by
unpacking 4,096 state ids into a scratch array, then reading each one
back, looking up LightTables::info and storing 2 of its bytes. That round
trip was 35% of a light job, and the flood fill only ever reads those 2
bytes.

PalettedContainer::unpack_ids_mapped decodes the packed storage through a
lookup table in one pass (out[i] = map[id of entry i]), with a fast path
for 4 bits per entry, a linear palette's usual width. light.rs's new
states_of maps a section's palette to the packed light info once (at most
256 entries), then copies the region's 16-wide rows from the staged
values. A section whose palette emits light stages full infos and seeds
block light and the queue per cell, in the same order as before. Air now
writes info[0], which is 0, the value the region already holds (asserted
in debug builds).

Callgrind, gen_light_bench --seed 1234 --radius 4 --threads 1 (81 lit
columns): the light job 1.991 G -> 1.260 G instructions (-36.7%), the
whole run -1.15%. The light job is 5-7% of the workers' CPU, so this is
about 2% of the chunk pipeline.

Exact: gen_light_bench's block and light checksum is identical for three
seeds and centres at 1 and 4 threads, light_chunk_matches_old_algorithm
(an independent light implementation over random 3x3 regions) passes
unchanged, and the new section_states_match_the_unpacked_ids compares
states_of with a walk over unpacked state ids for one value, 4 to 8 bits
and the global palette, with and without light sources.

A first draft by DeepSeek V4.1 Flash (pi coding agent), reviewed by Claude.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 03:04:19 +01:00
voidsip 11dda135a8 Attribute bursts of short LAN events in slow tick notes 2026-11-05 19:52:54 -06:00
voidsip 8a65d28c0d fix(server): recover interrupted region pruning before startup 2026-11-05 19:47:51 -06:00
Skr1zzzz 643d699d51 Player list: keep the shown list's layout between frames
While Tab is held every frame sorted every entry twice (once for the faces,
once for the rows), allocated team keys inside the comparator, rebuilt and
measured every row's text, split the header and footer, and pruned the
health states with a scan per row (#10108). TabList now keeps the sorted
order and the laid-out rows, rebuilt only when their inputs differ (players,
scoreboard, header/footer, width, font and language); the faces reuse the
order; sort keys are borrowed; the health states go by a uuid set. Hearts
still animate every frame.
2026-11-06 04:24:37 +03:00
TheMrZZandCodex gpt-6.1-sol 7d5d34ae32 Play ordinary arrow hits in the neutral sound category
Override only ArrowSound::HitGround through Audio::play_on. Add an actual Game/Java block-hit capture using the existing headless client, Studio recorder and official sound codec.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 02:16:39 +01:00
Skr1zzzz f0de57ae23 Merge remote-tracking branch 'origin/main' into mp-fps-4664
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-06 03:52:21 +03:00
Skr1zzzz 2f075689f5 Multiplayer: skip name tags whose entity is off screen before laying out their text
Every frame built, styled and measured the name tag of every player within
64 blocks, drawn or not: with 150 players and the camera facing the sky that
was ~0.74 ms a frame (#4664). A tag is now built only for an entity vanilla
would render, its culling box grown by 0.5 in the frame's frustum
(EntityRenderer.shouldRender), checked before any text work; armor stands
likewise. Status and native snapshot queries still see every tag.

other_players finds each player's entity through one id->index map instead
of scanning every entity per player (the first match, as iter().find).
2026-11-06 03:47:29 +03:00
PzmuV1517 7b2ce7a1bc Shaders: the shader screen's words in every language
The shader screen's 17 new captions and descriptions, in the 86 languages besides English.
2026-11-06 02:30:40 +02:00
PzmuV1517 9e90050c60 Shaders: the old shader pack pages go
The shader screen (shader_ui) draws the pack list and every pack's settings,
so the options screen's own rows for them go: the Shader Packs and Shader
Options button lists, a Horizon pack's option pages, their actions and the two
pack slider kinds, with the label fitter only they used and the four lang keys
only they read.
2026-11-06 02:30:40 +02:00
PzmuV1517 131d5d73e2 Shaders: the shader screen reads the picked pack's settings however it opens
A button that pushes the Shader Packs page (as one on the Options screen would) listed the packs but left the picked pack's settings unread until a pack was picked again. Every way into the screen now reads them with the list.
2026-11-06 02:30:40 +02:00
PzmuV1517 f488baa0ab Shaders: the shader pack list and each pack's settings in the settings frame
Shader Packs... opens a screen in the frame of the graphics and controller
settings (settings_shell) instead of two vanilla button lists. Its first page
lists the installed packs (a tick box on the one in use; picking it again
turns shaders off), the pack folder, and Horizon's own Shadow Cache under
"Built-in settings". Under the picked pack's name, the page list holds the
pack's own screens (a shaders/ pack's screen.NAMEs, a Horizon pack's
[[pages]]), as "Pack settings": switches, sliders, choices, profiles and links
to deeper screens, with the pack's own descriptions in the strip. The search
finds an option on any of the pack's screens. A shaders/ pack's changes apply
with Apply or when the screen closes; picking another pack keeps the last
one's changes first.
2026-11-06 02:24:17 +02:00
Evrim Cicek dcfa4b07f7 Merge upstream/main 2026-11-06 03:05:16 +03:00
Evrim CicekandClaude Opus 5.5 00b9953741 Dragon: hover's fly speed, Mth.wrapDegrees, Mth trig on the server step, scanning's 10 degrees
From review, each measured against vanilla:
- DragonHoverPhase.getFlySpeed is 1, not the default 0.6: a hovering dragon thrown upwards
  climbs back at vanilla's rate (new harness case, 12 commands off before).
- wrap / wrap64 are Mth.wrapDegrees: Java's % then one step of 360. rem_euclid lost the low
  bits of a negative angle (-10.123456 became -10.123444) every tick (unit test).
- The parts' placement, the strafe aim and the flap timing use Mth.sin / Mth.cos and vanilla's
  float casts.
- DragonSittingScanningPhase turns only while the player is more than 10 degrees off the
  heading, with Mth.atan2 and vanilla's degree constant; dist is clamped in both divisions.
- A dragon's parts start at the origin, as vanilla's part entities do until the first aiStep,
  so its first scanning turn aims from there.

The command execution harness gets "dragon scanning turn" (a dragon facing a survival player and
one turned away): 6/12 before, 12/12 now. All dragon and End harnesses match; the whole command
execution harness: 15832/15832.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:05:04 +03:00
PzmuV1517 623aa1d3ea Shader packs: a texel offset given as a parameter moves the coordinate
GLSL asks a texel offset to be a constant expression; GL drivers also take one a
function receives as a `const in ivec2` parameter (Arc's FXAA samples its neighbours
that way, so its final pass was skipped in every dimension). Such an offset now moves
the coordinate by that many texels of the base level; a constant one stays an offset.
2026-11-06 01:52:10 +02:00
PzmuV1517 82e2c96a20 Shader packs: CAT_* and PPT_* are macros of the stage files too
The biome categories and precipitations custom uniforms compare with are defined for the
GLSL as well, with the same values: Sildur's Enhanced Default reads
`biome_category == CAT_THE_END` in gbuffers_skytextured, which failed on the unknown
name. BIOME_* still needs the world's registry and stays in custom uniforms.
2026-11-06 01:52:10 +02:00
PzmuV1517 ee50f8ae1a Shader packs: empty declarations, precision on parameters, infinities in functions
Three things GL drivers take that naga's GLSL front end refused:
- an empty declaration (`;`) at global scope in any version, as a macro that writes a
  function leaves after it (DrDestens: 56 programs skipped);
- a precision qualifier on a function parameter (`float rand(highp vec2 p)`,
  TrailerShaders: final and composite);
- a constant expression in a function whose value is infinite or NaN
  (`1.0 / toeX` with toeX 0, Epoch's tonemap at its defaults): it is computed at run
  time, as GL does, instead of folded into a literal that can't hold it. WGSL keeps
  refusing it.
2026-11-06 01:52:10 +02:00
PzmuV1517 bfc40195eb Shader packs: texture() and textureLod() read integer samplers
GLSL lets a pack sample a usampler2D or isampler2D with texture(); GL drivers read an
integer texture with nearest filtering, so it is the texel the point falls in. naga
rejects sampling an integer image, and Mello, Voyager, Exposa and Reverie lost the
programs that read their integer colortex buffers that way (Mello 20, Voyager 42, Exposa
48). Those calls now go to helpers that texelFetch the texel under the point, clamped to
the edge, at level 0 (textureLod: the level it names).
2026-11-06 01:52:10 +02:00
PzmuV1517 1da773a501 Shader packs: a matrix built from one vector takes its components column by column
`mat2(v)` with a vec4 `v` is the matrix with columns v.xy and v.zw in GLSL, as when the
components come as several arguments. naga's GLSL front end made each column the whole
vector, which failed validation ("Composing 0's component type is not expected"):
Bloop's water normals (prepare1) were skipped.
2026-11-06 01:52:10 +02:00
PzmuV1517 a02c7fcd4b Shader packs: DH_BLOCK_* are defined, with or without Distant Horizons
Packs compare block ids with Distant Horizons' block materials outside any
DISTANT_HORIZONS guard (Exposa: `blockID == DH_BLOCK_LEAVES`), so Iris defines them in
its standard macros whether the mod is there or not. Exposa's composite and composite1
failed on the unknown names; DH_BLOCK_UNKNOWN to DH_BLOCK_ILLUMINATED are now 0 to 15,
the mod's EDhApiBlockMaterial numbers.
2026-11-06 01:52:10 +02:00
PzmuV1517 19f87e8059 Shader packs: an integer vertex output travels flat without saying so
GLSL asks a fragment input of integer type to be `flat`, but since 4.30 only the
fragment stage's interpolation qualifier counts: a vertex stage may write
`out uint frag_worldTN;` and GL drivers link it to `flat in uint`. naga left such an
output without interpolation and refused it (Alpha Piscium: 111 programs). An integer
vertex output or fragment input with no qualifier is now flat.
2026-11-06 01:52:10 +02:00
PzmuV1517 83e2920c5e Shader packs: a paste that makes no single token stays its tokens
`colors2_colorspaces_ ## a ## _to_ ## b ##(x)` (Alpha Piscium) pastes the name's last
part to its `(`. The preprocessor made one token of `..._6_to_8(`, which named no macro,
so the call to the function-like macro `colors2_colorspaces_6_to_8` was left for the
compiler as an unknown function. As GL drivers' preprocessors do, the paste now stays the
tokens it is made of, and the name is rescanned with its `(`.
2026-11-06 01:52:10 +02:00
Skr1zzzz d5479156d7 Multiplayer: the hidden player list and its skins stop costing per player
draw_tab_list ran every frame and, through remote_tab_players, cloned every
listed player and formatted two object-skin keys for each before it knew
whether the list was shown; update_remote_skins then searched every entity
once per level player and cloned the whole skin map. With 150 players and
none drawn a frame still cost ~0.4 ms more than with none (#4664).

- The list's visibility comes from the listed count; the players are built
  only while it is shown or when it opens/closes (narration).
- The id:/name: object skins publish only when a player, its skin or the
  atlas's object skins changed (a fingerprint and an atlas revision), so
  inline faces keep the server's skin as before.
- update_remote_skins finds players and mannequins through one id->index map
  and moves the resolved skins in instead of cloning them.
2026-11-06 02:18:39 +03:00
masa-masa-0314andZCode f221ae33ef Realms: the invite-code list and its edit screen
The Players tab's second button now opens RealmsInviteCodesScreen: the count in the
title, the subtitle, a row per code with its expiration (or Never), Copy / Edit / Delete
per row, and Create. RealmsEditInviteCodeScreen edits one code: its active flag, the
seven InviteCodeExpiration choices (a click on the slider track picks the nearest — this
screen has no drag event yet), Copy to the clipboard (a new Outcome::Copy the title
turns into Action::CopyText), Delete, Done and Back.

RealmsClient gains inviteCodes (GET api/v1.0/invite/codes?realmId={id}),
createInviteCode (POST .../invite/codes), updateInviteCode (POST .../update) and
deleteInviteCode (DELETE .../delete/{code}), all on the invite-code service with the
token in x-mojangauth, with InviteCode's DTO. The existing redeemInviteCode now goes
through the same helper. An existing expiration reads back as the closest of the seven
choices (InviteCodeExpiration.closestTo).

Co-Authored-By: ZCode <[email protected]>
2026-11-06 07:16:04 +08:00
Evrim CicekandClaude Opus 5.5 b7841886cf A summoned Ender Dragon hovers where it was put, as vanilla's does
EnderDragon's constructor sets EnderDragonPhase.HOVERING; only EnderDragonFight.createNewDragon
sets the holding pattern. Dragon::new started every dragon holding its pattern, so a dragon from
/summon, a spawn egg or a save without DragonPhase flew off. It now starts hovering, and
create_new_dragon sets the holding pattern.

Hovering had never run on the server, and the flight step diverged there: the target is a hair
away, where Vec3.normalize returns zero under 1.0E-5 and vanilla's Mth.sin/cos table and
Mth.atan2 turn the dragon differently from the exact functions. The step now uses them, the
doubles of moveRelative's input vector and vanilla's 0.91F and 0.8F as doubles.

The command execution harness gets "summoned dragon hovers" (phase and position every 20 ticks
over 120): 0/13 before, 25/25 now. The dragon harnesses (server dragon, contacts, NoAI, not the
fight's, death outside the End, death roar, forced End, spawn eggs in fluids, dispensers) match.

Fixes #10285

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:11:40 +03:00
Evrim CicekandClaude Opus 5.5 d95d0be4e4 /damage on the dragon: a perched dragon refuses a direct arrow, the hit pushes it, the right guest gets the kill
From review:
- A perched dragon shrugs off a direct arrow, trident or wind charge given as `by`
  (AbstractDragonSittingPhase.onHurt is 0, under 0.01): the command is refused.
- dealDefaultKnockback: a counted hit of a type not in #no_knockback pushes the dragon away from
  the command's source position (`at`, else the direct entity), or in no direction without one.
- credit_dragon_kill switches to the credited guest whenever they are not the one acting, as the
  mob kill credit does, so a guest's command that credits another guest awards that guest.

The harness scenario summons its dragons holding their pattern (DragonPhase 0): vanilla summons a
hovering dragon, which is sitting, and Horizon one holding its pattern; that gap is separate.
The intermediate build: 36/39; now 39/39.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:22:38 +03:00
Ian WorleyandClaude Opus 5.5 8f4888a2ef docs(native-mods): document client world snapshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:46 -06:00
Ian WorleyandClaude Opus 5.5 d1eef82997 feat(native-api): client world snapshots for mods' worker threads
Client mods that bake from the world in bulk (a minimap's tiles, a world
map export) had to clone `game.world.chunks` and read `world_biomes!`,
which pins them to one Horizon commit. Four stable helpers replace that:

- `native_client_chunks(from, to)` shares the client world's loaded
  chunks of an area as `NativeClientChunks` (Send + Sync): block state,
  biome id, loaded, each column's top non-empty section, build height.
- `native_client_chunks_changed(&snapshot)` names the chunks that differ
  from the snapshot now. A held chunk is copied before any edit, so the
  same allocation means the same chunk.
- `native_client_biomes()` gives the biome table the terrain is drawn
  with (`NativeClientBiomes`): names and tints by `NativeBiomeId`.
- `native_client_biomes_changed(&biomes)` says when that table was
  replaced (a mod's seasons, a resource reload, another world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:46 -06:00
Ian WorleyandClaude Opus 5.5 dfa637e389 fix(native-mods): a world is known once logged in, and a pack reload covers it
A connection reported its world while still connecting, before a failed
login could drop it, and a resource-pack reload's loading screen still
counted as playing.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 897f17114a fix(native-mods): keep a friend's relay in its world identity
Round-two review finding: a long invite's identity kept only the host's
addresses when it had any. Two friends on other networks behind the
same private address and the fixed direct port (25580), each reached
through a relay, shared one NativeWorldId. The relay's address and
nameplate now stay beside the addresses; neither secret takes part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 8f75db7d5c fix(native-mods): portal loading covers the world; friends' worlds stay apart
Review findings on the session state helpers:

- NativeClientState.screen read remote_loading(), which leaves out the
  portal loading screens because they draw no panorama blur, so a
  minimap drew over them. A connection's screen is now one predicate,
  remote_screen_up(), which remote_overlay draws by too.
- Every long friend invite showed as the same "Friend's world", so two
  friends' worlds shared one NativeWorldId and a mod's per-world data.
  A friend's world is now friend: and the invite's public part (the
  host's addresses, else its relay nameplate), never its secret.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 6975556e3d feat(native-mods): session state a client HUD reads
A minimap or waypoint mod can now read what it needs through stable
helpers instead of Game's fields, on a server's client and in single
player alike:

- native_client_state() gains screen (NativeScreenKind: playing, chat,
  death or another screen), debug_screen (F3), world (NativeWorldId:
  single player's save folder or the server's address, for per-world
  data) and sleeping_pos (the bed, LivingEntity.getSleepingPos).
- native_biome(pos): the biome at a block as Level.getBiome picks it,
  through BiomeManager's zoom.
- native_data_dir(mod_id) joins the helper catalog.

The window's connection remembers the save it joined through memory,
its own server thread's or P1's for a splitscreen seat, so the world
identity needs no mixin on open_integrated. A friend's world is named
as players see it, never by its invite secret.

Helper-only API fragment session_state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Evrim CicekandClaude Opus 5.5 25bec21906 /damage hurts the Ender Dragon and End crystals as vanilla does
cmd_damage had no arm for them, so both said "Target is invulnerable".

EnderDragon.hurtServer is hurt(body, ...): a quarter plus up to 1; reallyHurt runs only with a
player behind the source or an #is_explosion type and refuses fire, and hurt succeeds either
way unless the dragon is dying or the hit is under 0.01. EndCrystal.hurtServer refuses an
Invulnerable crystal (unless generic_kill, out_of_world or a creative player), fire and the
dragon's own damage; else it is removed, and blows up unless the damage is an explosion.

The command execution harness gets a "dragon command damage" group: 15/31 before, 31/31 after.

Fixes #10265

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:59:38 +03:00
HiuoyandClaude Opus 5.5 b5f50bc41d Native hooks: AnvilUpdateEvent, a mod's say in an anvil's result (#10230)
anvil_update fires where AnvilMenu.createResult ends, once vanilla has
worked out the result, while the left slot holds an item: on the host for
each player's menu (single player's server thread, a LAN or dedicated
host), before prepare_anvil, and in the window's own menu. A mod is told
the inputs, the rename box's text and vanilla's result. It may change the
output, the level cost and the material cost, or cancel, which leaves no
result (NeoForge's CommonHooks.onAnvilUpdate).

The host asks again after a rename that leaves the result as it was, as
setItemName runs createResult. The window's click prediction starts from
the station's cost and material cost, so a result a mod made can be taken
there as on the server. The window names the player as the server knows
them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:52:39 +04:00
MSedgeMC cf6a402c41 port_map: EntityRenderer.getBoundingBoxForCulling, the interpolated box
The ledger named the four renderer overrides but not the method they override;
it now says the box is the frame's, grown by 0.5, and points at the dropped
item's own copy of it.
2026-11-05 20:49:37 +00:00
MSedgeMC 4ebdcbf7b2 test: the dropped item's cull box over the frame
An item sliding from x = 0 to 1 in camera space: the box is -0.625..0.625 at the
start of the frame, -0.125..1.125 halfway through and 0.375..1.625 at its end,
where the old code had the last on every frame. A falling item's box floor is
65.0 halfway down a 66 -> 65 drop instead of the tick's 64.5.
2026-11-05 20:49:37 +00:00
MSedgeMC 0fdc892ef9 fix: cull a dropped item at the box the frame draws
EntityRenderer.shouldRender tests the frustum against
getBoundingBoxForCulling(entity, partialTick), which EntityRenderer leaves as
Entity.getInterpolatedBoundingBox(partialTick) - ItemEntity overrides neither -
grown by 0.5 all round.

The dropped-item pass built that box from the tick's own position, so for the
frames of a tick in which an item crossed the frustum edge it was culled at the
cell it was about to reach: an item sliding from x = 0 to 1 had its box at
0.375..1.625 (camera space) on every frame, where at the start of the frame it
stands at -0.625..0.625.

dropped_item_cull_box(previous, position, alpha, camera) is that box now, the
interpolated position grown by the item's 0.125 half-width and 0.5, with the
0.25 height below and 0.75 above.
2026-11-05 20:49:37 +00:00
Florian ERNSTandCodex 8089757807 Reject missing advancement selector criteria
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:05:15 +01:00
MSedgeMC a266ac1c43 test: an empty-criteria advancement is done and is the one auto-granted
`Progress::done` on an advancement parsed from `{"criteria": {}}` (true, no
requirement groups to wait for) against one with a criterion (false), and
`Tree::automatic` over a tree holding both: only the empty one is listed, which
is what the load pass grants the rewards of.

Without the `done` change the first assertion fails at once
(`assertion failed: Progress::default().done(&empty)`), and `Tree::automatic`
does not exist to compile against.
2026-11-05 19:25:02 +00:00
MSedgeMC 349ec13a9b fix: an advancement with no criteria is done and pays out
`AdvancementProgress.isDone` loops over the requirement groups, so with no
groups at all it answers true: an advancement whose `criteria` map is empty is
finished from the moment the player's progress loads. `PlayerAdvancements.
checkForAutomaticTriggers` walks every advancement, and for those with no
criteria calls `award(holder, "")` - which finds no `""` criterion to grant, so
it neither toasts nor announces - and then `AdvancementRewards.grant`s it. The
rewards are granted again on the next load, because there is no criterion to
record them under.

Horizon required `!requirements.is_empty()`, so such an advancement was never
done and never paid out. `Progress::done` now keeps vanilla's shape (every group
satisfied, vacuously true with no groups), `Tree::automatic` lists the
advancements nothing can grant, and the progress load grants their rewards
through the new `grant_rewards`, which is the `AdvancementRewards.grant` half of
`award` split out so the toast and announcement stay with a real completion.

Scope, plainly: **no vanilla advancement has an empty criteria map** - all 1224
in the shipped data have at least one criterion - so nothing in the game's own
data changes. What changes is a datapack's: before this, an advancement it
declared with `"criteria": {}` could never be obtained and its rewards never
arrived, where vanilla hands it over on load.
2026-11-05 19:24:58 +00:00
andrescamilomartinezandAntigravity 2.0 313f65e049 fix(client): prevent taskbar overlap and window shift in borderless fullscreen
When entering borderless fullscreen on Windows while the window is
maximized, winit's WM_NCCALCSIZE handler forces the client area to
match the desktop work area (rcWork), which excludes the taskbar.
Because the window is sized to monitor dimensions, this clamps the
bottom edge above the taskbar and shifts the top edge offscreen by the
taskbar height (~48px), leaving the taskbar visible on ultrawide and
standard displays (upstream MC-311683).

Ensure that any transition to fullscreen unmaximizes the window first,
and explicitly pass the active monitor handle to Borderless mode so
the window covers the entire display area without vertical offset.

Fixes #9984

Co-Authored-By: Antigravity 2.0 <[email protected]>
2026-11-05 14:08:33 -05:00
MSedgeMC 4b8abba21c test: an automatic update keeps a disabled mod off
Install ruby 1.0.0, turn it off in the Mods page, run the automatic
update to 1.1.0: the file is replaced and the mod is still off. Turned
back on, the next update keeps it on, and an explicit install of a
disabled mod turns it on again. Without the fix the first update
re-enables it and the test fails at 'the update kept it off'.
2026-11-05 19:04:13 +00:00
MSedgeMC e932cade0c fix: an automatic update keeps a disabled mod off
install_version_guarded() turned every mod it installed on with
java::set_enabled(home, id, true), and the automatic registry update
(browser/horizon/auto.rs update) installs through the same function.
A content mod the player had turned off in the Mods page therefore came
back on after every automatic update: 'off' is the absence of the
'mod.<id>:true' line in horizon-mods.txt, which the update re-added.

The guarded install no longer touches the flag. The player's own
install (install_version, which the browser and modpack paths use)
turns the mod on after the guarded install, as before, while the
automatic update leaves the mod's current on/off choice alone. A new
install still starts on because it goes through install_version.
2026-11-05 19:04:13 +00:00
Florian ERNSTandCodex 7c171a543c Preserve loaded furnace cooking totals until completion
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:53:51 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol e1e307cbb5 fix(launcher): preserve native shader selection and options
Fixes #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:48:31 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 64297b8e8b fix(shaders): preserve native pack identity across updates
Part of #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:45:17 +01:00
Evrim CicekandClaude Opus 5.5 1bf04aa84f The vehicle's box is read now, and only for the vehicle ridden
From review, against vanilla (getAttackBoundingBox and getHitbox read the
current getVehicle()):
- A mob vehicle's box comes from the mob snapshot, which holds it as it is
  after its own tick this tick, not from the last positionRider.
- The seat positionRider records names its vehicle, and is used only while
  the mob rides that one: a dismount or a new vehicle no longer reads an old
  vehicle's box or attachment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:13:30 +03:00
Federico TaglianiandCopilot App 8a2619be29 Make launcher titlebar match glass UI
Co-authored-by: Copilot App <[email protected]>
2026-11-05 19:11:13 +01:00
HiuoyandClaude Opus 5.5 8ec7d24add port-pack events: RecipesUpdatedEvent is looked up as RecipesReceivedEvent
1.21.1's client had every recipe and 27 of ATM10's mods listen to
RecipesUpdatedEvent to rebuild what they made of them. 26.1's client gets
only the recipe types a mod asks for, in RecipesReceivedEvent (NeoForge's
documentation, "Client-Side Recipes"), which recipes_received answers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:10:13 +04:00
Nikita Maximilian Grimm 02ba821bcf Merge remote-tracking branch 'origin/main' into hdr-note-serve-0-1-until-step-5
Co-Authored-By: GPT 6.1 Sol <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-05 19:04:22 +01:00
MSedgeMC 3ef1d5a7aa port_map: StatsScreen.keyPressed is the statistics tab bar's keys 2026-11-05 18:04:22 +00:00
MSedgeMC fadcfcd6d0 test: the statistics tab bar takes Ctrl+Tab, Ctrl+Shift+Tab and Ctrl+number
`StatsUi::key` as `Game::stats_key` calls it: the cycle wraps both ways, a
number picks its tab and is clamped to the last one, a key without Control is
left alone, an empty Items or Mobs tab is skipped (and not switched to by its
number), and a switch scrolls the new list back to the top.
2026-11-05 18:04:22 +00:00
MSedgeMC c679dff99e feat: Ctrl+Tab switches the statistics screen's tabs
`StatsScreen.keyPressed` runs `MenuTabBar.keyPressed` first: with Control
held, `TabNavigationBar` cycles the tabs on Tab and Shift+Tab (skipping a tab
that cannot be chosen) and picks one on the number keys, where
`InputConstants.Key.getNumericKeyValue` reads the main row's digits and the
keypad's. Horizon's statistics screen had no keyboard handling at all, so only
the mouse could change tabs.

`StatsUi::key` is that tab-bar key press: it reports whether the bar took the
key, moves to the chosen tab when that tab has rows, and starts that list at
the top as a tab press does. `tab_key` ports `getNextTabIndex` (a digit is
`floorMod(digit - 1, 10)` clamped to the last tab; Tab walks to the next tab
that can be chosen, one full turn at most) and `numeric_key_value` ports
`getNumericKeyValue`. `Game::stats_key` gives it the tabs that have rows
(`stats_tab_active`) and plays the tab's click when the tab changes, and
`Game::key_pressed` hands the statistics screen its keys.
2026-11-05 18:04:22 +00:00
MSedgeMC d0a71973e8 The health HUD state's doc drops the removed heights
native_health_hud_state described left_height and right_height as the ones
that place the selected item's name and the action bar. They are gone, and
the text no longer reads them.
2026-11-05 18:04:12 +00:00
MSedgeMC 15a6831a33 The HUD probe fixture drops the bars' height with the argument
The two calls in hud_fixture/kinds.rs still passed the 39 the old signatures
took. The probe compares against the vanilla client, which reads neither, so
nothing else changes.

cargo clippy --examples -p horizon-client-lib -p horizon-client -- -D
warnings is clean.
2026-11-05 18:03:21 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol ffd9b8f475 chore: remove agent markers from shader code comments
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:00:20 +01:00
Nikita Maximilian Grimm ceeb7841e2 Merge remote-tracking branch 'origin/main' into shader-api-02-serve 2026-11-05 18:59:42 +01:00
DUzzL 9cf77dcdd7 Run location effects of newly worn armor at once 2026-11-05 18:54:19 +01:00
DUzzL 7858858b94 Make thrown potions bounce off a breeze 2026-11-05 18:54:16 +01:00
Evrim CicekandClaude Opus 5.5 934c4aaabf A mounted mob reaches from its vehicle's width, and a rider is hit from its seat up
Mob.getAttackBoundingBox widens the attacker's box on x and z to its
vehicle's before the melee reach is added, so a zombie on a horse, a
chicken jockey or a mob in a boat reaches as far as the side of what it
rides. LivingEntity.getHitbox starts a rider's box at its seat
(vehicle.getPassengerRidingPosition(this).y), so its legs can't be hit; for
a riding player that is 0.6 above its feet (Avatar.DEFAULT_VEHICLE_ATTACHMENT).

positionRider now records each mob rider's own vehicle attachment and its
vehicle's box (mobs, boats, minecarts, primed TNT, displays), which the
melee check and the slime's touch read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:50:13 +03:00
Claude cc474268e7 Launcher: a server on another computer, run over SSH
An Entry on the Servers list can name a host: its folder is then on that
computer, and Entry::command runs the same horizon-server arguments there
as `ssh HOST horizon-server --dir DIR ...`, with the system's ssh and the
player's keys (BatchMode, so no password is asked for or kept). Each
argument goes to the remote shell in single quotes, and a host that ssh
could read as an option is refused. remote::check asks the server there
for its status and says in plain words what is missing: a key, the host,
horizon-server, or a server in that folder.

Lists written before keep reading as they were; nothing on the page uses
remote entries yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
2026-11-05 17:48:14 +00:00
Nikita Maximilian Grimm 276919beae Merge remote-tracking branch 'fork/shader-api-02' into shader-api-02-serve 2026-11-05 18:47:17 +01:00
yahya-nusseibehandClaude Opus 5.5 e0b19571dd RSA: constant-time private-key operations
The dedicated server and a LAN host decrypt each client's
ServerboundKey with the server's private exponent. That used
square-and-multiply, which multiplies only on the exponent's set bits,
with a data-dependent final subtraction in each Montgomery product, and
no blinding: a client controls the ciphertext and can time the reply,
the classic extra-reduction channel. The key is regenerated every start
(as in vanilla) and a recovered one still needs an on-path position,
but vanilla's JCE blinds this operation.

- Mont::mul subtracts n through a mask chosen from the overflow and
  the borrow of t − n, never a branch (Big::sub_with_borrow,
  Big::select). Every product takes the same steps.
- Mont::pow_secret: a square and a multiply for every bit of the
  exponent's limbs, the product kept by a mask. RsaPublicKey::
  apply_secret uses it; RsaPrivateKey::decrypt and ::sign call that.
  Public-key verification and encryption stay on the faster pow.
- Big::from_be_bytes no longer skips zero bytes: it parses the private
  exponent on every call, and the result is the same.
- Test: pow and pow_secret agree with a reference modular power, for
  moduli near a limb boundary (the overflow case) and exponents with
  leading zero limbs. The 11 existing RSA tests (OpenSSL signature,
  known-key decryption, generated keys) pass unchanged.

No wire or parity change. Multiplicative blinding would be the more
complete fix once a modular inverse exists.

Found in yahya-nusseibeh's review of Horizon (L1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:40:43 +02:00
yahya-nusseibehandClaude Opus 5.5 3b71256fcd RCON: one password guess per connection
After a refused login the connection stayed open, so within the 30 s
login window a client could send as many AUTH packets as it liked: with
the 16 connections allowed at once, thousands of guesses a second
against an RCON port on 0.0.0.0, each adding a supervisor log line.
Vanilla does the same, but this module already departs from vanilla for
safety (constant-time comparison, login deadline, client cap), and this
belongs with those.

- A refused login (wrong or empty password) answers as before, waits
  REFUSED_PAUSE (1 s) and closes the connection. Its place stays taken
  meanwhile, so the client cap also bounds how fast passwords can be
  tried: at most 16 guesses a second instead of thousands.
- A command before the login is still refused without closing, and the
  wire protocol is unchanged; standard RCON clients connect again after
  a refusal.
- Tests: a wrong and an empty password each end their connection after
  the pause; a failed login after a successful one does too. The pause
  is a Limits field so tests use 200 ms.

Found in yahya-nusseibeh's review of Horizon (L3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:37:23 +02:00
Horizon agentandClaude Opus 5.5 96d60b9daf Multiplayer: sneaking for a use is the input of the last tick, as vanilla's isShiftKeyDown
LocalPlayer.isShiftKeyDown reads input.keyPresses, which LocalPlayer.aiStep
sets from the keys: what the last ServerboundPlayerInputPacket said.
Minecraft.handleKeybinds runs before it, so a sneak pressed in the frame of
a right-click counts from the next tick. Horizon read the live Shift key for
ServerboundInteractPacket's secondary flag and for every secondary-use
check (isSecondaryUseActive): a click in that frame sent a sneaking
interaction a server saw before the sneak itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:56:54 +01:00
MSedgeMC 51a4b98992 port_map: the item name and the action bar rows name their layout fns
Hud.extractSelectedItemName is hud_overlays::ToolHighlight::draw and
Hud.extractOverlayMessage is hud_overlays::Titles::overlay_message; neither
row existed. map-check: 151 rows, no problems.
2026-11-05 16:54:42 +00:00
MSedgeMC 0e97601d25 test: the item name and the action bar sit at vanilla's heights
text_position_tests asks a ToolHighlight holding a stack and a Titles
holding an action bar for their draw calls at 320x240: the name's text top
is 240 - 59 (and 240 - 45 in a mode that cannot hurt the player), the action
bar translates to 240 - 68 with its text four pixels under that, and a
zeroed timer draws nothing. The font is built from no resources, as
chat_tests does, so the test needs no game assets.
2026-11-05 16:54:42 +00:00
MSedgeMC d48f37c2a4 fix: the selected item name and the action bar keep vanilla's height
Hud.extractSelectedItemName draws the name at guiHeight - 59, plus 14 in a
mode that cannot hurt the player, and Hud.extractOverlayMessage puts the
action bar at guiHeight - 68: both constants, which the health, food, air and
mount rows above the hotbar never move.

Horizon moved both up by max(leftHeight, rightHeight). That is 59 for a
survival player with armour, so the common case agreed by accident, but it is
69 with a second row of hearts and 81 for a player underwater on a mount, and
the two texts floated ten or twenty pixels above where vanilla draws them.

Both use the constants now, and HealthHud no longer keeps the heights: 26.3's
Hud has no such fields, only the locals of extractItemHotbar that the
contextual-info bar reads.
2026-11-05 16:54:42 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 785a399740 docs: HDR design note: only the 0.1 shim goes with 0.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:53:11 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a88fab01a5 A mob's Thorns hurts the player who hits it
EnchantmentHelper.doPostAttackEffectsWithItemSource runs the victim's
equipment before the attacker's weapon: the player's melee hit and sweep,
arrow, trident and wind charge now meet the mob's own post_attack
effects. Thorns rolls from the level's random, hurts the player from
the wearer by 1-5 and wears the piece by 2 (Unbreaking, breaking with
its entity event); the player's own Thorns doesn't answer a thorns hit,
and a thorns hit that lands whole plays enchant.thorns.hit.

The mace harness gets armored targets with Thorns and Unbreaking; new
lan_e2e check mob-thorns.

Part of #9954

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:49:52 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 02b2f7b9c0 docs: HDR design note: serve shader API 0.1 until 0.2 is on by default
The note kept 0.1 at least until 0.2 leaves experimental, which would keep
today's HDR path (marks, the 16-bit unorm frame, Smooth Gradients) alive for
0.1 packs alone. The shader API is experimental, which stability.md lets
change in any release without notice. So step 5, which turns 0.2 on by
default, also stops serving 0.1: 0.1 packs are refused with stability.md's
message, the registry lists 0.2 packs, and Golden Hour's 0.2 port becomes the
example. Later experimental lines follow the same rule. The shader API page
tells pack authors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:46:24 +01:00
IsT3RiK 44b980def2 Merge remote-tracking branch 'upstream/main' into traduction-messages-mods
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 17:46:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 12daf9d13f Golden Hour 0.2: 0.1's look in the grade, Horizon's tone curve
The port ran 0.1's grade on linear values, which darkened midtones by about
nine 8-bit steps. The soft shoulder and the saturation are a look written for
display values, so the grade now runs them on the encoded picture, while the
golden tint, the glow and the vignette stay on light. The pack drops its own
[tonemap]: Horizon's curve clips at white in SDR as 0.1's clamp did, and light
above white survives the grade for HDR. The captures now match 0.1 within
about 0.3 steps on average; what's left is the glow around light sources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:44:38 +01:00
IsT3RiK f719bbef1c Merge remote-tracking branch 'upstream/main' into traduction-fr
# Conflicts:
#	launcher/lang/fr.json
2026-11-05 17:43:05 +01:00
MSedgeMC dd6a0b938d port_map: ChatScreen.formatChat is the input's draft formatter 2026-11-05 16:16:26 +00:00
MSedgeMC 4077c5e01c test: a restored chat draft draws grey italic and one Backspace clears it
Through `ChatScreen::new` with `is_draft`, `key` and `draw` - the calls
`Game::open_chat` and `Game::chat_key_code` make: the draft's drawn text is
grey and italic, an edited draft and a box opened on ordinary text are not,
and the first Backspace on an untouched draft empties the box where an
ordinary box loses one character.
2026-11-05 16:16:26 +00:00
MSedgeMC 6a51ac58a7 feat: a restored chat draft draws grey italic and clears on the first Backspace
`ChatScreen` keeps the `isDraft` its constructor argument sets: `formatChat`
draws the text grey italic until `onEdited` runs, and `keyPressed` clears the
whole box on the first Backspace while it is still set. Horizon tracked the
draft (`Game::chat_draft`, `chat_draft_shown`) but opened the box as plain
text: `open_chat_method` knew whether the text was the draft and did not pass
it.

`ChatScreen::new` takes `is_draft` (`ChatScreen.init`'s `isDraft`), `edited`
(`onEdited`) clears it, `key` (`keyPressed`) clears the input on a Backspace
while it is set, `format` applies the draft formatter (`formatChat`, which
`ChatScreen.init` registers before `CommandSuggestions.formatText`) and
`resize` keeps it (`ChatScreen.resize` keeps the field).

`Game::open_chat` threads the flag from `open_chat_method`
(`ChatComponent.createScreen`); the in-bed wake-up
(`InBedChatScreen.onPlayerWokeUp`) and `Gui.openChatAndAddText` open plain
text, as vanilla does.
2026-11-05 16:16:26 +00:00
repositorykreml1nandCodex 6d9e1191a2 fix: broadcast command-block say announcements to connected players
Broadcast unsigned say chat independently of feedback capture and keep the
command source display name. Add a two-client LAN regression for both
TrackOutput states and conditional-chain success.

Co-Authored-By: Codex <[email protected]>
2026-11-05 19:16:22 +03:00
DUzzL fb828fda35 Teleport spectators that use a nether portal, end portal or end gateway 2026-11-05 17:08:32 +01:00
umcekoandClaude Opus 5.5 0e80c10042 Projectiles owned by any non-player entity need mob_griefing
Projectile.mayInteract asks mob_griefing for every owner that isn't a
player, and canBreakBlockInAdventureMode lets no non-player owner break
a block without can_break. An owner UUID that names a loaded armor
stand, boat or other non-mob entity was treated as no owner, so a
burning arrow lit TNT and campfires with mob_griefing off. The arrow now
asks the level for the UUID, as getOwner does.

Fixes #9999

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:06:37 +03:00
some-du6eandClaude Opus 5.5 a1a052c35c OptiFine custom skies: read the sky layers of optifine/sky
Parse optifine/sky/world<N>/sky<M>.properties into layers (source, fade
times, blend, rotation, days, weather, biomes, heights, transition), the
sun and moon_phases replacements, and the 3x2 skybox grid's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:59:41 -06:00
Riccardo GuerrieroandClaude Opus 5.5 7a7cf79d4b A player's death makes a vibration and counts toward Killed By
ServerPlayer.die posts entity_die at the player, and with a kill credit
awards killed_by for the killer's type. A creeper, sulfur cube or wither
whose own blast kills the player is still credited, as vanilla's player
dies inside the explosion: ExplosionSource carries the mob's type.
New lan_e2e check death-credit.

Fixes #9955

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 16:48:54 +01:00
DUzzL 37fed7a88e Credit damage_dealt_absorbed for a player's projectiles 2026-11-05 16:47:46 +01:00
MSedgeMC 955558305a Merge remote-tracking branch 'upstream/main' into fix/prediction-snap
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-05 15:42:26 +00:00
IsT3RiK f3e8afcb03 Launcher: add Glass, Blur and the server modes' notes to every table
These labels reach tr through a variable (the Launcher settings' slider rows, MODE_NOTES in
the server dialog), so the scan for tr("...") never asked for them and no table had them: they
showed in English in every language. Added to every table, in English, and translated in
fr.json.
2026-11-05 16:05:40 +01:00
IsT3RiK 66beeb539d Launcher: the Mods page's install messages go through tr
The messages the Mods page shows after an install (a mod, a native mod, a data pack, a
modpack, a resource pack) and its "isn't set up" errors were built with format!, so they
stayed in English in every language: "Installed JEI for Horizon 0.1.0 into Horizon. Build to
play with it." on a French launcher. They now use trf with named holes. The 11 strings are
added to every table, in English, and translated in fr.json.
2026-11-05 15:47:58 +01:00
IsT3RiK 3a298db1c0 Launcher French: translate the 38 strings left in English, address the player as vous throughout
38 entries still held their English text (the modpack coverage reasons, Performance mode, the
Vulkan fallback notice and a few command-line hints). 15 entries said tu while the other ~110
said vous, sometimes side by side on one page; they now say vous, as the game's own French
table does. Names that read the same in French (Mods, Console, Nightly, Snapshots...) stay.
2026-11-05 15:45:27 +01:00
HiuoyandClaude Opus 5.5 6d59c2da8d Port pack events: tests and the listener class fixture in tests/events.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:44:02 +04:00
HiuoyandClaude Opus 5.5 8a3eafbd61 Port pack: events, a mod's NeoForge listeners against the hooks (#9921)
`horizon-port-pack events <jar or folder>` reads class files for the
NeoForge events a mod listens to (an @SubscribeEvent method's parameter,
a method reference or lambda that takes one event) and the Capabilities
fields it reads, and looks each name up in native-hooks.json's `neoforge`
labels: a hook, another phase's hook, a hook whose label says why it
isn't quite one, or none. Over a folder of jars it ranks the names by
how many mods use them. Names NeoForge changed after 1.21.1 are looked
up under their new ones. It reads names, never code.

On ATM10's 498 jars it takes 8 s: 416 of 497 mods use 326 names, 6,616
uses in all. Main's catalog has a hook for 23% of them, and 43% with
#9924's labels. The top gameplay gaps are ClientTickEvent (95 and 46
mods), RenderLevelStageEvent (81) and ClientPlayerNetworkEvent (53, 35).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:42:14 +04:00
Evrim Cicek f732441c84 rustfmt 2026-11-05 17:38:11 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 2bc92b6348 Serve shader API 0.2 behind HORIZON_HDR_FRAME=float
With the switch, the game draws 0.2 packs ([frame] blend = "linear" is
still refused). A renderer made with a 0.2 pack picked draws a float frame
where the device can, else the 8-bit one; a pack picked or dropped later
runs on the frame there is, logged once. The renderer's material, sky,
weather, vertex and shadow entries decode a 0.2 stage's colours and encode
its results; scene_color, opaque_color and the water mirror are decoded
copies, shadow_color is float, and Frame's sky and fog colours are
decoded. The post chain ends in a window-size picture, and the pack's
[tonemap] (or Horizon's clip) draws it onto the frame, encoded at alpha 1.
With HDR on, HDR output lifts nothing of a 0.2 pack's world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:37:14 +01:00
Evrim CicekandClaude Opus 5.5 068b87cc25 An iron golem's anger target is kept, saved and forgiven like a wolf's
Now that the golem keeps its anger target through updatePersistentAnger:
NeutralMob.playerDied ends anger at the dying player even once the golem
lost sight of them; the save writes the anger target, not the current
target; and anger at a mob is written with that mob's UUID and read back
as anger at a mob, so a reloaded golem that was fighting a monster is not
isAngryAtAllPlayers under universal_anger. A calm mob no longer keeps a
loaded angry_at in its save.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:24:18 +03:00
Evrim CicekandClaude Opus 5.5 a5416ca075 Iron golems toss by knockback resistance and keep their anger like vanilla
IronGolem.doHurtTarget throws the target up only when the hit lands, by
0.4f * max(0, 1 - knockback resistance); Horizon threw everything it swung
at by 0.4. The hit and the toss are one event now, like the warden's
sonic boom.

IronGolem.aiStep ends in NeutralMob.updatePersistentAnger(level, true), the
same as a wolf's: every tick it holds a target, a monster too, the anger
timer restarts with a draw of the golem's random. Horizon only angered it at
a player who hit it, so a golem going after a monster drifted from vanilla's
random within a few ticks. The golem now uses the wolf's port, and its
player target goal asks isAngryAt.

LivingEntity.aiStep still drops motion below 0.003 and runs pushEntities
without AI; Horizon's NoAI branch skipped both, which the new mob AI
scenarios (a golem fighting a NoAI zombie, ravager and warden) showed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:09:34 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 36021e2559 Golden Hour's shader API 0.2 port, as a test pack of the SDK
The example pack written for 0.2: its sky and post passes in linear light,
with the 0.1 pack's gamma constants decoded, and a [tonemap] stage of its
own that eases colours above three quarters of the headroom toward it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:59:12 +01:00
GabrielNichols 37ff7be994 Format the vanilla recipe source call 2026-11-05 03:30:41 -03:00
GabrielNichols 476c083e82 Send owned content recipes in the server book 2026-11-05 03:14:27 -03:00
GabrielNichols 055c47c98d Merge official main into Recipes content scenario 2026-11-05 02:45:32 -03:00
GabrielNichols 31756ebfe2 Index owned content recipes for book placement 2026-11-05 02:30:21 -03:00
GabrielNichols 0e6e28109f Keep the pilot fixture focused on an original content recipe 2026-11-05 01:56:50 -03:00
GabrielNichols e76cb1f87a Use edit-box input when replacing Recipes search 2026-11-05 01:45:55 -03:00
GabrielNichols e770447686 Keep Recipes search editable after an empty result 2026-11-05 01:11:59 -03:00
Claude 82f601072b Server panel: the launcher's Glass look, a page for each part, in-page dialogs
The panel takes the Glass look from docs/design-system.md: the sunset with pixel ridges, frosted
plates, Geist and Bricolage Grotesque Heading, the theme's Hugeicons. What it already did gets a page
each in a sidebar (overview, players, console, backups, crash reports), and confirm()/prompt() become
in-page dialogs. The supervisor serves the theme's fonts and the mark; the CSP gains font-src 'self'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
2026-11-04 23:29:16 +00:00
MSedgeMC 6510fd2039 The ledger knows Entity.isColliding is ported
The row described it as missing because the prediction only restored the block.
It is game/remote.rs::Game::block_meets_player now, and end_predictions acts on
the answer.
2026-11-04 16:07:38 +00:00
MSedgeMC 817722009c Test the collision and the snap a restored block makes
Entity.isColliding on a full block at the player's feet meets a 0.6 x 1.8 box
standing in it, and a block above its head, the one beside it or an empty shape
does not. The snap takes the remembered position while the teleport guard allows
it, and nothing when the block came back clear of the player or the player was
teleported at the same sequence.
2026-11-04 16:07:38 +00:00
MSedgeMC ea80bba0f7 A refused block change that comes back inside the player snaps it out
ClientLevel.syncBlockState is what BlockStatePredictionHandler.endPredictionsUpTo
calls once the server acknowledges a sequence: when the block takes a state it
did not have, the level restores it, and then, if the prediction remembered a
player position and the player was not teleported since
(lastTeleportSequence < sequence), it asks Entity.isColliding(pos, state) -- the
state's own collision shape at the player's pose against the player's box -- and
calls Player.absSnapTo with the remembered point when they meet.

Horizon's end_predictions restored the block and stopped there, so a break the
server refused under the player, or a placement it pulled back, left the player
standing inside it until its own physics pushed it out.

A prediction now remembers what vanilla's ServerVerifiedState does -- the
sequence, the state the server last gave the block, and the player's position
when the change was first predicted -- and keeps all three through a
retainKnownServerState that only bumps the sequence. end_predictions restores the
block, tests the shape against the player and snaps to the remembered point when
the guard allows it. remote_teleport takes BlockStatePredictionHandler.onTeleport,
so a prediction made before a teleport keeps its block but not its snap.
2026-11-04 16:07:37 +00:00
873 changed files with 56943 additions and 6121 deletions

No files matched your search

+8 -7
View File
@@ -234,14 +234,15 @@ jobs:
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
# `$output = & launcher --version` read no output from it (run 42217014588).
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
python ../tools/check_imports.py "$launcher"
out=$("$launcher" --version)
echo "$out"
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
+3 -1
View File
@@ -34,7 +34,9 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
Generated
+199 -3
View File
@@ -153,6 +153,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@@ -540,6 +547,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
@@ -946,6 +959,18 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1135,6 +1162,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
@@ -1264,6 +1301,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1660,10 +1706,15 @@ dependencies = [
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"gif",
"mysql",
"quinn",
"redis",
"rusqlite",
"rusty-opus",
"snap",
"tungstenite",
"yaml-rust2",
]
[[package]]
@@ -2268,6 +2319,17 @@ dependencies = [
"redox_syscall 0.9.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -2323,6 +2385,12 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -2450,6 +2518,7 @@ dependencies = [
"btoi",
"byteorder",
"bytes",
"chrono",
"crc32fast",
"flate2",
"getrandom 0.3.4",
@@ -2484,7 +2553,7 @@ dependencies = [
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
"unicode-ident",
@@ -2498,7 +2567,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"rustc-hash 1.1.0",
"thiserror 2.0.21",
]
@@ -3354,6 +3423,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -3401,6 +3526,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3539,12 +3673,32 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3619,6 +3773,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -3883,6 +4038,12 @@ dependencies = [
"serde",
]
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4198,13 +4359,23 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
[[package]]
@@ -4274,6 +4445,9 @@ name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
@@ -4412,6 +4586,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
@@ -4675,6 +4855,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -4728,7 +4914,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
@@ -5471,6 +5657,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"hashlink",
]
[[package]]
name = "yasna"
version = "0.6.0"
+62 -64
View File
@@ -1,77 +1,87 @@
<p align="center">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
<img src="docs/media/banner.webp" alt="Horizon: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Docs</b></a> ·
<a href="https://discord.gg/playhorizon"><b>Discord</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
</p>
# Horizon
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
**Minecraft: Java Edition 26.3, rewritten in Rust.** Your worlds, the same rules and the same terrain as the Java game, checked against it by hundreds of differential tests, in one native program with no Java. Free, open source (MIT) and unofficial: you play it with your own copy of Java Edition.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
</tr>
</table>
<p align="center">
<img src="docs/media/readme/valley.webp" alt="A river valley with a plains village, savanna hills and a cherry grove, seen from above with a long view distance" width="100%">
</p>
## What works today
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
Everything below is in the current release.
In the Rust client, with vanilla assets and no mods or shaders:
- **The game.** Survival, creative, hardcore and spectator; vanilla world generation in all three dimensions, mobs, villagers, raids, redstone, every command, the HUD and the recipe book. Vanilla saves open as they are, and worlds from 1.8 to 26.2 are upgraded.
- **Multiplayer.** Join vanilla and Horizon servers (online mode included), open a world to LAN, or invite friends with a code from the launcher's Home page.
- **Graphics.** Distant terrain up to 1,024 chunks, graphics presets, HDR and controllers, all off by default where they would change the vanilla look ([settings](https://playhorizon.dev/docs/settings)).
- **Packs.** Resource packs, data packs and shader packs ([below](#mods-and-shader-packs)).
- **Mods.** Content mods and native Rust mods, installed from the launcher ([below](#mods-and-shader-packs)).
- **Servers.** Host one from the launcher, or run `horizon-server` on a VPS ([below](#host-a-server)).
- **The launcher.** It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
<td width="50%"><img src="docs/media/readme/sunset.webp" alt="Sunset behind a cherry grove on a hill, with a river below"></td>
<td width="50%"><img src="docs/media/readme/nether.webp" alt="A crimson forest in the Nether, with a piglin among the fungi"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/readme/survival.webp" alt="Survival in a village square: two villagers, flowers, the hotbar, health and hunger"></td>
<td width="50%"><img src="docs/media/readme/multiplayer.webp" alt="Two players in a LAN world: the other player's name tag and the player list"></td>
</tr>
</table>
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
| Platform | File | Needs |
| --- | --- | --- |
| Windows x64 | `horizon-launcher-setup.exe` | Windows 10 or 11, a GPU with Vulkan or DirectX 12 |
| Windows on Arm | `horizon-launcher-setup-arm64.exe` | Windows 11 on Arm |
| macOS, Apple Silicon | `horizon-launcher-macos-arm64.dmg` | macOS 12 or later |
| macOS, Intel | `horizon-launcher-macos-x64.dmg` | macOS 12 or later |
| Linux x86-64 | `horizon-launcher-linux-x86_64.AppImage` or `.tar.gz` | a glibc distribution, X11 or Wayland, a GPU with Vulkan or OpenGL 3.3 |
**You also need a Microsoft account that owns Minecraft: Java Edition.** You sign in with it in the launcher, which plays only with an account that owns the game ([why](docs/microsoft-account.md)). Horizon contains no Minecraft code or assets: an existing install (official launcher, Prism, MultiMC...) is reused, and without one the launcher downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/readme/launcher-home.webp" alt="The launcher's Home page: the Horizon instance, Play, the version picker, the friend invite field, screenshots and the patch log"></td>
<td width="50%"><img src="docs/media/readme/launcher-mods.webp" alt="The launcher's Mods page: Horizon's mod registry with Zoom selected, and tabs for modpacks, resource packs, shader packs and data packs"></td>
</tr>
</table>
## Mods and shader packs
- **Content mods** (`.hmod`): blocks, items, recipes, loot, models and textures, with no code.
- **Native mods** (`.hnmod`): Rust that the launcher compiles into the game for you, with nothing to install.
- **Java mods** (Fabric, NeoForge, Forge) don't run: Horizon has no Java. The launcher carries over the data and assets inside their jars, and imports modpacks (`.mrpack`, CurseForge zips, Prism instances) the same way ([modpacks](https://playhorizon.dev/docs/modpacks)).
- **Shader packs**: Iris/OptiFine-format packs (`shaders/` folders) and Horizon's own WGSL packs, under Video Settings → Shader Packs ([pack format](docs/shaders.md)).
Mods are off by default: a fresh install plays exactly like vanilla 26.3. The launcher's Mods page browses Horizon's registry and Modrinth. [Mods](https://playhorizon.dev/docs/mods) · [make a mod](docs/mods.md).
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends. On a VPS, `horizon-server` installs with one command, needs no Java, and takes vanilla 26.3 and Horizon players alike. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Agent control
@@ -80,13 +90,11 @@ AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, re
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
horizon-ctl state # position, health, food, target, screen...
horizon-ctl find-block birch_log # the nearest logs
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
horizon-ctl break -1 65 -3 # mines it; replies with the drops
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
`horizon-ctl help` lists every command. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
@@ -105,13 +113,9 @@ cargo run --release -p horizon-client
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
## Roadmap
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests), and see [ROADMAP.md](ROADMAP.md) or [playhorizon.dev/hub](https://playhorizon.dev/hub) for what's next. Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
@@ -124,7 +128,7 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
### Java parity: 100%
@@ -147,14 +151,8 @@ Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Horizon is made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)) and [its contributors](https://github.com/horizon-rust/horizon/graphs/contributors); support it on [GitHub Sponsors](https://github.com/sponsors/ludovic111).
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT. Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
+40 -2
View File
@@ -266,7 +266,7 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786) · [#9045](https://github.com/horizon-rust/horizon/issues/9045) · [#6233](https://github.com/horizon-rust/horizon/issues/6233) · [#6485](https://github.com/horizon-rust/horizon/issues/6485) · [#3734](https://github.com/horizon-rust/horizon/issues/3734) · [#3125](https://github.com/horizon-rust/horizon/issues/3125)
</details>
@@ -298,6 +298,8 @@ Far terrain drawn from the world you really built and explored, caves and builds
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
More: [#4678](https://github.com/horizon-rust/horizon/issues/4678)
</details>
#### A batch of targeted optimisations
@@ -308,7 +310,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
More: [docs/profiling.md](docs/profiling.md) · [#7752](https://github.com/horizon-rust/horizon/issues/7752) · [#8251](https://github.com/horizon-rust/horizon/issues/8251) · [#10229](https://github.com/horizon-rust/horizon/issues/10229)
</details>
@@ -322,6 +324,8 @@ Recognise mods built for versions before 26.1 in the mod browser, so they can be
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
More: [#10262](https://github.com/horizon-rust/horizon/issues/10262)
</details>
#### A modpack's own resource and shader packs
@@ -536,6 +540,8 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
More: [#6302](https://github.com/horizon-rust/horizon/issues/6302) · [#5313](https://github.com/horizon-rust/horizon/issues/5313) · [#5556](https://github.com/horizon-rust/horizon/issues/5556)
</details>
#### Proximity voice chat as a native mod
@@ -558,6 +564,8 @@ Every past and future Minecraft version prepared for Horizon automatically. When
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
More: [#4758](https://github.com/horizon-rust/horizon/issues/4758)
</details>
#### Controllers: tested on real pads, and in the launcher
@@ -568,6 +576,8 @@ Make controller support solid: tested with real PlayStation and Xbox pads and a
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
More: [#10298](https://github.com/horizon-rust/horizon/issues/10298) · [#8678](https://github.com/horizon-rust/horizon/issues/8678)
</details>
#### The full debug screen
@@ -668,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
</details>
#### A launcher screen readers can read
Screen readers read the launcher's buttons, lists and settings, and every part of it works from the keyboard.
<details><summary>Technical</summary>
Issue [#4537](https://github.com/horizon-rust/horizon/issues/4537): the launcher is GPUI (not Electron), so it exposes nothing to UI Automation, NSAccessibility or AT-SPI yet. Needs an accessibility tree from GPUI (an AccessKit bridge) and a role and label on each widget.
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
@@ -690,6 +710,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
</details>
#### The launcher builds Horizon on other systems
On a system Horizon has no download for, such as FreeBSD, the launcher offers to build the game on your computer, as it already does for native mods.
<details><summary>Technical</summary>
Issue [#4354](https://github.com/horizon-rust/horizon/issues/4354): when no release asset matches the platform, the launcher fetches the tagged source and runs the same pinned-toolchain `cargo build --locked` its native-mod builds use, with a warning about the time it takes. The build is unsigned, so the launcher marks the version as built locally.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Horizon on Windows ARM64
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
@@ -710,6 +742,8 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
More: [#9134](https://github.com/horizon-rust/horizon/issues/9134) · [#8069](https://github.com/horizon-rust/horizon/issues/8069) · [#4153](https://github.com/horizon-rust/horizon/issues/4153)
</details>
#### Horizon on Intel Macs
@@ -752,6 +786,8 @@ Record, replay and edit videos without leaving the game: record in a keypress, r
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
More: [#10025](https://github.com/horizon-rust/horizon/issues/10025)
</details>
#### Game menus in the launcher's style
@@ -842,6 +878,8 @@ Every download Horizon makes is checked against the project's signature, includi
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
More: [#10354](https://github.com/horizon-rust/horizon/issues/10354)
</details>
## Help wanted
@@ -108,6 +108,16 @@ impl<T> CompiledChoices<T> {
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// A variant that needs no random selection and produces exactly one model.
pub fn single_variant(&self, selected: &SelectedModels) -> Option<&T> {
let SelectedModels::Variant(index) = selected else {
return None;
};
match self.variants.get(*index)? {
Choice::Single(value) => Some(value),
Choice::Weighted { .. } => None,
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
+136 -12
View File
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
other swing, landing it while falling: a critical hit, then a sweep from the
ground (give them swords); for PvP benches (crit and sweep particles)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
@@ -165,6 +168,8 @@ struct Options {
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
crits: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
o.no_swing = true;
continue;
}
if arg == "--crits" {
o.crits = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
const SWORD_COOLDOWN_TICKS: u32 = 13;
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
const JUMP_POWER: f64 = 0.42;
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
#[derive(Default)]
struct Crits {
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
jump: Option<f64>,
ground: f64,
/// The next swing is the jumped one.
crit_next: bool,
}
impl Crits {
fn airborne(&self) -> bool {
self.jump.is_some()
}
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
fn tick(
&mut self,
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since: &mut u32,
) -> Option<i32> {
if let Some(v) = self.jump {
pos[1] += v;
if pos[1] <= self.ground {
pos[1] = self.ground;
self.jump = None;
} else {
self.jump = Some((v - 0.08) * 0.98);
}
} else {
self.ground = pos[1];
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
self.jump = Some(JUMP_POWER);
}
}
let falling = self.jump.is_some_and(|v| v < 0.0);
if self.crit_next && !falling {
*since = since.saturating_add(1);
fight_step(pos, target.1);
return None;
}
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
if hit.is_some() {
self.crit_next = !self.crit_next;
}
hit
}
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
fn fight_tick(
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since_attack: &mut u32,
cooldown: u32,
) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
fight_step(pos, at);
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
@@ -1124,6 +1202,7 @@ fn run_bot(
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let mut crits = Crits::default();
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1181,11 +1260,16 @@ fn run_bot(
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
let hit = if o.crits {
crits.tick(&mut pos, near, &mut since_attack)
} else {
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
};
if let Some(victim) = hit {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
) && send(play::Serverbound::Punch(Empty).encode());
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
@@ -1300,9 +1384,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
// Fight bots stand on the ground (but in a --crits jump); the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|| o.ground,
),
})
.encode(),
@@ -1600,7 +1685,9 @@ mod tests {
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.filter_map(|tick| {
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
})
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
@@ -1617,7 +1704,7 @@ mod tests {
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
first = Some(tick);
break;
}
@@ -1627,6 +1714,43 @@ mod tests {
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
let mut last_y = pos[1];
let mut hits = Vec::new();
for tick in 0..120u32 {
let hit = crits.tick(&mut pos, target, &mut since);
if hit.is_some() {
hits.push((tick, crits_state(pos[1], last_y)));
}
last_y = pos[1];
}
assert!(hits.len() >= 6, "{hits:?}");
for (i, w) in hits.windows(2).enumerate() {
assert!(
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
"full strength each time: {hits:?}"
);
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
}
// A crit lands in the air, on the way down; a sweep from the ground.
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
assert!(pos[1] >= 64.0);
}
fn crits_state(y: f64, before: f64) -> &'static str {
if y == 64.0 {
"ground"
} else if y < before {
"falling"
} else {
"rising"
}
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
@@ -0,0 +1,22 @@
//! NBT path parser output and byte cursor for the pinned Java oracle.
use horizon_client_core::nbt_path::NbtPath;
use std::io::{BufRead, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let input = std::io::BufReader::new(std::fs::File::open(args.next().unwrap())?);
let mut output = std::io::BufWriter::new(std::fs::File::create(args.next().unwrap())?);
for line in input.lines() {
let text: String = serde_json::from_str(&line?)?;
match NbtPath::parse_prefix(&text) {
Ok((path, cursor)) => {
write!(output, "OK {cursor} ")?;
for value in path.as_str().as_bytes() {
write!(output, "{value:02x}")?;
}
writeln!(output)?;
}
Err(error) => writeln!(output, "ERR {} {}", error.cursor, error.key)?,
}
}
Ok(())
}
+34 -2
View File
@@ -111,7 +111,15 @@ impl Parser<'_> {
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
// SnbtGrammar: an entry may be followed by a comma before the brace.
Some(b',') => {
self.i += 1;
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
}
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
@@ -140,7 +148,15 @@ impl Parser<'_> {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
// SnbtGrammar: a list or array may end with a comma.
Some(b',') => {
self.i += 1;
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
}
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
@@ -497,6 +513,22 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
/// SnbtGrammar: a compound or list may end with one comma; two in a row
/// stay an error.
#[test]
fn a_component_takes_one_trailing_comma() {
let parse = |s: &str| parse_snbt(s).map(|(v, _)| v);
assert_eq!(
parse("{text:\"a\",color:\"red\",}"),
parse("{text:\"a\",color:\"red\"}")
);
assert_eq!(
parse("[\"a\", {text:\"b\",} ,]"),
parse("[\"a\",{text:\"b\"}]")
);
assert!(parse("{text:\"a\",,}").is_err());
assert!(parse("[\"a\",,]").is_err());
}
#[test]
fn hex_color_follows_text_color_parse_color() {
+49 -3
View File
@@ -726,11 +726,16 @@ fn parse_node(text: &str, i: usize, first: bool) -> Result<(Node, usize), PathEr
Some(b']') => Ok((Node::AllElements, j + 1)),
_ => {
let start = j;
if b.get(j) == Some(&b'-') {
// StringReader.readInt scans all allowed number characters
// before parsing, including malformed signs and decimal points.
while b
.get(j)
.is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'.'))
{
j += 1;
}
while b.get(j).is_some_and(u8::is_ascii_digit) {
j += 1;
if start == j {
return Err(PathError::new("parsing.int.expected", vec![], start));
}
let n: i32 = text[start..j].parse().map_err(|_| {
PathError::new(
@@ -792,6 +797,13 @@ fn read_quoted(text: &str, i: usize) -> Result<(String, usize), PathError> {
let mut escaped = false;
for (o, c) in text[i + 1..].char_indices() {
if escaped {
if c != quote && c != '\\' {
return Err(PathError::new(
"parsing.quote.escape",
vec![c.to_string()],
i + 1 + o,
));
}
out.push(c);
escaped = false;
} else if c == '\\' {
@@ -840,6 +852,40 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoted_escapes_and_index_tokens_match_brigadier() {
let cases = [
(r#""a\q""#, "parsing.quote.escape", vec!["q"], 3),
(r#"'a\x'"#, "parsing.quote.escape", vec!["x"], 3),
("[1.0]", "parsing.int.invalid", vec!["1.0"], 1),
("[.]", "parsing.int.invalid", vec!["."], 1),
("[--1]", "parsing.int.invalid", vec!["--1"], 1),
("[2147483648]", "parsing.int.invalid", vec!["2147483648"], 1),
("[+1]", "parsing.int.expected", vec![], 1),
(r#""é\q""#, "parsing.quote.escape", vec!["q"], 4),
(r#""é"[1.0]"#, "parsing.int.invalid", vec!["1.0"], 5),
];
let mut failures = Vec::new();
for (text, key, args, cursor) in cases {
match NbtPath::parse_prefix(text) {
Err(error) if error.key == key && error.args == args && error.cursor == cursor => {}
other => failures.push(format!("{text:?}: {other:?}")),
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn valid_quoted_escapes_still_select_the_exact_field() {
let root = Tag::compound([("a\"b", Tag::Int(7)), ("a\\b", Tag::Int(8))]);
for (path, value) in [(r#""a\"b""#, 7), (r#""a\\b""#, 8)] {
assert_eq!(
NbtPath::parse(path).unwrap().get(&root).unwrap(),
vec![Tag::Int(value)]
);
}
}
#[test]
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
for text in [
+52 -17
View File
@@ -393,19 +393,38 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
}
}
@@ -592,13 +611,13 @@ pub fn fullscreen_request(
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
.or_else(|| window.primary_monitor());
if !exclusive {
return Fullscreen::Borderless(monitor);
}
let Some(monitor) = monitor else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
@@ -608,7 +627,7 @@ pub fn fullscreen_request(
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
None => Fullscreen::Borderless(Some(monitor)),
}
}
@@ -628,6 +647,22 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
let attributes =
winit::platform::windows::WindowAttributesExtWindows::with_taskbar_icon(attributes, icon);
attributes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -66,6 +66,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
))?;
renderer.resize(320, 180);
assert!(
@@ -368,6 +369,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
+73 -14
View File
@@ -856,27 +856,50 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
}
}
/// The buffer size to request for `open_device`. On Linux the ALSA backend's
/// `BufferSize::Default` means a ~100 ms buffer, so sound effects come out
/// about 100 ms late: request a short fixed buffer (1024 frames) by default.
/// An environment override `HORIZON_AUDIO_BUFFER` can specify `default` or a
/// custom frame count (e.g. 2048) when the default short buffer underruns
/// (e.g. PipeWire min-quantum >= 1024).
#[cfg(feature = "audio")]
fn target_buffer_size(
range: (u32, u32),
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Option<cpal::BufferSize> {
if let Some(val) = var("HORIZON_AUDIO_BUFFER").and_then(|v| v.into_string().ok()) {
if val == "default" {
return Some(cpal::BufferSize::Default);
}
if let Ok(frames) = val.parse::<u32>() {
return Some(cpal::BufferSize::Fixed(frames.clamp(range.0, range.1)));
}
}
#[cfg(target_os = "linux")]
{
Some(cpal::BufferSize::Fixed(1024u32.clamp(range.0, range.1)))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
/// come out about 100 ms late: request a short fixed buffer there. Other
/// platforms keep cpal's default behaviour.
#[cfg(feature = "audio")]
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
#[cfg(target_os = "linux")]
{
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
let target = 1024u32.clamp(range.0, range.1);
let mut sc = config.config();
sc.buffer_size = cpal::BufferSize::Fixed(target);
sc
}
#[cfg(not(target_os = "linux"))]
{
config.config()
let mut sc = config.config();
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
if let Some(bs) = target_buffer_size(range, |name| std::env::var_os(name)) {
sc.buffer_size = bs;
}
sc
}
#[cfg(feature = "audio")]
@@ -1805,6 +1828,42 @@ mod tests {
);
}
#[test]
#[cfg(feature = "audio")]
fn buffer_size_override_and_defaults() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
let range = (256, 4096);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "default")])),
Some(cpal::BufferSize::Default)
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "2048")])),
Some(cpal::BufferSize::Fixed(2048))
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "100")])),
Some(cpal::BufferSize::Fixed(256))
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "10000")])),
Some(cpal::BufferSize::Fixed(4096))
);
#[cfg(target_os = "linux")]
assert_eq!(
target_buffer_size(range, env(&[])),
Some(cpal::BufferSize::Fixed(1024))
);
#[cfg(not(target_os = "linux"))]
assert_eq!(target_buffer_size(range, env(&[])), None);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
@@ -538,6 +538,11 @@ impl BlockEntities {
});
Arc::clone(&self.tickers)
}
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
/// the last [`Self::tickers`]: what a diagnostics mod counts.
pub fn ticking_len(&self) -> usize {
self.live.len()
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
@@ -3407,7 +3407,9 @@ impl BeamPart {
(at(b, self.end), [s.u0, v1]),
] {
out.push(Vertex {
normal: [0; 4],
// The beacon beam render types keep the default back-face culling: seen
// from inside a beam, its walls must not show.
normal: CULL_BACK,
pos: p.to_array(),
uv,
layer: 6,
@@ -4277,6 +4279,23 @@ mod tests {
/// submits the unit cube's two Y faces. Both are wound outward, so of each
/// face only the side looking at the camera draws: the portal shows one
/// surface from above and one from below, never both at once.
/// `RenderPipelines.BEACON_BEAM_OPAQUE` / `_TRANSLUCENT` keep the default
/// back-face culling, so a camera inside a beam sees no wall of it.
#[test]
fn beacon_beam_faces_cull_their_backs() {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
let mut out = Vec::new();
beacon_beam(Vec3::ZERO, &[(0xFFFFFF, 4)], 0.0, 1.0, rect, &mut out);
assert_eq!(out.len(), 32, "solid and glow, four quads each");
assert!(out.iter().all(|v| v.normal == CULL_BACK));
}
#[test]
fn the_end_portal_cube_is_one_sided() {
let mut out = Vec::new();
@@ -0,0 +1,249 @@
//! Every block state's facts and looks by its id, for a client mod drawing blocks or a map off the main thread
//! (`native_block_states`; docs/native-mods.md): what the game knows of each state, what its model draws on each side
//! and the texture behind each of those sprites. A copy, so the game's own tables can change shape under it.
use crate::{
block_sprites::{BlockSprites, Lookup, SpriteRef},
streaming::WorkerContext,
};
use horizon_items::blocks::{BlockFacts, MAP_COLORS, RenderShape};
use horizon_world::{
block_collision::BlockCollisionRegistry, fluid::FluidState, registry::BlockStateId,
};
use std::{collections::HashMap, sync::Arc};
/// What one block state is, as a renderer or a map reads it.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct BlockStateFacts {
/// Its block, without properties (`minecraft:grass_block`).
pub name: Arc<str>,
/// `BlockState.isAir`: air, cave air or void air.
pub air: bool,
/// No model draws it (render shape `INVISIBLE`): air, barriers, light, and water and lava, drawn as fluids.
pub invisible: bool,
/// `BlockState.getFluidState`: the fluid in it, a waterlogged block's and kelp's included; empty for none.
pub fluid: FluidState,
/// Its `waterlogged` property is true.
pub waterlogged: bool,
/// The light it gives, 0 to 15.
pub emission: u8,
/// `BlockState.getShape`, the outline the cursor picks, as block-local boxes
/// `[min_x, min_y, min_z, max_x, max_y, max_z]`: `[0, 0, 0, 1, 1, 1]` for a whole block, none for air. Without
/// the random offset of flowers and grass.
pub outline: Arc<[[f64; 6]]>,
/// `MapColor.col` (`0xRRGGBB`), its colour on a vanilla map; none for `MapColor.NONE`, which maps leave out.
pub map_color: Option<u32>,
/// It can collide at all: false for a state whose collision shape is always empty (air, flowers, water).
pub collides: bool,
}
// A mod builds its tables from these on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<BlockStates>()
};
/// Every block state by its id: its facts, and what its model draws with the resources loaded when it was made.
#[derive(Clone)]
pub struct BlockStates {
states: Arc<[BlockStateFacts]>,
context: Arc<WorkerContext>,
/// Texture names by sprite layer, across every array.
textures: Arc<HashMap<u32, String>>,
split: u32,
}
impl BlockStates {
/// The states of `context`'s registry, with their `facts` and `collision`, and the sprites of `context`'s
/// resources at `split` layers to an array (`Renderer::sprite_split`).
pub fn new(
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
context: Arc<WorkerContext>,
split: u32,
) -> Self {
let registry = context.assets.registry;
let states = facts
.states
.iter()
.enumerate()
.map(|(i, f)| {
let id = BlockStateId(i as u32);
BlockStateFacts {
name: f.name.clone(),
air: facts.is_air(id),
invisible: f.render_shape == RenderShape::Invisible,
fluid: registry
.state(id)
.and_then(|state| FluidState::from_block(state).ok())
.unwrap_or(FluidState::EMPTY),
waterlogged: f.waterlogged,
emission: f.emission,
outline: f.outline.clone(),
map_color: MAP_COLORS
.get(usize::from(f.map_color))
.copied()
.filter(|_| f.map_color != 0),
collides: collision.may_collide(id),
}
})
.collect();
// Vanilla's names go without their namespace, as `native_draw_cube` takes them.
let textures = context
.render
.sprite_layers
.iter()
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
.collect();
Self {
states,
context,
textures: Arc::new(textures),
split: split.max(1),
}
}
/// How many states there are: their ids run from 0 to one less.
pub fn len(&self) -> usize {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
}
/// `state`'s facts; none for an id past the last state.
pub fn get(&self, state: BlockStateId) -> Option<&BlockStateFacts> {
self.states.get(state.0 as usize)
}
/// Every state with its id, in id order.
pub fn iter(&self) -> impl Iterator<Item = (BlockStateId, &BlockStateFacts)> {
self.states
.iter()
.enumerate()
.map(|(i, facts)| (BlockStateId(i as u32), facts))
}
/// What `state` draws on each side, as `native_block_sprites` says for a block by name; none for an id past the
/// last state.
pub fn sprites(&self, state: BlockStateId) -> Option<BlockSprites> {
self.get(state)?;
Some(Lookup::new(&self.context, self.split).state(state))
}
/// The texture `sprite` shows, by the name `native_texture_sprites` takes (`block/grass_block_top`; a mod's with
/// its namespace, `mymod:block/gear`).
pub fn texture(&self, sprite: SpriteRef) -> Option<&str> {
let layer = sprite
.array
.checked_mul(self.split)?
.checked_add(sprite.layer)?;
self.textures.get(&layer).map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
assets::Assets, biome::Biomes, block_sprites::parse_state, light::LightTables,
mesher::MeshTables,
};
use horizon_assets::model::Direction;
use horizon_world::fluid::FluidKind;
use std::{path::PathBuf, sync::OnceLock};
/// Far fewer layers to an array than a GPU takes, so most sprites lie past array 0.
const SMALL_SPLIT: u32 = 64;
/// The game's own states from real prepared resources, loaded once for every test here.
fn states() -> &'static BlockStates {
static STATES: OnceLock<BlockStates> = OnceLock::new();
STATES.get_or_init(|| {
let root = PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root).unwrap());
let render = Arc::new(assets.reload(&root).unwrap());
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let context = Arc::new(WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root).unwrap()),
});
BlockStates::new(&facts, &collision, context, SMALL_SPLIT)
})
}
fn facts(text: &str) -> &'static BlockStateFacts {
let states = states();
let id = parse_state(states.context.assets.registry, text).expect(text);
states.get(id).unwrap()
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_grass_block_is_a_whole_solid_grass_coloured_block() {
let grass = facts("grass_block");
assert_eq!(&*grass.name, "minecraft:grass_block");
assert!(!grass.air && !grass.invisible && !grass.waterlogged);
assert_eq!(grass.fluid.kind(), FluidKind::Empty);
assert_eq!(grass.emission, 0);
assert_eq!(&*grass.outline, [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]);
assert_eq!(grass.map_color, Some(0x7F_B238), "MapColor.GRASS");
assert!(grass.collides);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn air_and_light_are_not_drawn() {
let air = facts("air");
assert!(air.air && air.invisible && !air.collides);
assert!(air.outline.is_empty());
assert_eq!(air.map_color, None);
let light = facts("light[level=15]");
assert!(!light.air && light.invisible);
assert_eq!(light.emission, 15);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_slabs_outline_is_its_half() {
assert_eq!(
&*facts("stone_slab[type=bottom]").outline,
[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]
);
assert_eq!(
&*facts("stone_slab[type=top]").outline,
[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]]
);
let wet = facts("stone_slab[type=bottom,waterlogged=true]");
assert!(wet.waterlogged && wet.collides);
assert_eq!(wet.fluid.kind(), FluidKind::Water);
assert!(wet.fluid.is_source());
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_face_names_its_texture() {
let states = states();
let id = parse_state(states.context.assets.registry, "grass_block").unwrap();
let grass = states.sprites(id).unwrap();
let top = grass.sides[Direction::Up as usize][0].sprite;
assert_eq!(states.texture(top), Some("block/grass_block_top"));
let north = &grass.sides[Direction::North as usize];
assert_eq!(
states.texture(north[0].sprite),
Some("block/grass_block_side")
);
assert_eq!(
states.texture(north[1].sprite),
Some("block/grass_block_side_overlay")
);
}
}
+36 -4
View File
@@ -1193,6 +1193,10 @@ impl SuggestLayout {
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1300,11 +1304,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1323,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1353,6 +1360,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1382,7 +1390,8 @@ impl ChatScreen {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1399,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1420,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1729,6 +1748,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1889,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
+104 -7
View File
@@ -201,7 +201,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +242,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +264,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +371,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +421,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +469,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -403,7 +491,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +561,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +587,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -0,0 +1,143 @@
//! The files of the resources the game loaded, resource packs' over vanilla's, for a client mod reading textures and
//! other resources on any thread (`native_client_resources`; docs/native-mods.md).
use horizon_core::prepared_files;
use std::path::PathBuf;
/// A texture's pixels: `width` by `height` RGBA, rows top first, as its PNG stores them (gamma-encoded, alpha not
/// premultiplied). An animated texture is its frames one under another, as in its file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TexturePixels {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
// A mod reads textures on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<ClientResources>()
};
/// The resources the game loaded, as one tree: vanilla's, with the resource packs turned on (a higher pack over a
/// lower one) and the content mods' files over it. A pack turned on or off makes a new tree: ask for it again then.
/// It reads the files on disk at each call, not a copy: while a reload rebuilds them, a read can miss a file or get
/// the new pack's before `native_block_sprites_version()` changes.
#[derive(Clone, Debug)]
pub struct ClientResources {
root: PathBuf,
}
impl ClientResources {
/// The tree at `root` (the game's `options.resources`).
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// The bytes of the resource at `path` in the tree (`assets/minecraft/textures/entity/cow/cow_temperate.png`).
/// None when there is no such file, or for a path that isn't plain names under `assets/`.
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\');
if !path.starts_with("assets/") || !path.split('/').all(plain) {
return None;
}
prepared_files::read(&self.root.join(path)).ok()
}
/// A texture's pixels by name (`block/stone`, `entity/cow/cow_temperate`, a mod's `mymod:item/gear`): the PNG
/// under `assets/<namespace>/textures/`, decoded. None when there is no such PNG or it doesn't decode.
pub fn texture(&self, name: &str) -> Option<TexturePixels> {
let path = crate::atlas_sources::texture_path(&self.root, name, "textures");
let (width, height, rgba) =
crate::hud::decode_bytes(&prepared_files::read(&path).ok()?).ok()?;
Some(TexturePixels {
width,
height,
rgba,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::{build_overlay, discover};
use std::{fs, path::Path};
const COW: &str = "assets/minecraft/textures/entity/cow/cow_temperate.png";
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn one_pixel_png(rgba: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&rgba)
.unwrap();
out
}
fn write(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn a_resource_pack_overrides_a_texture() {
let base =
std::env::temp_dir().join(format!("horizon-client-resources-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
write(&vanilla.join(COW), &one_pixel_png(RED));
let pack = base.join("packs/Blue cows");
write(
&pack.join("pack.mcmeta"),
br#"{"pack":{"description":"blue cows","pack_format":97}}"#,
);
write(&pack.join(COW), &one_pixel_png(BLUE));
let overlay = base.join("overlay");
build_overlay(&vanilla, &discover(&vanilla, &base.join("packs")), &overlay).unwrap();
let (plain, packed) = (ClientResources::new(vanilla), ClientResources::new(overlay));
assert_eq!(plain.read(COW), Some(one_pixel_png(RED)));
assert_eq!(packed.read(COW), Some(one_pixel_png(BLUE)));
assert_eq!(
packed.texture("entity/cow/cow_temperate"),
Some(TexturePixels {
width: 1,
height: 1,
rgba: BLUE.to_vec(),
})
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn only_plain_paths_under_assets_are_read() {
let base = std::env::temp_dir().join(format!(
"horizon-client-resources-paths-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let root = base.join("root");
write(&root.join(COW), &one_pixel_png(RED));
write(&root.join("horizon-resources.json"), b"{}");
write(&base.join("secret.txt"), b"secret");
let resources = ClientResources::new(root);
assert!(resources.read(COW).is_some());
for refused in [
"horizon-resources.json",
"assets/../horizon-resources.json",
"assets/../../secret.txt",
"assets//minecraft/textures/entity/cow/cow_temperate.png",
"/assets/minecraft/textures/entity/cow/cow_temperate.png",
] {
assert_eq!(resources.read(refused), None, "{refused}");
}
let _ = fs::remove_dir_all(base);
}
}
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
@@ -572,7 +572,7 @@ impl ControllerUi {
&cols,
&lines,
self.scroll.amount,
Page::name,
|page| lang::get(page.name()),
|batch, setting: Setting, y| {
let item = setting.item;
let [rx, ry, rw, rh] = cols.row(y);
+22 -1
View File
@@ -10,6 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// The build's architecture, and the machine's after "on" when it emulates the build.
fn arch_label(build: &str, machine: &str) -> String {
if build == machine {
build.to_owned()
} else {
format!("{build} on {machine}")
}
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
t.lines()
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn an_emulated_build_names_both_architectures() {
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
// On the machine running the tests: native builds name one architecture.
let label = arch_label(std::env::consts::ARCH, machine_arch());
assert!(label.starts_with(std::env::consts::ARCH));
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
@@ -98,14 +98,42 @@ pub struct Source {
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
/// position and level. Overwrites `out`.
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
select_iter(sources.iter().copied(), camera, out);
}
/// Streaming selection with the same stable distance order as [`select`]. Only the best
/// [`MAX`] sources are kept, and each source's distance is evaluated once.
pub fn select_iter(
sources: impl IntoIterator<Item = Source>,
camera: DVec3,
out: &mut Vec<[f32; 4]>,
) {
out.clear();
let key = |s: &Source| s.at.distance_squared(camera);
let mut near: Vec<&Source> = sources
.iter()
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
.collect();
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
out.extend(near.into_iter().take(MAX).map(|s| {
let mut near = [(
0.0_f64,
Source {
at: DVec3::ZERO,
level: 0,
},
); MAX];
let mut len = 0;
for source in sources {
let distance = source.at.distance_squared(camera);
// The original <= test also rejects NaN and infinite distances.
if source.level == 0 || !(0.0..=REACH * REACH).contains(&distance) {
continue;
}
let at = near[..len].partition_point(|(d, _)| d.total_cmp(&distance).is_le());
if at == MAX {
continue;
}
// Insert after equal distances: the first MAX sources in input order win ties.
let end = len.min(MAX - 1);
near.copy_within(at..end, at + 1);
near[at] = (distance, source);
len = (len + 1).min(MAX);
}
out.extend(near[..len].iter().map(|(_, s)| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
}));
@@ -179,6 +207,104 @@ mod tests {
assert!(out.is_empty());
}
#[test]
fn streaming_selection_matches_stable_full_sort() {
let camera = DVec3::new(30_000_000.0, -64.0, -30_000_000.0);
let mut seed = 0x8469_d28b_314a_723fu64;
let mut sources = Vec::new();
for i in 0..2048 {
let offset = std::array::from_fn::<_, 3, _>(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 241) as f64 - 120.0
});
sources.push(Source {
at: camera + DVec3::from(offset),
level: (i % 22) as u8,
});
}
sources.extend([
Source {
at: camera + DVec3::X * 96.0,
level: 14,
},
Source {
at: camera - DVec3::X * 96.0,
level: 15,
},
Source {
at: camera + DVec3::X * 96.0001,
level: 15,
},
Source {
at: DVec3::splat(f64::NAN),
level: 15,
},
Source {
at: DVec3::splat(f64::INFINITY),
level: 15,
},
]);
let reference = |input: &[Source], camera: DVec3| {
let mut selected: Vec<_> = input
.iter()
.filter(|s| s.level > 0 && s.at.distance_squared(camera) <= 96.0 * 96.0)
.collect();
selected.sort_by(|a, b| {
a.at.distance_squared(camera)
.total_cmp(&b.at.distance_squared(camera))
});
selected
.into_iter()
.take(16)
.map(|s| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
})
.collect::<Vec<_>>()
};
let mut out = vec![[999.0; 4]; 20];
for input in [sources.clone(), sources.into_iter().rev().collect()] {
for n in [0, 1, 15, 16, 17, 40, input.len()] {
select_iter(input[..n].iter().copied(), camera, &mut out);
assert_eq!(out, reference(&input[..n], camera));
}
select_iter(input.iter().copied(), DVec3::splat(f64::NAN), &mut out);
assert!(out.is_empty());
}
}
#[test]
fn equal_distances_keep_first_sources_and_reach_is_inclusive() {
// Every source ties: neither a different position nor a different light level
// may win the sixteenth slot just because it was inserted later.
let sources: Vec<_> = (0..40)
.map(|i| Source {
at: if i % 2 == 0 {
DVec3::X * 96.0
} else {
-DVec3::X * 96.0
},
level: (i % 15 + 1) as u8,
})
.collect();
let mut out = Vec::new();
select_iter(sources, DVec3::ZERO, &mut out);
assert_eq!(out.len(), 16);
for (i, light) in out.iter().enumerate() {
assert_eq!(
*light,
[
if i % 2 == 0 { 96.0 } else { -96.0 },
0.0,
0.0,
(i % 15 + 1) as f32
]
);
}
}
#[test]
fn light_falls_off_a_level_per_block() {
let torch = [[0.0, 0.0, 0.0, 14.0]];
+46 -5
View File
@@ -116,8 +116,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +127,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +142,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -494,6 +497,44 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
+21
View File
@@ -413,6 +413,13 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
/// `shouldRenderSky` false while a boss bar makes world fog (the Ender Dragon's).
pub fn sky_pass_skipped(fog_type: FogType, effect_blocks_sky: bool, boss_world_fog: bool) -> bool {
matches!(fog_type, FogType::Lava | FogType::PowderSnow) || effect_blocks_sky || boss_world_fog
}
/// `AtmosphericFogEnvironment.updateRainFogState`.
pub fn update_rain_fog(
state: &mut FogState,
@@ -480,6 +487,20 @@ mod tests {
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
}
#[test]
fn the_dragon_fights_world_fog_leaves_out_the_sky() {
use FogType::*;
// GameRenderer.renderLevel: no sky pass under a createWorldFog boss bar, in clear air
// as in water.
assert!(sky_pass_skipped(Atmospheric, false, true));
assert!(sky_pass_skipped(Water, false, true));
// LevelRenderer.addSkyPass's own conditions.
assert!(sky_pass_skipped(Lava, false, false));
assert!(sky_pass_skipped(PowderSnow, false, false));
assert!(sky_pass_skipped(Atmospheric, true, false));
assert!(!sky_pass_skipped(Atmospheric, false, false));
assert!(!sky_pass_skipped(Water, false, false));
}
#[test]
fn boss_world_fog_closes_in() {
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
let sky = Sky {
+589 -101
View File
@@ -874,7 +874,8 @@ pub struct Frame<'a> {
pub cave_culling: bool,
/// Title screen: only the dedicated panorama cube map and the HUD.
pub title: bool,
/// Lava, powder snow, blindness or darkness blocks the sky pass.
/// Lava, powder snow, blindness, darkness or a boss bar's world fog leaves out the sky pass
/// (`fog::sky_pass_skipped`).
pub sky_blocked: bool,
/// Dimensions without a skybox (the Nether) clear to the fog colour.
pub skybox: bool,
@@ -925,6 +926,10 @@ pub struct Frame<'a> {
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
pub entity_motion: &'a [(u32, u64)],
/// For each mark of `entity_motion`, the GPU-posed vertex count at it (`skin`'s
/// `vertices`): where that entity's posed vertices start. A shader pack's `at_velocity`
/// matches both parts of an entity with last frame's.
pub entity_motion_posed: &'a [u32],
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
/// outlined through walls after the hand.
pub glowing: &'a [Vertex],
@@ -1029,6 +1034,25 @@ pub struct ScreenshotRequest {
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid
/// pass at Retina sizes).
pub struct SolidPipeline {
plain: wgpu::RenderPipeline,
hdr: Option<wgpu::RenderPipeline>,
}
impl SolidPipeline {
/// The pipeline for a frame with HDR output on (`hdr`) or off.
pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline {
match &self.hdr {
Some(lit) if hdr => lit,
_ => &self.plain,
}
}
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -1080,10 +1104,10 @@ pub struct Renderer {
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
pub config: wgpu::SurfaceConfiguration,
solid: wgpu::RenderPipeline,
solid: SolidPipeline,
/// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`).
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
solid_opaque: SolidPipeline,
solid_cutout: SolidPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
@@ -1375,9 +1399,12 @@ pub struct Renderer {
hdr_output: Option<hdr::Output>,
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
/// swapchain through `readback`.
/// 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,
/// 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,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
@@ -1385,10 +1412,14 @@ pub struct Renderer {
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
pack_loading: Option<PackLoading>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// Least time between timed frames (`set_pass_timing_gap`).
pass_timing_gap: std::time::Duration,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -1849,9 +1880,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the
/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself.
/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included,
/// then Vulkan, based on the device losses seen there (#7513). Other
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
/// crash in the driver can't be
@@ -1974,7 +2006,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
features.contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) && format.allowed_usages.contains(
) && draws_frame(format)
}
/// Whether a format with the adapter's `format` features takes every usage and flag the
/// passes take of the frame.
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
format.allowed_usages.contains(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
@@ -1984,8 +2022,14 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
)
}
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// The frame of a renderer made with a shader API 0.2 pack picked under
/// `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md): float, so values over 1
/// survive from the world to the tonemap.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` for a 0.2 pack under the switch
/// (`linear`: `(picked, the device draws it)`); else `WIDE_FRAME` when it is made with HDR on
/// and Smooth Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
@@ -1993,8 +2037,11 @@ fn frame_format(
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool),
) -> wgpu::TextureFormat {
if wide_frame && hdr_offered && device_draws_it {
if linear.0 && linear.1 {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
@@ -2015,7 +2062,8 @@ async fn open_device(
for (order, allow_cpu) in device_search(
api.with_env(),
std::env::consts::OS,
std::env::consts::ARCH,
// The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513).
horizon_telemetry::system::machine_arch(),
wgpu_backend,
) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
@@ -2115,10 +2163,22 @@ async fn open_with_retry(
);
match open_backend(backends, window, offscreen, true, layers, allow_cpu).await {
Ok(opened) => {
let info = opened.adapter.get_info();
if is_broken_haswell_vulkan(&info) {
log::warn!(
"Horizon renderer: skipping {} on Vulkan because Mesa hasvk suffers from \
vertex corruption on Haswell; falling back to next backend",
info.name
);
return Err(format!(
"Mesa hasvk on Haswell ({}) has broken vertex buffers under Vulkan; falling back",
info.name
));
}
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
info.name
);
Ok(opened)
}
@@ -2129,6 +2189,19 @@ async fn open_with_retry(
Ok(opened) => Ok(opened),
}
}
/// Intel Haswell (Gen 7.5) Mesa Vulkan driver (`hasvk`) reports 0.0.0.0 and suffers
/// from severe vertex buffer corruption and polygon tearing (#9912), but its OpenGL driver
/// (`crocus`) is fully conformant and stable. Ivy Bridge (`IVB`) keeps Vulkan (#4059).
pub(crate) fn is_broken_haswell_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x0400..=0x042f // Haswell Desktop / Mobile
| 0x0a00..=0x0a2f // Haswell ULT (Ultrabook)
| 0x0d00..=0x0d2f // Haswell CRW (Crystal Well)
) || ["HSW", "Haswell"].iter().any(|tag| info.name.contains(tag)))
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try; a
/// software adapter is taken only when `allow_cpu`.
@@ -2442,6 +2515,7 @@ impl Renderer {
translucent_batching: bool,
wide_frame: bool,
shader_pack: bool,
linear_pack: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2575,7 +2649,22 @@ impl Renderer {
adapter.get_texture_format_features(WIDE_FRAME),
);
crate::graphics::offer_smooth_gradients(wide_ok);
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
let linear = (
linear_pack,
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
);
if linear == (true, false) {
log::warn!(
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
}
let frame_format = frame_format(
wide_frame,
hdr_output.is_some(),
wide_ok,
config.format,
linear,
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2695,16 +2784,24 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &debug_line_attributes,
})];
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
// The solid terrain's fragment constants without HDR output's lighting, for the
// frames HDR is off (`SolidPipeline`).
let plain_constants = [
sprite_constants[0],
sprite_constants[1],
("HDR_LIGHTING", 0.0),
];
let pipeline_with = |constants: &[(&str, f64)],
layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -2735,7 +2832,7 @@ impl Renderer {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -2748,6 +2845,57 @@ impl Renderer {
cache: None,
})
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
pipeline_with(
&sprite_constants,
layout,
label,
vertex,
fragment,
buffers,
topology,
cull,
depth_write,
depth_test,
blend,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
// output's lighting, and with it where the display offers HDR output.
let solid_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
let build = |constants: &[(&str, f64)]| {
pipeline_with(
constants,
layout,
label,
vertex,
fragment,
buffers,
wgpu::PrimitiveTopology::TriangleList,
Some(wgpu::Face::Back),
true,
true,
None,
)
};
SolidPipeline {
plain: build(&plain_constants),
hdr: hdr_output.is_some().then(|| build(&sprite_constants)),
}
};
let pipeline = |label: &str,
vertex: &str,
fragment: &str,
@@ -2771,41 +2919,29 @@ impl Renderer {
)
};
let tri = wgpu::PrimitiveTopology::TriangleList;
let solid = pipeline(
let solid = solid_in(
&layout,
"Terrain solid",
"vs_terrain",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
// world pass without a shader pack: no cutout first, through a shader that never
// discards, then the rest.
let solid_opaque = pipeline(
let solid_opaque = solid_in(
&layout,
"Terrain solid opaque",
"vs_terrain_opaque",
"fs_opaque",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
let solid_cutout = pipeline(
let solid_cutout = solid_in(
&layout,
"Terrain solid cutout",
"vs_terrain_cutout",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// The water mirror's camera sees the world from the other side of the plane, which
// flips the winding: its solid terrain culls the front.
@@ -4326,6 +4462,7 @@ impl Renderer {
layout, label, vertex, fragment, buffers, tri, cull, true, true, blend,
)
},
|layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]),
)
});
// With flat draws' page groups the per-section solid draws read their vertices from
@@ -4339,40 +4476,24 @@ impl Renderer {
..Default::default()
});
let origins = [None, terrain_buffers[1].clone()];
let pulled = |label: &str, vertex: &str, fragment: &str, cull| {
let solid = |label: &str, vertex: &str, fragment: &str| {
solid_in(&pulled_layout, label, vertex, fragment, &origins)
};
(
solid("Terrain solid", "vs_section", "fs_solid"),
solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"),
solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"),
pipeline_in(
&pulled_layout,
label,
vertex,
fragment,
&origins,
tri,
Some(cull),
true,
true,
None,
)
};
let back = wgpu::Face::Back;
(
pulled("Terrain solid", "vs_section", "fs_solid", back),
pulled(
"Terrain solid opaque",
"vs_section_opaque",
"fs_opaque",
back,
),
pulled(
"Terrain solid cutout",
"vs_section_cutout",
"fs_solid",
back,
),
pulled(
"Terrain solid (mirrored)",
"vs_section",
"fs_solid_mirror",
wgpu::Face::Front,
&origins,
tri,
Some(wgpu::Face::Front),
true,
true,
None,
),
)
}
@@ -4709,10 +4830,13 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
pack_loading: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
pass_timing_gap: std::time::Duration::ZERO,
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -6462,7 +6586,17 @@ impl Renderer {
world,
)
});
let post = match post.transpose() {
self.pack_loading = None;
self.install_shader_pack(dimension, post.transpose())
}
/// Runs the pack `set_shader_pack` or `load_shader_pack` built (`None`: no pack), or none when
/// it can't run, for the dimension `dimension`; the programs that run and their notes.
fn install_shader_pack(
&mut self,
dimension: i32,
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
let post = match post {
Ok(post) => post,
Err(e) => {
self.pack_post = Some((dimension, None));
@@ -6481,8 +6615,138 @@ impl Renderer {
}
Ok(report)
}
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
pub fn load_shader_pack(
&mut self,
dimension: i32,
pack: horizon_shaders::Post,
block_ids: Vec<i32>,
label: String,
started: std::time::Instant,
) {
let device = self.device.clone();
let queue = self.queue.clone();
let adapter = self.adapter.clone();
let format = self.frame_format;
let atlas = self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size()));
let sprite_rects = self
.pack_atlas
.as_ref()
.map_or_else(Vec::new, |a| a.rects_table());
let material_maps = self
.pack_material_atlases
.as_ref()
.map(|a| [a[0].view.clone(), a[1].view.clone()]);
let lightmap = self.lightmap_view.clone();
let gui_size = self
.hud
.atlas_texture
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]);
let celestial = self
.hud
.atlas_texture
.as_ref()
.map(|t| t.create_view(&Default::default()));
let flat = self.pack_terrain_flat;
let table = (self.flat.as_ref())
.filter(|_| flat)
.map(|f| f.draw_layout.clone());
let terrain = self.pack_atlas.is_some();
let clouds = self.cloud_bindings.clone();
let build = move || {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let vertex = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
};
let buffers = [Some(vertex), Some(instances)];
let world = pack_post::World {
atlas,
sprite_rects: &sprite_rects,
material_maps,
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: true,
}),
block_ids: &block_ids,
celestial,
clouds: Some(&clouds),
weather: true,
hand: true,
};
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
};
// A pack is not the game's code: a panic building it turns the pack off (#3525).
let thread = std::thread::Builder::new()
.name("Shader pack".into())
.spawn(move || crate::crash::contain(build));
match thread {
Ok(thread) => {
// The last pack doesn't draw this dimension meanwhile.
self.pack_post = None;
self.pack_loading = Some(PackLoading {
dimension,
thread,
label,
started,
});
}
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
}
}
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
/// `set_shader_pack` would run it. Its dimension, label, start and what `set_shader_pack`
/// returns.
#[allow(clippy::type_complexity)]
pub fn poll_shader_pack(
&mut self,
) -> Option<(
i32,
String,
std::time::Instant,
std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused>,
)> {
if !self.pack_loading.as_ref()?.thread.is_finished() {
return None;
}
let loading = self.pack_loading.take()?;
let built = match loading.thread.join() {
Ok(Ok(built)) => built,
Ok(Err(panic)) => Err(pack_post::Refused {
reason: format!("building it panicked: {panic}"),
notes: Vec::new(),
}),
Err(_) => Err(pack_post::Refused {
reason: "its thread panicked".into(),
notes: Vec::new(),
}),
};
let report = self.install_shader_pack(loading.dimension, built.map(Some));
Some((loading.dimension, loading.label, loading.started, report))
}
/// Whether a `shaders/` pack is still building (`load_shader_pack`).
pub fn shader_pack_loading(&self) -> bool {
self.pack_loading.is_some()
}
/// Drops the running shader pack, of either kind.
pub fn drop_shader_packs(&mut self) {
self.pack_loading = None;
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
@@ -6514,6 +6778,18 @@ impl Renderer {
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
if (linear || self.frame_format == LINEAR_FRAME)
&& linear != (self.frame_format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
{
log::info!(
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader API 0.2 pack picked or dropped since runs on it",
self.frame_format
);
}
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
@@ -6657,6 +6933,15 @@ impl Renderer {
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
std::time::Duration::ZERO
} else {
gap
};
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
@@ -6709,7 +6994,8 @@ impl Renderer {
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
let loading = self.pack_loading.as_ref().map(|l| l.dimension);
loading.or_else(|| self.pack_post.as_ref().map(|p| p.0))
}
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
/// anisotropic; `bit`: `maxAnisotropyBit`). All take LevelRenderer's chunk layer sampler
@@ -7231,6 +7517,7 @@ impl Renderer {
.flatten();
}
if let Some(t) = &mut self.own_timing {
t.gap = self.pass_timing_gap;
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
@@ -8361,6 +8648,7 @@ impl Renderer {
frame.end_portal_sprites[1],
&mut self.pack_entity_rest,
skin.filter(|_| pack_skin),
(frame.entity_motion, frame.entity_motion_posed),
);
entities = &self.pack_entity_rest;
}
@@ -8868,6 +9156,9 @@ impl Renderer {
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
t.gap = self.pass_timing_gap;
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
@@ -9271,6 +9562,8 @@ impl Renderer {
}
}
if let Some(native) = &mut self.native {
let reflection =
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
native
.frame
.set_reflection(&self.device, reflection.as_ref());
@@ -9304,7 +9597,9 @@ impl Renderer {
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
let hdr = self.hdr.is_some();
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
// Two solid passes only where a discard costs the GPU its hidden surface
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
// would double the solid draw calls and vertex work for nothing.
@@ -9529,14 +9824,14 @@ impl Renderer {
let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat {
Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque, 0..phase2_split),
(flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split),
(
&flat.solid_cutout,
flat.solid_cutout.get(hdr),
cutout..flat.phase1_calls,
phase2_split..phase2_calls,
),
],
None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)],
None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)],
};
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls, _) in passes {
@@ -9589,9 +9884,13 @@ impl Renderer {
type Draws = std::ops::Range<u32>;
let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material {
None if split_solid => &[
(&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout),
(
&self.solid_cutout,
self.solid_opaque.get(hdr),
0..opaque,
phase1_draws..phase2_cutout,
),
(
self.solid_cutout.get(hdr),
cutout..phase1_draws,
phase2_cutout..solid_draws,
),
@@ -9933,12 +10232,7 @@ impl Renderer {
depth.size(),
);
if let Some((color, _)) = &scaled {
let color = color.texture();
encoder.copy_texture_to_texture(
color.as_image_copy(),
native.frame.opaque_color.as_image_copy(),
color.size(),
);
native.frame.copy_opaque(&self.device, &mut encoder, color);
}
pass = world_pass_after_culling(
&mut encoder,
@@ -10570,12 +10864,18 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
lifts_world,
self.own_timing.as_mut(),
),
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
@@ -10594,7 +10894,7 @@ impl Renderer {
.filter(|(request, _)| request.size.is_none())
.zip(self.hdr.as_ref())
.and_then(|(_, hdr)| {
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
@@ -11520,6 +11820,21 @@ impl Frustum {
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let v = Vec3::new(
if p.x >= 0.0 { max.x } else { min.x },
if p.y >= 0.0 { max.y } else { min.y },
if p.z >= 0.0 { max.z } else { min.z },
);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
@@ -11726,6 +12041,47 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
use super::*;
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
* crate::camera::view_matrix(yaw, pitch);
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
let view = Frustum {
planes: frustum(matrix),
};
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
for (x, y) in [(-1.0, 1.0), (1.0, -1.0)] {
let jitter = Mat4::from_translation(Vec3::new(
x / size[0] as f32,
y / size[1] as f32,
0.0,
));
let inverse = (jitter * matrix).inverse();
for (x, y) in [(-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)] {
for z in [0.001, 0.5, 0.999] {
let p = inverse * Vec4::new(x, y, z, 1.0);
let p = p.truncate() / p.w;
assert!(view.intersects_depth(
p - Vec3::splat(1.0e-4),
p + Vec3::splat(1.0e-4)
));
}
}
}
}
let point = |p| pose.inverse().transform_point3(p);
for z in [10.0, -600.0] {
let p = point(Vec3::new(0.0, 0.0, z));
assert!(!view.intersects_depth(p - Vec3::splat(0.1), p + Vec3::splat(0.1)));
}
// An oversized model straddling the eye must stay, even with its origin behind.
let p = point(Vec3::ZERO);
assert!(view.intersects_depth(p - Vec3::splat(10.0), p + Vec3::splat(10.0)));
assert!(view.intersects_depth(Vec3::splat(f32::NAN), Vec3::ONE));
}
}
#[test]
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
@@ -12068,20 +12424,39 @@ mod tests {
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
// No 0.2 pack (or a device without the float frame) leaves it as before.
for linear in [(false, false), (false, true), (true, false)] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
Bgra8Unorm
frame_format(true, true, true, Bgra8Unorm, linear),
Rgba16Unorm
);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
Bgra8Unorm
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
Rgba8Unorm
);
}
}
}
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
/// wherever the device draws it, HDR on or off.
#[test]
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::Bgra8UnormSrgb;
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
Rgba8Unorm
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, (true, true)),
LINEAR_FRAME
);
}
}
@@ -12481,6 +12856,77 @@ mod tests {
}
}
#[test]
fn plain_solid_shaders_translate_for_every_backend() {
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
// off on every backend: they must still translate (the other test translates them
// with the constants' defaults).
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let stage = naga::ShaderStage::Fragment;
let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect();
for entry in ["fs_solid", "fs_opaque"] {
let (module, info) = naga::back::pipeline_constants::process_overrides(
&module,
&info,
Some((stage, entry)),
&constants,
)
.unwrap();
let msl = naga::back::msl::write_string(
&module,
&info,
&naga::back::msl::Options {
fake_missing_bindings: true,
..Default::default()
},
&naga::back::msl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
..Default::default()
},
);
// Metal compiles the lighting out from this constant.
assert!(
msl.unwrap()
.0
.contains("constant bool HDR_LIGHTING = false;"),
"{entry}"
);
let mut hlsl = String::new();
naga::back::hlsl::Writer::new(
&mut hlsl,
&Default::default(),
&naga::back::hlsl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
},
)
.write(&module, &info, None)
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let mut glsl = String::new();
naga::back::glsl::Writer::new(
&mut glsl,
&module,
&info,
&naga::back::glsl::Options {
version: naga::back::glsl::Version::Desktop(330),
..Default::default()
},
&naga::back::glsl::PipelineOptions {
shader_stage: stage,
entry_point: entry.to_owned(),
multiview: None,
},
Default::default(),
)
.and_then(|mut w| w.write())
.unwrap_or_else(|e| panic!("{entry}: {e}"));
}
}
#[test]
fn entity_shader_validates_with_its_overlay_constants() {
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
naga::valid::Validator::new(
@@ -13219,6 +13665,36 @@ mod sprite_upload_tests {
assert_eq!(adapter_requested_split(&info, None), None);
}
#[test]
fn broken_haswell_vulkan_detects_haswell_and_leaves_ivy_bridge() {
let mut info = wgpu::AdapterInfo {
name: "Intel(R) HD Graphics 4400 (HSW GT2)".into(),
vendor: 0x8086,
device: 0x0a16,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "Mesa 26.2.5".into(),
driver_info: "Mesa 26.2.5".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_broken_haswell_vulkan(&info));
// Ivy Bridge keeps Vulkan (#4059)
info.name = "Intel(R) HD Graphics 4000 (IVB GT2)".into();
info.device = 0x0166;
assert!(!is_broken_haswell_vulkan(&info));
// Haswell on OpenGL is stable (crocus)
info.name = "Intel(R) HD Graphics 4400 (HSW GT2)".into();
info.device = 0x0a16;
info.backend = wgpu::Backend::Gl;
assert!(!is_broken_haswell_vulkan(&info));
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
@@ -13733,3 +14209,15 @@ mod shadow_caster_tests {
assert!(!kept(50.0, 0.0, 0.0, [-2.0, 3.0, 0.0]));
}
}
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
/// is for, the thread (a panic in it contained), and what the game named it with and when.
struct PackLoading {
dimension: i32,
#[allow(clippy::type_complexity)]
thread: std::thread::JoinHandle<
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
>,
label: String,
started: std::time::Instant,
}
@@ -1040,7 +1040,14 @@ mod tests {
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let pipeline = &pipeline;
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
super::super::SolidPipeline {
plain: pipeline(layout, label, "", "", None, None, true),
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -530,6 +530,7 @@ impl Scene {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
+10 -16
View File
@@ -17,6 +17,7 @@
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
@@ -86,12 +87,12 @@ pub struct Call {
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
pub solid: SolidPipeline,
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
pub solid_opaque: SolidPipeline,
pub solid_cutout: SolidPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -357,7 +358,8 @@ fn plan(
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -372,6 +374,7 @@ impl Flat {
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
@@ -432,32 +435,23 @@ impl Flat {
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
solid: solid(
&layout,
"Terrain solid (flat)",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
solid_opaque: solid(
&layout,
"Terrain solid opaque (flat)",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
solid_cutout: solid(
&layout,
"Terrain solid cutout (flat)",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
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;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -7,11 +7,209 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
Api::V0_2 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
}
}
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let entries = (None, "horizon_vs", "horizon_fs");
Self(fullscreen_pipeline(
device,
"Shader API 0.2 decode",
&module,
entries,
INTERMEDIATE,
&[],
))
}
/// Draws `from` decoded onto `to`.
pub fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
from: &wgpu::TextureView,
to: &wgpu::TextureView,
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader API 0.2 decode"),
layout: &self.0.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(from),
}],
});
fullscreen_pass(
encoder,
"Shader API 0.2 decode",
to,
&self.0,
(0, &group),
None,
);
}
}
/// A full-screen pipeline `label` of `module`'s entries `(vertex, fragment)`, unblended.
fn fullscreen_pipeline(
device: &wgpu::Device,
label: &str,
module: &wgpu::ShaderModule,
(layout, vertex, fragment): (Option<&wgpu::PipelineLayout>, &str, &str),
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let options = wgpu::PipelineCompilationOptions {
constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout,
vertex: wgpu::VertexState {
module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// One full-screen triangle of `pipeline` onto `view`, cleared first.
fn fullscreen_pass(
encoder: &mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
(index, group): (u32, &wgpu::BindGroup),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: timing.and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(index, group, &[]);
pass.draw(0..3, 0..1);
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
)
}
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
/// A colour target of `INTERMEDIATE` and `size`, drawn and read.
fn intermediate(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
}
/// A 0.2 pack's display (docs/api/shaders-reference-0.2.md): the world decoded into
/// `scene_color` (the first pass's `previous`), the chain ending in `picture`, and the tonemap
/// stage drawing that onto the screen.
struct Display {
decoder: Decoder,
/// The pack's `[tonemap]` label, `None` for Horizon's.
label: Option<String>,
tonemap: wgpu::RenderPipeline,
/// `scene_color` at the world's size; `picture` at the window's (none without a chain:
/// the tonemap reads `scene_color`).
scene: Option<([u32; 2], wgpu::TextureView)>,
picture: Option<([u32; 2], wgpu::TextureView)>,
}
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
@@ -98,6 +296,8 @@ pub struct NativePost {
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
/// Under 0.2: the decode, the picture and the tonemap.
display: Option<Display>,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
@@ -145,6 +345,11 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -179,7 +384,10 @@ fn copy_target(
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
// RENDER_ATTACHMENT: 0.2 draws `opaque_color` decoded rather than copying it.
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
@@ -225,6 +433,7 @@ impl NativeFrame {
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
api: Api,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -290,7 +499,7 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow, api);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
@@ -347,6 +556,9 @@ impl NativeFrame {
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -460,6 +672,7 @@ impl NativeFrame {
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) if self.decoder.is_some() => ([c.width(), c.height()], INTERMEDIATE),
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
@@ -478,6 +691,46 @@ impl NativeFrame {
self.regroup(device);
}
/// Fills `opaque_color` from the world's colour `color`: a copy, under 0.2 decoded.
pub fn copy_opaque(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
) {
match &self.decoder {
Some(d) => {
let to = self.opaque_color.create_view(&Default::default());
d.run(device, encoder, color, &to);
}
None => encoder.copy_texture_to_texture(
color.texture().as_image_copy(),
self.opaque_color.as_image_copy(),
color.texture().size(),
),
}
}
/// The water mirror `view` as the material reads it: under 0.2 decoded into a copy.
pub fn linear_mirror(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
view: wgpu::TextureView,
) -> wgpu::TextureView {
let Some(decoder) = &self.decoder else {
return view;
};
let size = [view.texture().width(), view.texture().height()];
let fits = |m: &wgpu::TextureView| [m.texture().width(), m.texture().height()] == size;
if !self.mirror.as_ref().is_some_and(fits) {
self.mirror = Some(intermediate(device, "Water mirror, linear", size));
}
let mirror = self.mirror.clone().unwrap();
decoder.run(device, encoder, &view, &mirror);
mirror
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
@@ -512,27 +765,41 @@ impl NativeFrame {
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let inputs = self.next_inputs(frame, size);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
/// This frame's inputs, moving the eased values and the frame count on.
fn next_inputs(&mut self, frame: &Frame, size: [u32; 2]) -> FrameInputs {
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
// eased values sit at their targets.
let still = frame.pack.still_ticks;
let seconds = still.map_or_else(
|| self.start.elapsed().as_secs_f32() % 3600.0,
|ticks| (ticks as f32 / 20.0) % 3600.0,
);
// A scripted clock (`PackInputs::frame_timing`) comes first, and eased values
// move by its delta.
let now = std::time::Instant::now();
let since = match still {
Some(_) => f32::INFINITY,
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
let timing = frame.pack.frame_timing;
// Seconds, frame index, and the seconds eased values move by when not the
// measured ones.
let (seconds, index, step) = match (timing, frame.pack.still_ticks) {
(Some(t), _) => (t.elapsed, t.counter, Some(t.delta)),
(None, Some(ticks)) => (
(ticks as f32 / 20.0) % 3600.0,
ticks as u32,
Some(f32::INFINITY),
),
(None, None) => (
self.start.elapsed().as_secs_f32() % 3600.0,
self.frames,
None,
),
};
let since = step.unwrap_or_else(|| self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()));
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(
frame,
size,
seconds,
still.map_or(self.frames, |ticks| ticks as u32),
self.shadow_camera.as_ref(),
);
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
inputs.wetness = self.wetness.0;
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
}
if !self.reflects {
inputs.has_reflection = 0;
}
@@ -542,8 +809,11 @@ impl NativeFrame {
inputs.previous_view_projection = view_proj;
// `write` skips title frames, so a gap means a pause or another world: the frame
// before says nothing about this one, and the eased values start over.
// A scripted clock's frames follow each other however long one takes to draw.
match self.last {
Some((previous, camera, time)) if (now - time).as_secs_f32() < MAX_FRAME_GAP => {
Some((previous, camera, time))
if timing.is_some() || (now - time).as_secs_f32() < MAX_FRAME_GAP =>
{
inputs.previous_view_projection = previous;
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
@@ -562,12 +832,11 @@ impl NativeFrame {
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = match still {
Some(_) => {
inputs.delta_time = 0.05;
f32::INFINITY
}
None => inputs.delta_time,
let dt = step.unwrap_or(inputs.delta_time);
inputs.delta_time = match (timing, step) {
(Some(t), _) => t.delta,
(None, Some(_)) => 0.05,
(None, None) => inputs.delta_time,
};
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
@@ -577,7 +846,7 @@ impl NativeFrame {
inputs.in_water = water;
inputs.in_lava = lava;
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
inputs
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
@@ -672,7 +941,8 @@ fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
impl NativePost {
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
/// `constants`, drawing onto a screen of `format`; under 0.2 the last onto `picture`, and
/// `tonemap` (else Horizon's) onto the screen. Errors name the pass wgpu refused.
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
@@ -680,6 +950,7 @@ impl NativePost {
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
tonemap: Option<&CheckedStage>,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
@@ -752,7 +1023,7 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if plan[i].writes == Writes::Screen {
format: if plan[i].writes == Writes::Screen && frame.decoder.is_none() {
format
} else {
INTERMEDIATE
@@ -769,6 +1040,41 @@ impl NativePost {
}
passes.push((stage.label, pipeline));
}
let display = match &frame.decoder {
Some(decoder) => {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries();
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
let present = present_constants(&module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Tonemap"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let entries = (
Some(&pipeline_layout),
"horizon_vs_tonemap",
"horizon_fs_tonemap",
);
let pipeline =
fullscreen_pipeline(device, "Tonemap", &module, entries, format, &present);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{file}: {e}"));
}
Some(Display {
decoder: decoder.clone(),
label: tonemap.map(|s| s.label.clone()),
tonemap: pipeline,
scene: None,
picture: None,
})
}
None => None,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
@@ -799,12 +1105,18 @@ impl NativePost {
named: None,
plan,
format,
display,
})
}
/// The passes' labels, in order.
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
self.passes.iter().map(|(l, _)| l.as_str()).collect()
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
self.passes
.iter()
.map(|(l, _)| l.as_str())
.chain(tonemap)
.collect()
}
fn prepare(
@@ -812,8 +1124,26 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
window: [u32; 2],
history_reset: bool,
) {
if let Some(d) = &mut self.display {
if d.scene.as_ref().is_none_or(|s| s.0 != size) {
d.scene = Some((
size,
intermediate(device, "Shader API 0.2 scene_color", size),
));
self.groups = None;
}
let chain = !self.passes.is_empty();
if chain && d.picture.as_ref().is_none_or(|p| p.0 != window) {
d.picture = Some((
window,
intermediate(device, "Shader API 0.2 picture", window),
));
self.groups = None;
}
}
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
@@ -901,85 +1231,98 @@ impl NativePost {
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
// Under 0.2 the passes read the world decoded.
let scene = self
.display
.as_ref()
.and_then(|d| d.scene.as_ref())
.map_or(world.0, |s| &s.1);
let group = |previous: &wgpu::TextureView, writes: Option<Writes>| {
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if writes == Some(Writes::Target(j)) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
};
let mut groups: Vec<_> = (0..self.passes.len())
.map(|i| {
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
_ => scene,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
group(previous, Some(self.plan[i].writes))
})
.collect();
// The tonemap's, last: the chain's picture, else the decoded world.
if let Some(d) = &self.display {
groups.push(group(d.picture.as_ref().map_or(scene, |p| &p.1), None));
}
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
@@ -999,18 +1342,34 @@ impl NativePost {
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size, history_reset);
let window = [screen.texture().width(), screen.texture().height()];
self.prepare(device, world, size, window, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, _, groups)) = &self.groups else {
return;
};
if let Some(Display {
decoder,
scene: Some((_, scene)),
..
}) = &self.display
{
decoder.run(device, encoder, world.0, scene);
}
let last = match &self.display {
Some(Display {
picture: Some((_, picture)),
..
}) => picture,
_ => screen,
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
_ => last,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -1031,9 +1390,13 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
if let (Some(d), Some(group)) = (&self.display, groups.last()) {
let label = d.label.as_deref().unwrap_or("Tonemap");
fullscreen_pass(encoder, label, screen, &d.tonemap, (GROUP, group), timing);
}
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
let before_last = &self.plan[..self.plan.len().saturating_sub(1)];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
@@ -1068,6 +1431,11 @@ fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::T
})
}
/// Vanilla's gamma colour `c` in linear light (0.2's decode; negatives and NaN made 0).
fn decoded(c: [f32; 3]) -> [f32; 3] {
c.map(|v| if v > 0.0 { v.powf(2.2) } else { 0.0 })
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -1320,6 +1688,55 @@ mod tests {
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
}
/// A scripted clock comes before STILL's ticks, eased values and wetness move by its
/// delta, and a long wait between its frames is not a gap that starts them over.
#[test]
fn a_scripted_clock_drives_time_frame_index_and_eased_values() {
use crate::gfx::pack_uniforms::FrameTiming;
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
frame.pack.rain = 1.0;
frame.pack.frame_timing = Some(FrameTiming::nth(1, 2000.0));
let first = native.next_inputs(&frame, [4, 4]);
assert_eq!(
(first.time, first.frame_index, first.delta_time),
(0.0, 1, 0.0)
);
// Wetness starts dry, as on a fresh pack; frame 1's delta of 0 leaves it there.
assert_eq!((first.eye_light_smooth[1], first.wetness), (1.0, 0.0));
assert!(native.take_history_reset());
native.last.as_mut().unwrap().2 -= std::time::Duration::from_secs(5);
frame.pack.eye_light = [0, 0];
frame.pack.frame_timing = Some(FrameTiming::nth(2, 2000.0));
let second = native.next_inputs(&frame, [4, 4]);
assert_eq!(
(second.time, second.frame_index, second.delta_time),
(2.0, 2, 2.0)
);
// Two seconds: the eye light's 2 s half-life halves it, and wetness rises
// by 1 - 0.5^(2/30) (a 30 s half-life while it rains harder).
assert_eq!(second.eye_light_smooth[1], 0.5);
assert!(
(second.wetness - 0.045_158_4).abs() < 1e-6,
"{}",
second.wetness
);
assert!(!native.take_history_reset());
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -1364,7 +1781,14 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -1377,6 +1801,7 @@ mod tests {
&[("gain".into(), 1.0)],
format,
&frame,
None,
)
.unwrap();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
@@ -1484,7 +1909,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1492,6 +1924,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1596,7 +2029,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1604,6 +2044,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1875,4 +2316,126 @@ file = \"b.wgsl\"
);
}
}
/// Shader API 0.2: the world decoded, through a chain that passes it on or none, and
/// Horizon's tonemap or the pack's identity one, comes back encoded as it went in (within
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
let post = "[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n";
let curve = "[tonemap]\nfile = \"t.wgsl\"\n";
// A pack needs a stage: the first has only a sky, and no chain or tonemap.
let sky = "[sky]\nfile = \"s.wgsl\"\n";
for extra in [
sky.into(),
post.into(),
curve.into(),
format!("{post}{curve}"),
] {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n{extra}"
);
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
let identity = "fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
let mut files = vec![(MANIFEST, manifest.as_str())];
if extra.contains("[[post]]") {
files.extend([("a.wgsl", pass), ("b.wgsl", pass)]);
}
if extra.contains("[tonemap]") {
files.push(("t.wgsl", identity));
}
if extra.contains("[sky]") {
files.push((
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
));
}
let files = files
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let (passes, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
.partition(|s| s.kind == StageKind::Post);
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
headroom: 1.0,
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let mut post = NativePost::new(
&device,
passes,
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.labels().last() == Some(&"t.wgsl"), tonemap.is_some());
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut encoder = device.create_command_encoder(&Default::default());
let clear = wgpu::Color {
r: 0.8,
g: 0.4,
b: 0.2,
a: 0.5,
};
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &screen, [4, 4]);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
let want = [204, 102, 51, 255];
assert!(
pixel
.iter()
.zip(want)
.all(|(&got, want)| got.abs_diff(want) <= 1),
"{extra}: {pixel:?}"
);
}
}
}
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -32,13 +32,28 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// 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.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
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\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
vec4 hz_tangent; vec2 hz_mid;\n"
.to_owned();
let words = std::mem::size_of::<Vertex>() / 4;
// `at_velocity`: the vertex's offset from last frame in view space, this frame's view
// position less last frame's. Last frame's vertices stay at binding 2, relative to last
// frame's camera, and binding 3 gives each vertex its index there (none: no velocity).
declarations.push_str(&format!(
"layout(set = 1, binding = 2) readonly buffer HzEntityPrevious {{ uint hz_entity_previous[]; }};\n\
layout(set = 1, binding = 3) readonly buffer HzEntityPreviousIndex {{ uint hz_previous_index[]; }};\n\
uint hz_vi;\n\
vec3 hz_velocity(mat4 view, mat4 previous_view) {{\n \
uint p = hz_previous_index[hz_vi];\n \
if (p == 0xffffffffu) return vec3(0.0);\n \
uint w = p * {words}u;\n \
vec3 then = uintBitsToFloat(uvec3(hz_entity_previous[w], hz_entity_previous[w + 1u], hz_entity_previous[w + 2u]));\n \
return (view * vec4(hz_v_pos, 1.0)).xyz - (previous_view * vec4(then, 1.0)).xyz;\n\
}}\n"
));
let corner = |k: usize, field: &str| {
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
match field {
@@ -47,7 +62,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
};
let body = format!(
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
" hz_vi = uint(gl_VertexIndex);\n \
uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
vec3 hz_e1 = {p1} - {p0};\n \
vec3 hz_e2 = {p2} - {p0};\n \
vec2 hz_t0 = {t0};\n \
@@ -87,6 +103,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
(
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -229,7 +249,15 @@ impl Casters {
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
pub(super) struct PackEntities {
vertices: wgpu::Buffer,
/// Set 1: the vertices again, for the quads' corners.
/// Last frame's `vertices` (the two swap each frame), for `at_velocity`.
previous: wgpu::Buffer,
/// For each of `vertices`, its index in `previous` (`NO_PREVIOUS`: none).
previous_index: wgpu::Buffer,
/// This frame's entities (`Frame::entity_motion`) and last frame's.
motion: Motion,
previous_motion: Motion,
index_scratch: Vec<u32>,
/// Set 1: the vertices again, for the quads' corners; last frame's and the indices into them.
pub table: wgpu::BindGroupLayout,
pub table_group: wgpu::BindGroup,
slots: wgpu::Buffer,
@@ -248,23 +276,32 @@ impl PackEntities {
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
let entry = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack entity vertices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
entries: &[entry(0), entry(2), entry(3)],
});
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
let vertices = Self::vertex_buffer(device, 4096);
let previous = Self::vertex_buffer(device, 4096);
let previous_index = Self::index_buffer(device, 4096);
let table_group = Self::table_group(device, &table, &vertices, &previous, &previous_index);
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
Self {
vertices,
previous,
previous_index,
motion: Motion::default(),
previous_motion: Motion::default(),
index_scratch: Vec::new(),
table,
table_group,
slots,
@@ -278,28 +315,51 @@ impl PackEntities {
}
}
fn vertex_buffer(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
fn vertex_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity vertices"),
size: vertices * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
})
}
fn index_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity previous indices"),
size: vertices * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn table_group(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: &wgpu::Buffer,
previous: &wgpu::Buffer,
index: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity vertices"),
layout: table,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, group)
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: vertices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: previous.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: index.as_entire_binding(),
},
],
})
}
fn slot_buffer(
@@ -359,11 +419,17 @@ impl PackEntities {
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
// Last frame's vertices stay for `at_velocity`; this frame's go to the other buffer.
std::mem::swap(&mut self.vertices, &mut self.previous);
std::mem::swap(&mut self.motion, &mut self.previous_motion);
self.motion.clear();
let mut copied = Vec::new();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
@@ -389,6 +455,7 @@ impl PackEntities {
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
@@ -444,6 +511,10 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
@@ -468,6 +539,14 @@ impl PackEntities {
if self.draws.is_empty() && posed == 0 {
return;
}
let total = posed + self.scratch.len() as u32;
let posed_marks = if skin.is_some() { motion.1 } else { &[] };
self.motion
.collect(motion.0, posed_marks, entities.len() as u32, posed, &copied);
self.index_scratch.clear();
self.index_scratch.resize(total as usize, NO_PREVIOUS);
self.motion
.link(&self.previous_motion, &mut self.index_scratch);
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
@@ -476,9 +555,23 @@ impl PackEntities {
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
(self.vertices, self.table_group) =
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
self.vertices = Self::vertex_buffer(device, need_vertices.next_power_of_two());
}
if need_vertices * 4 > self.previous_index.size() {
self.previous_index = Self::index_buffer(device, need_vertices.next_power_of_two());
}
queue.write_buffer(
&self.previous_index,
0,
bytemuck::cast_slice(&self.index_scratch),
);
self.table_group = Self::table_group(
device,
&self.table,
&self.vertices,
&self.previous,
&self.previous_index,
);
if need_slots > have_slots {
(self.slots, self.group) =
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
@@ -670,6 +763,87 @@ fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
}
/// The kind of program that draws `span`'s run, when a pack may.
/// A vertex with no copy last frame: `at_velocity` reads zero.
const NO_PREVIOUS: u32 = u32::MAX;
/// Where each entity's vertices are in a frame's pack buffer, to match them with last frame's
/// (`at_velocity`): the indices of each one's GPU-posed vertices, then of its CPU ones.
#[derive(Default)]
struct Motion {
/// Entity id (`Frame::entity_motion`) → (first index in `indices`, posed count, CPU count).
entities: std::collections::HashMap<u64, (u32, u32, u32)>,
indices: Vec<u32>,
}
impl Motion {
fn clear(&mut self) {
self.entities.clear();
self.indices.clear();
}
/// Records each marked entity (`marks`: first CPU vertex and id, each run up to the next;
/// `posed_marks`: the posed count at each mark, empty without posing). A CPU vertex is in
/// the pack buffer where `copied` (first vertex in `Frame::entities`, first index in the
/// buffer, count, in order) has it, else `NO_PREVIOUS`.
fn collect(
&mut self,
marks: &[(u32, u64)],
posed_marks: &[u32],
cpu_total: u32,
posed_total: u32,
copied: &[(u32, u32, u32)],
) {
let at = |e: u32| {
let i = copied.partition_point(|c| c.0 <= e);
match i.checked_sub(1).map(|i| copied[i]) {
Some((first, index, n)) if e < first + n => index + (e - first),
_ => NO_PREVIOUS,
}
};
for (i, &(cpu, id)) in marks.iter().enumerate() {
if id == 0 {
continue;
}
let cpu_end = marks.get(i + 1).map_or(cpu_total, |m| m.0).max(cpu);
let posed = posed_marks.get(i).copied().unwrap_or(0).min(posed_total);
let posed_end = posed_marks
.get(i + 1)
.copied()
.unwrap_or(posed_total)
.clamp(posed, posed_total);
let first = self.indices.len() as u32;
self.indices.extend(posed..posed_end);
self.indices.extend((cpu..cpu_end).map(at));
self.entities
.insert(id, (first, posed_end - posed, cpu_end - cpu));
}
}
/// For each entity drawn last frame with the same posed and CPU counts, each vertex's index
/// last frame, written into `out` (indexed by this frame's buffer).
fn link(&self, previous: &Motion, out: &mut [u32]) {
for (id, &(first, posed, cpu)) in &self.entities {
let Some(&(then, p, c)) = previous.entities.get(id) else {
continue;
};
if (p, c) != (posed, cpu) {
continue;
}
let n = (posed + cpu) as usize;
let now = &self.indices[first as usize..][..n];
let before = &previous.indices[then as usize..][..n];
for (&now, &before) in now.iter().zip(before) {
if now != NO_PREVIOUS
&& before != NO_PREVIOUS
&& let Some(slot) = out.get_mut(now as usize)
{
*slot = before;
}
}
}
}
}
pub fn program_of(span: &EntitySpan) -> Option<usize> {
Some(if span.stage == render_stage("ENTITIES") {
if span.translucent {
@@ -1079,6 +1253,7 @@ mod gpu_span_tests {
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
(geometry.draws.iter())
@@ -1160,6 +1335,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
Some(&skin),
(&[], &[]),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
@@ -1203,6 +1379,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
None,
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert_eq!(
@@ -1230,8 +1407,125 @@ mod gpu_span_tests {
Casters::default(),
&mut rest,
Some(&skin),
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
#[cfg(test)]
mod velocity_tests {
use super::{Motion, NO_PREVIOUS};
/// Two mobs (ids 7 and 9) and a run of unmarked geometry (id 0); `copied` places CPU
/// vertices `first..first + n` at buffer index `at`.
fn frame(order: [(u64, u32); 2], copied: &[(u32, u32, u32)]) -> Motion {
let mut marks = Vec::new();
let mut cpu = 0;
for (id, n) in order {
marks.push((cpu, id));
cpu += n;
}
marks.push((cpu, 0));
let mut m = Motion::default();
m.collect(&marks, &[], cpu + 2, 0, copied);
m
}
/// The same mob's vertices find themselves however the frame's order and the buffer's
/// layout moved between frames; geometry of no entity, and vertices not in the buffer,
/// find nothing.
#[test]
fn each_vertex_finds_its_copy_from_last_frame() {
// Last frame: mob 7 (4 vertices) then mob 9 (2), copied whole from buffer index 10.
let before = frame([(7, 4), (9, 2)], &[(0, 10, 8)]);
// This frame: mob 9 first, then mob 7, at buffer index 0; 9's last vertex not drawn.
let now = frame([(9, 2), (7, 4)], &[(0, 0, 1), (2, 1, 6)]);
let mut out = vec![NO_PREVIOUS; 8];
now.link(&before, &mut out);
// 9's first vertex (buffer 0) was 10 + 4; 7's four (buffer 1..5) were 10..14.
assert_eq!(
out,
[14, 10, 11, 12, 13, NO_PREVIOUS, NO_PREVIOUS, NO_PREVIOUS]
);
}
/// A mob whose vertex count changed (armor put on, an item picked up) gets no velocity
/// this frame rather than the wrong vertices'.
#[test]
fn a_changed_model_gets_none() {
let before = frame([(7, 4), (9, 2)], &[(0, 0, 8)]);
let now = frame([(7, 6), (9, 2)], &[(0, 0, 10)]);
let mut out = vec![NO_PREVIOUS; 10];
now.link(&before, &mut out);
assert!(out[..6].iter().all(|&i| i == NO_PREVIOUS));
assert_eq!(out[6..8], [4, 5]);
}
/// GPU-posed vertices come first in the buffer: each mob's posed part is matched by its
/// own posed count, then its CPU part.
#[test]
fn posed_and_cpu_parts_match_separately() {
let marks = [(0, 7), (2, 9), (2, 0)];
let mut before = Motion::default();
// Mob 7: 3 posed (0..3) and 2 CPU; mob 9: 1 posed (3), no CPU.
before.collect(&marks, &[0, 3, 4], 2, 4, &[(0, 4, 2)]);
let mut now = Motion::default();
// Mob 9 posed first this frame (0), then mob 7 (1..4).
now.collect(&[(0, 9), (0, 7), (2, 0)], &[0, 1, 4], 2, 4, &[(0, 4, 2)]);
let mut out = vec![NO_PREVIOUS; 6];
now.link(&before, &mut out);
assert_eq!(out, [3, 0, 1, 2, 4, 5]);
}
}
#[cfg(test)]
mod velocity_translate_tests {
/// A pack that reads `at_velocity` translates with the entity inputs (which read the
/// previous view and last frame's vertices) and with the hand's (which read zero).
#[test]
fn a_pack_reading_at_velocity_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-at-velocity-{}", 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 vec3 at_velocity;\nvarying vec3 v;\n\
void main() {\n gl_Position = ftransform();\n v = at_velocity;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying vec3 v;\nvoid main() {\n gl_FragData[0] = vec4(v, 1.0);\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 p = horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
let previous = p
.interface
.uniforms
.iter()
.any(|u| u.name == "gbufferPreviousModelView");
assert_eq!(
previous,
who == "entities",
"{who}: previous view in the block"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+119 -11
View File
@@ -19,6 +19,13 @@ const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
for (name, value) in &mut inputs.attributes {
if name == "at_velocity" {
*value = "vec3(0.0)".to_owned();
}
}
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
@@ -54,8 +61,12 @@ pub struct PackHand {
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
/// This frame's runs: first quad, quads, and the dynamic offset of their slot. The opaque
/// quads come first in the buffer, then the translucent ones (`translucent_runs`), which a
/// pack's `gbuffers_hand_water` draws after deferred.
pub runs: Vec<(u32, u32, u32)>,
pub translucent_runs: Vec<(u32, u32, u32)>,
order: Vec<Vertex>,
}
impl PackHand {
@@ -120,6 +131,8 @@ impl PackHand {
slot_group,
stride,
runs: Vec::new(),
translucent_runs: Vec::new(),
order: Vec::new(),
}
}
@@ -166,17 +179,25 @@ impl PackHand {
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
self.translucent_runs.clear();
if self.quads == 0 {
return;
}
let split = split(hand, items);
self.order = split.order;
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
for (i, &(first, quads, id, translucent)) in split.runs.iter().enumerate() {
let slot = (i as u64).min(SLOTS - 1);
if slot == i as u64 {
contents.extend_from_slice(&draw_slot(id, translucent));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
let run = (first, quads, (slot * self.stride) as u32);
if translucent {
self.translucent_runs.push(run);
} else {
self.runs.push(run);
}
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
@@ -185,7 +206,7 @@ impl PackHand {
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
self.indices = super::pack_entities::quad_indices(device, self.capacity);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.order));
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
}
@@ -198,11 +219,62 @@ impl PackHand {
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
/// The hand's quads reordered, opaque first and translucent after (each in their order), and
/// their runs: first quad, quads, `currentRenderedItemId`, translucent.
struct Split {
order: Vec<Vertex>,
runs: Vec<(u32, u32, i32, bool)>,
}
fn split(hand: &[Vertex], items: &[(u32, i32)]) -> Split {
let quads = (hand.len() / 4) as u32;
let mut ids = vec![0; quads as usize];
for (start, n, id) in runs(quads, items) {
ids[start as usize..(start + n) as usize].fill(id);
}
let kind = |q: usize| translucent_quad(&hand[q * 4..q * 4 + 4]);
let opaque: Vec<usize> = (0..quads as usize).filter(|&q| !kind(q)).collect();
let clear: Vec<usize> = (0..quads as usize).filter(|&q| kind(q)).collect();
let mut out = Split {
order: Vec::with_capacity(hand.len()),
runs: Vec::new(),
};
for &q in opaque.iter().chain(&clear) {
out.order.extend_from_slice(&hand[q * 4..q * 4 + 4]);
}
for (translucent, list, base) in [(false, &opaque, 0), (true, &clear, opaque.len())] {
let mut i = 0;
while i < list.len() {
let id = ids[list[i]];
let mut n = 1;
while i + n < list.len() && ids[list[i + n]] == id {
n += 1;
}
out.runs
.push(((base + i) as u32, n as u32, id, translucent));
i += n;
}
}
out
}
/// A held item's quad drawn from a translucent sheet (`item_render`: its overlay layer).
fn translucent_quad(quad: &[Vertex]) -> bool {
matches!(
quad[0].layer & 0xffff,
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
)
}
/// `HzDraw` of a hand run: `renderStage` the solid or translucent hand's, `blockEntityId` -1 as
/// outside block entities, and `currentRenderedItemId`.
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
let stage = horizon_shaders::render_stage(if translucent {
"HAND_TRANSLUCENT"
} else {
"HAND_SOLID"
}) as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
@@ -233,6 +305,42 @@ fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
mod tests {
use super::*;
/// The arm, then an item whose middle quad is translucent, then a second item: the opaque
/// quads keep their order first, the translucent one comes after them, and each run keeps
/// its item's id.
#[test]
fn translucent_quads_go_after_the_opaque_ones() {
let quad = |layer: u32, tag: f32| {
[Vertex {
layer,
pos: [tag, 0.0, 0.0],
..bytemuck::Zeroable::zeroed()
}; 4]
};
let glass = super::super::TRANSLUCENT_ITEM_OVERLAY;
let hand: Vec<Vertex> = [
quad(0, 0.),
quad(0, 1.),
quad(glass, 2.),
quad(0, 3.),
quad(0, 4.),
]
.concat();
// The arm is quad 0; item 7 quads 1..4; item 9 quad 4.
let split = split(&hand, &[(4, 7), (16, 9)]);
let order: Vec<f32> = split.order.chunks(4).map(|q| q[0].pos[0]).collect();
assert_eq!(order, [0., 1., 3., 4., 2.]);
assert_eq!(
split.runs,
[
(0, 1, 0, false),
(1, 2, 7, false),
(3, 1, 9, false),
(4, 1, 7, true)
]
);
}
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
@@ -30,11 +30,12 @@ impl PackImage {
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
/// The images `image.*` declares; `packed_float`: this device stores into `Rg11b10Ufloat`
/// (`format`).
pub(super) fn images(defs: Vec<CustomImage>, packed_float: bool) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
format: format(def.format, packed_float),
dimension: if def.is_1d() {
wgpu::TextureViewDimension::D1
} else if def.is_3d() {
@@ -63,7 +64,10 @@ fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
/// `R11F_G11F_B10F` is itself where the device stores into it (`packed_float`): no alpha, so it
/// reads 1 there, as Iris 1.11.7 read it with a probe pack of ours (#10490); elsewhere an
/// `Rgba16Float` stand-in, which keeps an alpha.
pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
@@ -78,6 +82,7 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, 11) if packed_float => T::Rg11b10Ufloat,
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
@@ -87,8 +92,12 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
/// Whether this device can make `def`: its format's features, and a size in pixels within the
/// device's limits.
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
let format = format(def.format);
pub(super) fn makeable(
device: &wgpu::Device,
def: &CustomImage,
packed_float: bool,
) -> Result<(), String> {
let format = format(def.format, packed_float);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
+307 -65
View File
@@ -34,33 +34,54 @@ mod packed;
#[cfg(test)]
mod world_target_tests;
#[cfg(test)]
mod celestial_blend_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod mixed_size_tests;
#[cfg(test)]
mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
#[cfg(test)]
mod shadow_faces_tests;
#[cfg(test)]
mod r11f_image_tests;
#[cfg(test)]
mod forced_flip_tests;
#[cfg(test)]
mod weather_depth_tests;
#[cfg(test)]
mod float_into_integer_tests;
#[cfg(test)]
mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod runtime_length_tests;
#[cfg(test)]
mod translucent_color_tests;
#[cfg(test)]
mod noise_resolution_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -372,6 +393,9 @@ struct Pass {
name: String,
group: Group,
pipeline: Pipeline,
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -593,6 +617,8 @@ pub struct PackPost {
/// What draws the first-person hand (`gbuffers_hand` or its fallback), after the opaque
/// world, and its vertices.
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// `gbuffers_hand_water` (or its fallback): the hand's translucent quads, after deferred.
hand_water: Option<Pass>,
/// The pre-translucent copy: `depthtex1`, or `depthtex2` without a shader hand.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
@@ -858,10 +884,11 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
}
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
/// `size`×`size` RGB white noise (6.4), from a fixed seed so every run looks the same.
fn noise(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::TextureView {
let size = size.min(device.limits().max_texture_dimension_2d);
let mut state = 0x2545_f491_4f6c_dd1du64;
let texels: Vec<u8> = (0..256 * 256 * 4)
let texels: Vec<u8> = (0..size as usize * size as usize * 4)
.map(|i| {
state ^= state << 13;
state ^= state >> 7;
@@ -869,7 +896,7 @@ fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
})
.collect();
texture(device, queue, "Shader pack noisetex", 256, &texels)
texture(device, queue, "Shader pack noisetex", size, &texels)
}
fn texture(
@@ -1525,13 +1552,20 @@ fn bindings(
}
(None, None) => stand_in(&i.name, notes),
};
if actual != format {
// An `R11F_G11F_B10F` image or buffer this device makes `RGBA16F` (a float stand-in
// that neither clamps nor drops NaN) takes `layout(r11f_g11f_b10f)` as `rgba16f`
// (`storage_formats_of`): Iris 1.11.7 stored into and read back such an image with a
// probe pack of ours (#10490), where Horizon refused the program.
let stand_in = format == wgpu::TextureFormat::Rg11b10Ufloat
&& actual == wgpu::TextureFormat::Rgba16Float;
if actual != format && !stand_in {
notes.push(format!(
"{name}: `{}` is {} but its texture is {actual:?}",
i.name, i.format
));
return None;
}
let format = actual;
let access = match i.access.as_str() {
"writeonly" => wgpu::StorageTextureAccess::WriteOnly,
"readonly" => wgpu::StorageTextureAccess::ReadOnly,
@@ -1562,6 +1596,51 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
@@ -1677,10 +1756,15 @@ fn build_pass(
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
)?;
let final_pass = p.group == Group::Final;
// Outputs into an integer buffer, and its integer kind (`packed::bitcast_outputs`).
let mut int_outputs = Vec::new();
let mut targets = Vec::new();
let mut writes = Vec::new();
// Outputs into a buffer of a small packed format, and the bits it keeps.
let mut packed_outputs = Vec::new();
// Each output's own `blend.<program>.<buffer>`, and whether its buffer blends at all: what
// the sky's part pipelines keep (`PostProgram::vanilla_sky_blends`).
let mut own_blends: Vec<(Option<wgpu::BlendState>, bool)> = Vec::new();
for &location in &interface.outputs {
let l = location as usize;
if targets.len() <= l {
@@ -1692,6 +1776,15 @@ fn build_pass(
}
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
match format.sample_type(None, Some(features)) {
Some(wgpu::TextureSampleType::Uint) => {
int_outputs.push((location, naga::ScalarKind::Uint));
}
Some(wgpu::TextureSampleType::Sint) => {
int_outputs.push((location, naga::ScalarKind::Sint));
}
_ => {}
}
let mut state = target(format);
if !p.group.shadow()
&& pack.rgb & 1 << buffer != 0
@@ -1699,19 +1792,21 @@ fn build_pass(
{
t.write_mask = wgpu::ColorWrites::COLOR;
}
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
let own = p.buffer_blends.iter().find(|(n, _)| *n == buffer);
let blendable = format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE);
if let (Some(t), Some(b)) = (&mut state, own.map_or(p.blend, |(_, b)| *b))
&& blendable
{
t.blend = Some(blend_state(b));
}
if own_blends.len() <= l {
own_blends.resize(l + 1, (None, false));
}
let own = own.and_then(|(_, b)| *b).filter(|_| blendable);
own_blends[l] = (own.map(blend_state), blendable);
if !p.group.shadow()
&& let Some(bits) = pack.packed[buffer as usize]
{
@@ -1771,6 +1866,7 @@ fn build_pass(
});
let module = |mut m: naga::Module| {
share_samplers(&mut m, &slots);
storage_formats_of(&mut m, &entries);
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&p.name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
@@ -1778,55 +1874,76 @@ fn build_pass(
};
let mut fragment = program.fragment;
packed::quantise_outputs(&mut fragment, &packed_outputs);
packed::bitcast_outputs(&mut fragment, &int_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
},
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &targets,
}),
multiview_mask: None,
cache: None,
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
})
};
let pipeline = descriptor(&targets);
// The program's own blend replaced by vanilla's for the part; a buffer's own
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
// buffer only, with a probe pack of ours).
let sky_parts = p.vanilla_sky_blends.then(|| {
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
let targets: Vec<_> = (targets.iter().enumerate())
.map(|(l, t)| {
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
t.clone().map(|t| wgpu::ColorTargetState {
blend: own.or(part.filter(|_| blendable)),
..t
})
})
.collect();
descriptor(&targets)
})
});
(layout, pipeline)
(layout, pipeline, sky_parts)
});
let (layout, pipeline) = match created {
let (layout, pipeline, sky_parts) = match created {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
@@ -1864,6 +1981,7 @@ fn build_pass(
name: p.name,
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
layout,
samplers,
writes,
@@ -1957,6 +2075,7 @@ fn build_compute(
});
let mut module = c.module;
share_samplers(&mut module, &slots);
storage_formats_of(&mut module, &entries);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
@@ -2002,6 +2121,7 @@ fn build_compute(
render: c.work_groups_render,
local,
},
sky_parts: None,
layout,
samplers,
writes: Vec::new(),
@@ -2086,6 +2206,7 @@ impl PackPost {
}
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -2128,9 +2249,14 @@ impl PackPost {
// An image this device can't make or store into: the programs using it are skipped.
let mut image_defs = std::mem::take(&mut post.images);
let mut left_out = Vec::new();
let packed_float = gpu.storable(adapter, wgpu::TextureFormat::Rg11b10Ufloat, features);
image_defs.retain(|def| {
let made = pack_images::makeable(device, def).and_then(|()| {
if gpu.storable(adapter, pack_images::format(def.format), features) {
let made = pack_images::makeable(device, def, packed_float).and_then(|()| {
if gpu.storable(
adapter,
pack_images::format(def.format, packed_float),
features,
) {
Ok(())
} else {
Err("this GPU can't store into its format".into())
@@ -2152,7 +2278,7 @@ impl PackPost {
));
left_out.push(def);
}
let images = pack_images::images(image_defs);
let images = pack_images::images(image_defs, packed_float);
let mut objects = std::mem::take(&mut post.buffer_objects);
if gpu.storage_stages().is_empty() {
objects.clear();
@@ -2253,7 +2379,16 @@ impl PackPost {
} else {
written.next()
};
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
// Sizes of one kind that differ never meet. A size relative to the render size and
// one in pixels can: the pass then draws while they resolve to one size
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
let apart = |s: horizon_shaders::BufferSize| {
first.is_some_and(|f| {
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
})
};
if !in_shadow && group != Group::Final && written.any(apart) {
notes.push(format!(
"{}: writes buffers of different sizes (size.buffer); skipped",
pass.name
@@ -2465,6 +2600,20 @@ impl PackPost {
};
build(p, Some(&g)).map(|pass| (pass, geometry))
});
// Translucent held items, drawn from the hand's own buffer.
let hand_water = post.hand_water.and_then(|p| {
let (_, geometry) = hand.as_ref()?;
let g = Geometry {
buffers: &entity_buffers,
table: Some(&geometry.layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
build(p, Some(&g))
});
let weather = post
.weather
.zip(weather_geometry.as_ref())
@@ -2585,6 +2734,9 @@ impl PackPost {
if let Some((h, _)) = &hand {
notes.push(format!("the hand draws with {}", h.name));
}
if let Some(h) = &hand_water {
notes.push(format!("translucent held items draw with {}", h.name));
}
if let Some([w, _]) = &weather {
notes.push(format!("rain and snow draw with {}", w.name));
}
@@ -2638,6 +2790,7 @@ impl PackPost {
.chain(&lines)
.chain(weather.iter().flatten())
.chain(hand.iter().map(|h| &h.0))
.chain(&hand_water)
};
let shadow_targets = shadow_formats.map(|f| {
let colors = every_pass().fold(0u16, |m, p| {
@@ -2691,6 +2844,7 @@ impl PackPost {
.chain(&water)
.chain(weather.iter().take(1).flatten())
.chain(hand.iter().map(|h| &h.0))
.chain(&hand_water)
.find(touched)
.map(|_| true)
.or_else(|| passes.iter().find(touched).map(|_| true));
@@ -2870,6 +3024,7 @@ impl PackPost {
weather_draw: None,
weather_generation: 0,
hand,
hand_water,
depth1,
depth2,
shadow_targets,
@@ -2910,7 +3065,7 @@ impl PackPost {
targets: None,
noise: pack_textures::noise(&textures)
.cloned()
.unwrap_or_else(|| noise(device, queue)),
.unwrap_or_else(|| noise(device, queue, noise_size)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
@@ -3500,6 +3655,16 @@ impl PackPost {
open.as_mut().expect("open").1 = i;
}
let rp = &mut open.as_mut().expect("open").0;
// Each part with vanilla's blend for it: one program, three pipelines.
let part_pipeline =
pass.sky_parts
.as_ref()
.and_then(|[plain, stars]| match part.stage {
"SKY" | "VOID" => Some(plain),
"STARS" => Some(stars),
_ => None,
});
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
if pass.draw {
self.draws.bind(rp, part.stage);
}
@@ -3700,6 +3865,7 @@ impl PackPost {
portal: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.portal_rect = portal;
let shadow = self.shadow_casters();
@@ -3720,6 +3886,7 @@ impl PackPost {
shadow,
rest,
skin,
motion,
);
}
@@ -3838,8 +4005,14 @@ impl PackPost {
rp.set_bind_group(1, &geometry.group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
// The arm and each held item: their own `currentRenderedItemId`.
for &(first, quads, offset) in &geometry.runs {
// The arm and each held item: their own `currentRenderedItemId`. Translucent
// quads wait for `gbuffers_hand_water` when the pack has one.
let late = self.hand_water.is_some();
let runs = geometry
.runs
.iter()
.chain(geometry.translucent_runs.iter().filter(|_| !late));
for &(first, quads, offset) in runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
@@ -3966,9 +4139,53 @@ impl PackPost {
terrain,
);
self.draw_entities(device, queue, encoder, size, true);
self.draw_hand_water(device, queue, encoder, size);
self.draw_weather(device, queue, encoder, size);
}
/// The hand's translucent quads (uploaded by `draw_hand` this frame), with
/// `gbuffers_hand_water`.
fn draw_hand_water(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
size: [u32; 2],
) {
let Some(mut pass) = self.hand_water.take() else {
return;
};
let hand = self.hand.take();
if let Some((_, geometry)) = hand
.as_ref()
.filter(|(_, g)| g.quads > 0 && !g.translucent_runs.is_empty())
{
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for &(first, quads, offset) in &geometry.translucent_runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
}
}
self.mips_valid &= !pass.written;
}
self.hand = hand;
self.hand_water = Some(pass);
}
/// The rain, then the snow, `set_weather` gave for this frame, with `gbuffers_weather`.
fn draw_weather(
&mut self,
@@ -4083,6 +4300,18 @@ impl PackPost {
})
}
/// Whether the buffers `pass` writes have different sizes at the current render size: then
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
/// a pass writing buffers sized relative to the render size and in pixels can.
fn sizes_differ(&self, pass: &Pass) -> bool {
let Some(targets) = &self.targets else {
return false;
};
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
let first = sizes.next();
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
}
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
@@ -4144,6 +4373,7 @@ impl PackPost {
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.hand_water)
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp);
@@ -4323,6 +4553,14 @@ impl PackPost {
mips,
});
self.forget_groups();
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
log::info!(
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
pass.name,
size[0],
size[1]
);
}
}
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
@@ -4869,6 +5107,10 @@ impl PackPost {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
// As if the pack didn't have it: no compute stages, flips or stage changes.
if self.sizes_differ(pass) {
continue;
}
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
water_drawn = true;
self.draw_translucent(device, queue, encoder, size, terrain);
@@ -0,0 +1,99 @@
//! The sun and moon blend as vanilla's `RenderPipelines.CELESTIAL` (`BlendFunction.OVERLAY`):
//! their colour is added to the sky's, and their own alpha replaces the buffer's. Iris 1.11.7
//! left the sun's alpha in a buffer the sky had written first, with a probe pack of ours
//! (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// The sky writes alpha 1 into colortex1, then the sun (default blend) writes alpha 0.5 there;
/// final shows colortex1's alpha in red.
fn files() -> Vec<(String, String)> {
let program = |alpha: &str| {
format!(
"#version 120\n/* DRAWBUFFERS:1 */\n\
void main() {{ gl_FragData[0] = vec4(0.0, 0.0, 0.0, {alpha}); }}\n"
)
};
let vertex = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
vec![
("gbuffers_skybasic.vsh".into(), vertex.into()),
("gbuffers_skybasic.fsh".into(), program("1.0")),
("gbuffers_skytextured.vsh".into(), vertex.into()),
("gbuffers_skytextured.fsh".into(), program("0.5")),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() { gl_FragColor = vec4(texture2D(colortex1, uv).a, 0.0, 0.0, 1.0); }\n"
.into(),
),
]
}
/// A quad across the whole view, `z` blocks in front of the camera.
fn quad(z: f32) -> [crate::mesher::Vertex; 4] {
[
[-400.0, -400.0],
[400.0, -400.0],
[400.0, 400.0],
[-400.0, 400.0],
]
.map(|[x, y]| crate::mesher::Vertex {
pos: [x, y, z],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
})
}
#[test]
fn the_sun_leaves_its_own_alpha_over_the_sky() {
let Some(gpu) = Gpu::new() else { return };
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The world drew nothing: the far plane (reverse-Z) everywhere.
gpu.clear(&color, &depth, 0.0);
let sun = gpu.texture(T::Rgba8Unorm);
let mut post = gpu.load(
files(),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: Some(sun),
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
celestial: Some(super::super::pack_sky::celestial_inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
let sun = quad(100.0);
frame.celestial = &sun;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// The top four rows see the sky disc (sky_depth_tests); the sun covers the whole view.
for (i, p) in pixels[..4 * SIZE[0] as usize].iter().enumerate() {
assert!(
p[0].abs_diff(128) <= 1,
"pixel {i}: colortex1 alpha {} / 255, expected the sun's 0.5",
p[0]
);
}
}
@@ -613,6 +613,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -451,6 +451,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -0,0 +1,67 @@
//! A program writing a float `vec4` into an integer buffer draws: its other buffers get what it
//! wrote, and the integer buffer the floats' bits, as Iris 1.11.7 on NVIDIA 615.78.08 did with
//! a probe pack of ours (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn a_float_output_into_an_integer_buffer_writes_its_bits() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("composite.vsh".into(), VERTEX.into()),
(
"composite.fsh".into(),
"#version 130\n/*\nconst int colortex3Format = RGBA32UI;\n*/\n/* DRAWBUFFERS:03 */\n\
void main() {\n\
\x20 gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\n\
\x20 gl_FragData[1] = vec4(1.5, 2.0, 7.0, 0.25);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\nuniform sampler2D colortex0;\nuniform usampler2D colortex3;\n\
varying vec2 uv;\n\
void main() {\n\
\x20 uvec4 q = texelFetch(colortex3, ivec2(gl_FragCoord.xy), 0);\n\
\x20 bvec4 bits = equal(q, uvec4(0x3FC00000u, 0x40000000u, 0x40E00000u, 0x3E800000u));\n\
\x20 gl_FragColor = uv.y > 0.5 ? texture2D(colortex0, uv)\n\
\x20 : vec4(float(bits.x && bits.y), float(bits.z), float(bits.w), 1.0);\n}\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
let width = SIZE[0] as usize;
let (top, bottom) = (pixels[0], pixels[(SIZE[1] as usize - 1) * width]);
assert!(
top[0].abs_diff(64) <= 1 && top[1].abs_diff(128) <= 1 && top[2].abs_diff(191) <= 1,
"colortex0 {top:?}, notes {:?}",
post.notes
);
assert_eq!(bottom, [255, 255, 255, 255], "colortex3's bits");
}
@@ -0,0 +1,89 @@
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
//! original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
/// blue.
fn pack(colortex3: &str) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// Every pixel's colour, with how many pixels differ from it by more than one step.
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
let first = pixels[0];
let off = pixels
.iter()
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
.count();
(first, off)
}
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
/// the whole pack in that case; Horizon skips only the program).
#[test]
fn a_program_writing_buffers_that_come_to_one_size_draws() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack("4 4"));
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
assert_eq!(off, 0, "{colour:?}");
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
assert!(got.abs_diff(want) <= 1, "{colour:?}");
}
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
}
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
#[test]
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
let Some(gpu) = Fixture::new() else { return };
let post = pack("0.5 0.5");
let post = gpu
.try_build(post)
.unwrap_or_else(|_| panic!("the pack runs"));
assert!(
post.notes
.iter()
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
"{:?}",
post.notes
);
}
@@ -0,0 +1,63 @@
//! `noiseTextureResolution` sizes the `noisetex` Horizon makes, as Iris 1.11.7 did with probe packs
//! of ours (#10490): a declaration in a fragment shader counts, one in a vertex shader doesn't.
//! Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// The width of `noisetex` that `final` sees, with these lines added to `final.vsh` and `final.fsh`.
fn noise_width(vertex_extra: &str, fragment_extra: &str) -> u8 {
let Some(gpu) = Gpu::new() else { return 0 };
let vertex = VERTEX.replacen('\n', &format!("\n{vertex_extra}"), 1);
let files = vec![
("final.vsh".into(), vertex),
(
"final.fsh".into(),
format!(
"#version 130\n{fragment_extra}uniform sampler2D noisetex;\nvarying vec2 uv;\n\
void main() {{ gl_FragColor = vec4(float(textureSize(noisetex, 0).x) / 255.0, 0.0, 0.0, 1.0); }}\n"
),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
assert!(post.notes.is_empty(), "{:?}", post.notes);
pixels[0][0]
}
#[test]
fn a_fragment_shader_sets_the_noise_resolution() {
if Gpu::new().is_none() {
return;
}
let declared = "#define NOISE 64\nconst int noiseTextureResolution = NOISE;\n";
assert_eq!(noise_width("", declared), 64);
// Not from a vertex shader: the default 256, read as 255 (1.0).
assert_eq!(
noise_width("const int noiseTextureResolution = 64;\n", ""),
255
);
}
@@ -180,6 +180,124 @@ pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8;
f.body = out;
}
/// A program's `vec4` float output into an integer buffer (`RGBA32UI`...) writes the float's
/// bits there, and the program draws its other buffers. Horizon refused such a program, since
/// wgpu has no float output into an integer target. Iris 1.11.7 drew it, with a probe pack of
/// ours (#10490): its other buffers kept every bit written, NaN payloads included, and the
/// integer buffer read the floats' bits back (1.5 as 0x3FC00000). GL leaves the integer
/// buffer's value undefined, so those bits are what this driver (NVIDIA 615.78.08) wrote.
pub(super) fn bitcast_outputs(module: &mut naga::Module, outputs: &[(u32, naga::ScalarKind)]) {
let Some(ep) =
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
else {
return;
};
let Some(result) = &ep.function.result else {
return;
};
let naga::TypeInner::Struct { members, span } = module.types[result.ty].inner.clone() else {
return;
};
let mut new_members = members.clone();
// (member, integer kind) of each output that changes.
let mut changed = Vec::new();
for (i, m) in members.iter().enumerate() {
let Some(naga::Binding::Location { location, .. }) = m.binding else {
continue;
};
let Some(&(_, kind)) = outputs.iter().find(|o| o.0 == location) else {
continue;
};
if !matches!(
module.types[m.ty].inner,
naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar::F32
}
) {
continue;
}
let ty = module.types.insert(
naga::Type {
name: None,
inner: naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar { kind, width: 4 },
},
},
naga::Span::UNDEFINED,
);
new_members[i].ty = ty;
if let Some(naga::Binding::Location {
interpolation,
sampling,
..
}) = &mut new_members[i].binding
{
*interpolation = Some(naga::Interpolation::Flat);
*sampling = None;
}
changed.push((i, kind));
}
if changed.is_empty() {
return;
}
let old_name = module.types[result.ty].name.clone();
let struct_ty = module.types.insert(
naga::Type {
name: old_name.map(|n| n + "_bits"),
inner: naga::TypeInner::Struct {
members: new_members,
span,
},
},
naga::Span::UNDEFINED,
);
let f = &mut ep.function;
if let Some(r) = &mut f.result {
r.ty = struct_ty;
}
let body = std::mem::take(&mut f.body);
let mut out = naga::Block::with_capacity(body.len() + 1);
for (statement, sp) in body.span_into_iter() {
let Statement::Return { value: Some(value) } = statement else {
out.push(statement, sp);
continue;
};
let Expression::Compose { components, .. } = &f.expressions[value] else {
out.push(statement, sp);
continue;
};
let mut components = components.clone();
let mut first = None;
for &(member, kind) in &changed {
let e = f.expressions.append(
Expression::As {
expr: components[member],
kind,
convert: None,
},
sp,
);
first.get_or_insert(e);
components[member] = e;
}
let value = f.expressions.append(
Expression::Compose {
ty: struct_ty,
components,
},
sp,
);
out.push(
Statement::Emit(naga::Range::new_from_bounds(first.unwrap_or(value), value)),
sp,
);
out.push(Statement::Return { value: Some(value) }, sp);
}
f.body = out;
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,63 @@
//! An `R11F_G11F_B10F` image that a compute program declares `layout(r11f_g11f_b10f)` is stored
//! into and read back, as Iris 1.11.7 did with a probe pack of ours (#10490): its program is no
//! longer refused when the image is the `RGBA16F` stand-in. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn an_r11f_image_is_stored_into_and_read_back() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
(
"shaders.properties".into(),
"image.imgA = texA RGBA R11F_G11F_B10F FLOAT false false 4 4\n".into(),
),
(
"composite.csh".into(),
"#version 430\nlayout(local_size_x = 1) in;\nconst ivec3 workGroups = ivec3(1, 1, 1);\n\
layout(r11f_g11f_b10f) writeonly uniform image2D imgA;\n\
void main() { imageStore(imgA, ivec2(1, 1), vec4(0.25, 1.5, 3.0, 1.0)); }\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\nuniform sampler2D texA;\nvarying vec2 uv;\n\
void main() { gl_FragColor = vec4(texelFetch(texA, ivec2(1, 1), 0).rgb / 4.0, 1.0); }\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// (0.25, 1.5, 3.0) / 4 = (0.0625, 0.375, 0.75).
let p = pixels[0];
assert!(
p[0].abs_diff(16) <= 1 && p[1].abs_diff(96) <= 1 && p[2].abs_diff(191) <= 1,
"{p:?}, notes {:?}",
post.notes
);
}
@@ -0,0 +1,50 @@
//! `.length()` of a runtime-sized array in a named buffer block reads the buffer's own length, as
//! Iris 1.11.7 did with a probe pack of ours (#10490): `bufferObject.0 = 32` holds 8 floats. The
//! program was refused before. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn a_runtime_arrays_length_is_the_buffers() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("shaders.properties".into(), "bufferObject.0 = 32\n".into()),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 430 compatibility\nin vec2 uv;\n\
layout(std430, binding = 0) buffer Values { float v[]; } values;\n\
void main() { gl_FragColor = vec4(float(values.v.length()) / 32.0, 0.0, 0.0, 1.0); }\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// 32 bytes of floats: 8 entries, 8 / 32 = 0.25.
let p = pixels[0];
assert!(p[0].abs_diff(64) <= 1, "{p:?}, notes {:?}", post.notes);
}
@@ -0,0 +1,99 @@
//! The sky disc draws unblended, as vanilla draws it (`RenderPipelines.SKY`): what
//! `gbuffers_skybasic` writes there stays, alpha included. Iris 1.11.7 left exactly that with
//! probe packs of ours (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `gbuffers_skybasic` writes (0.2, 0.4, 0.6, 0.25) into colortex1, with `blend` as the
/// properties line; final shows colortex1's colour on the top half and its alpha on the bottom
/// half, each seeing the top of the view.
fn files(blend: &str) -> Vec<(String, String)> {
vec![
("shaders.properties".into(), blend.into()),
(
"gbuffers_skybasic.vsh".into(),
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
),
(
"gbuffers_skybasic.fsh".into(),
"#version 120\n/* DRAWBUFFERS:1 */\n\
void main() { gl_FragData[0] = vec4(0.2, 0.4, 0.6, 0.25); }\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 vec4 c = texture2D(colortex1, vec2(uv.x, uv.y > 0.5 ? uv.y : uv.y + 0.5));\n\
\x20 gl_FragColor = uv.y > 0.5 ? vec4(c.rgb, 1.0) : vec4(c.a, c.a, c.a, 1.0);\n}\n"
.into(),
),
]
}
/// The pack's picture of a frame whose world drew nothing.
fn picture(gpu: &Gpu, blend: &str) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The far plane everywhere (reverse-Z).
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files(blend),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
/// The top four rows see the sky disc (sky_depth_tests); final repeats them in rows 8 to 11.
#[test]
fn the_sky_disc_keeps_what_the_program_wrote() {
let Some(gpu) = Gpu::new() else { return };
let width = SIZE[0] as usize;
let pixels = picture(&gpu, "");
for (i, p) in pixels[..4 * width].iter().enumerate() {
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
assert!(
near(p[0], 51) && near(p[1], 102) && near(p[2], 153),
"pixel {i}: colour {p:?}, expected (0.2, 0.4, 0.6)"
);
}
for (i, p) in pixels[8 * width..12 * width].iter().enumerate() {
assert!(p[0].abs_diff(64) <= 1, "pixel {i}: alpha {} / 255", p[0]);
}
// A pack's own `blend.gbuffers_skybasic` still blends the disc: alpha over the cleared 1.
let blended = picture(
&gpu,
"blend.gbuffers_skybasic = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
);
assert!(blended[0][0].abs_diff(51) > 8, "{:?}", blended[0]);
// So does a `blend.gbuffers_skybasic.colortex1` of its own, into that buffer.
let own = picture(
&gpu,
"blend.gbuffers_skybasic.colortex1 = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
);
assert!(own[0][0].abs_diff(51) > 8, "{:?}", own[0]);
}
@@ -420,6 +420,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -3,13 +3,15 @@
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
//! has passed since the last.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
@@ -43,6 +45,10 @@ pub struct PassTiming {
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
/// Least time between timed frames.
pub gap: Duration,
/// When the last timed frame began.
timed_at: Option<Instant>,
}
impl PassTiming {
@@ -81,6 +87,8 @@ impl PassTiming {
log: Self::logging(),
marker: None,
reports: 0,
gap: Duration::ZERO,
timed_at: None,
})
}
@@ -123,9 +131,12 @@ impl PassTiming {
}
_ => {}
}
if matches!(self.stage, Stage::Idle) {
if matches!(self.stage, Stage::Idle)
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
{
self.names.clear();
self.stage = Stage::Recording;
self.timed_at = Some(Instant::now());
}
}
@@ -94,6 +94,31 @@ pub struct PackInputs {
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
pub still_ticks: Option<u64>,
/// A scripted clock (`screenshot-sequence`): taken before `still_ticks` and the wall
/// clock, and smoothed values move by its `delta`.
pub frame_timing: Option<FrameTiming>,
}
/// One frame of a scripted pack clock: the frame's number from 1, the seconds since frame 1,
/// and the seconds since the frame before (0 on frame 1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameTiming {
pub counter: u32,
pub elapsed: f32,
pub delta: f32,
}
impl FrameTiming {
/// Frame `n` (from 1) of frames `step_ms` milliseconds apart (`16.666667` for 60 fps). The
/// elapsed time is worked out in f64, so frame 10 000 is as exact as frame 2.
pub fn nth(n: u32, step_ms: f64) -> Self {
let step = step_ms / 1000.0;
Self {
counter: n,
elapsed: (f64::from(n.saturating_sub(1)) * step) as f32,
delta: if n <= 1 { 0.0 } else { step as f32 },
}
}
}
/// The `cloudHeight` uniform: the dimension's `visual/cloud_height`
@@ -349,8 +374,10 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
still_ticks: Option<u64>,
/// The tick `eval_custom` restarts the custom uniforms at: every frame's under STILL; on a
/// scripted clock's frame 1 only 0 (where a freshly parsed pack starts), so its later frames
/// carry their smoothing and randoms.
custom_restart: Option<u64>,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
@@ -369,7 +396,7 @@ impl Default for FrameUniforms {
start: std::time::Instant::now(),
last: None,
frame: 0,
still_ticks: None,
custom_restart: None,
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
@@ -687,9 +714,16 @@ impl FrameUniforms {
let now = std::time::Instant::now();
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
self.still_ticks = inputs.still_ticks;
self.custom_restart = match inputs.frame_timing {
Some(timing) => (timing.counter <= 1).then_some(0),
None => inputs.still_ticks,
};
// Smoothing over an endless step lands on its target.
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
let dt = match (inputs.frame_timing, inputs.still_ticks) {
(Some(timing), _) => timing.delta,
(None, Some(_)) => f32::INFINITY,
(None, None) => measured,
};
let aspect = size[0] as f32 / size[1].max(1) as f32;
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
@@ -781,13 +815,14 @@ impl FrameUniforms {
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
Some(ticks) => (
let (seconds, frame_time, frame_counter) = match (inputs.frame_timing, inputs.still_ticks) {
(Some(timing), _) => (timing.elapsed, timing.delta, timing.counter as i32),
(None, Some(ticks)) => (
(ticks as f32 / 20.0) % 3600.0,
0.05,
(ticks % 720_720) as i32,
),
None => (
(None, None) => (
(now - self.start).as_secs_f32() % 3600.0,
measured,
self.frame as i32,
@@ -929,7 +964,7 @@ impl FrameUniforms {
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
/// `NAMES`' values, in `custom.uniforms()` order.
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
if let Some(tick) = self.still_ticks {
if let Some(tick) = self.custom_restart {
custom.restart_at(tick);
}
self.values.truncate(NAMES.len());
@@ -1298,7 +1333,7 @@ mod tests {
}
#[cfg(test)]
mod initial_history_tests {
pub(super) mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
@@ -2193,6 +2228,145 @@ mod initial_history_tests {
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
}
/// A scripted clock's values reach the pack as they are, before STILL's ticks.
#[test]
fn a_scripted_clock_comes_before_the_still_ticks() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
for still_ticks in [None, Some(47_811)] {
let mut u = FrameUniforms::default();
for (n, elapsed, delta) in [(1, 0.0, 0.0), (64, 3.15, 0.05)] {
let input = PackInputs {
day_time: 6_001,
still_ticks,
frame_timing: Some(FrameTiming {
counter: n,
elapsed,
delta,
}),
..Default::default()
};
u.update(&f, &input, [800, 450]);
match u.values[index("frameCounter").unwrap()] {
Value::I(v) => assert_eq!(v[0], n as i32),
_ => panic!("integer uniform expected"),
}
assert_eq!(float(&u, "frameTimeCounter")[0], elapsed);
assert_eq!(float(&u, "frameTime")[0], delta);
match u.values[index("worldTime").unwrap()] {
Value::I(v) => assert_eq!(v[0], 6_001),
_ => panic!("integer uniform expected"),
}
}
}
}
/// Smoothed values move by a scripted clock's delta, also under STILL, which would
/// otherwise put them at their targets.
#[test]
fn a_scripted_clock_moves_smoothed_values_by_its_delta() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 1.0,
eye_light: [0, 15],
still_ticks: Some(47_811),
frame_timing: Some(FrameTiming::nth(1, 1000.0)),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.rain = 0.0;
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
// A second each: eyeBrightnessSmooth halves (1 s half-life),
// centerDepthSmooth halves ten times (0.1 s), wetness falls by 0.5^(1/20) (20 s).
for (n, eye, depth, wetness) in [
(2, 120, 1.0 / 1_024.0, 0.965_936_3),
(3, 60, 1.0 / 1_048_576.0, 0.933_033),
] {
input.frame_timing = Some(FrameTiming::nth(n, 1000.0));
u.update(&f, &input, [800, 450]);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(v[1], eye),
_ => panic!("integer uniform expected"),
}
assert_eq!(float(&u, "centerDepthSmooth")[0], depth);
assert!((float(&u, "wetness")[0] - wetness).abs() < 1e-6);
}
}
/// Custom uniforms start over on a scripted clock's frame 1 and carry their smoothing
/// and randoms through the frames after it, so a sequence drawn again repeats.
#[test]
fn custom_uniforms_start_over_on_a_scripted_frame_1() {
let properties = horizon_shaders::Properties::parse(
"uniform.float.s = smooth(rainStrength, 10.0)
uniform.float.r = random()
",
&horizon_shaders::Macros::standard(),
);
let mut custom = horizon_shaders::Custom::parse(&properties);
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut run = |u: &mut FrameUniforms| -> Vec<[u32; 2]> {
(1..=3)
.map(|n| {
let input = PackInputs {
rain: if n == 1 { 0.0 } else { 1.0 },
still_ticks: Some(47_811),
frame_timing: Some(FrameTiming::nth(n, 1000.0)),
..Default::default()
};
u.update(&f, &input, [800, 450]);
u.eval_custom(&mut custom);
std::array::from_fn(|i| match u.values[NAMES.len() + i] {
Value::F(v) => v[0].to_bits(),
_ => panic!("float uniform expected"),
})
})
.collect()
};
let first = run(&mut u);
// The smoothed rain moves toward 1 over frames 2 and 3; the randoms differ
// each frame.
let s: Vec<f32> = first.iter().map(|v| f32::from_bits(v[0])).collect();
assert!(
s[0] == 0.0 && s[0] < s[1] && s[1] < s[2] && s[2] < 1.0,
"{s:?}"
);
assert!(first[0][1] != first[1][1] && first[1][1] != first[2][1]);
assert_eq!(run(&mut u), first);
}
#[test]
fn the_nth_scripted_frame_counts_from_1() {
assert_eq!(
FrameTiming::nth(1, 50.0),
FrameTiming {
counter: 1,
elapsed: 0.0,
delta: 0.0
}
);
assert_eq!(
FrameTiming::nth(2, 50.0),
FrameTiming {
counter: 2,
elapsed: 0.05,
delta: 0.05
}
);
assert_eq!(FrameTiming::nth(64, 50.0).elapsed, 3.15);
assert_eq!(FrameTiming::nth(10_000, 50.0).elapsed, 499.95);
assert_eq!(FrameTiming::nth(3, 1.0).elapsed, 0.002);
// 60 fps: frame 61 is one second in, not 61 × 16 ms or 61 × 17 ms.
let sixty = FrameTiming::nth(61, 1000.0 / 60.0);
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -2210,7 +2384,7 @@ mod initial_history_tests {
assert_eq!(float(&u, "constantMood")[0], 0.0235);
}
fn frame(hud: &HudBatch) -> Frame<'_> {
pub(in crate::gfx) fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
@@ -2257,6 +2431,7 @@ mod initial_history_tests {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -323,4 +323,26 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack under `HORIZON_HDR_FRAME=float`
/// (`Renderer::frame_format`): what the tonemap left over 1 is clipped on the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
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;
};
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+1 -1
View File
@@ -771,7 +771,7 @@ pub struct Graphics {
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
pub shader_pack: String,
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
/// for a short ring of far terrain past a small render distance (a phone or an
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
const LOD_STEPS: [u32; 24] = [
0,
16,
24,
32,
48,
64,
96,
128,
192,
256,
512,
1024,
@@ -1576,7 +1584,7 @@ impl GraphicsUi {
&cols,
&lines,
self.scroll.amount,
Page::name,
|page| lang::get(page.name()),
|batch, setting: Setting, y| {
let item = setting.item;
let rect = cols.row(y);
@@ -2692,12 +2700,21 @@ mod tests {
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
item.set_slider(&mut s, 11.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// The fine stops: a ring of far terrain just past a small render
// distance, which the doubling steps skipped (#9694).
item.set_slider(&mut s, 1.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 16);
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
item.set_slider(&mut s, 2.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 24);
// A distance between stops (from the file) sits on the next one.
s.horizon.lod_distance = 3000;
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
+36 -2
View File
@@ -76,6 +76,7 @@ pub struct HudAtlas {
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
pub object_skins_revision: u64,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -786,21 +787,29 @@ impl HudAtlas {
.or_else(|| self.sprites.get(MISSING_SPRITE))
.copied()
}
pub fn clear_object_skins(&mut self) {
self.object_skins.clear();
self.object_skins_revision = Self::next_revision();
}
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
self.clear_object_skins();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
if entry.get() != &skin {
entry.insert(skin);
self.object_skins_revision = Self::next_revision();
}
}
Entry::Vacant(entry) => {
entry.insert(skin);
self.object_skins_revision = Self::next_revision();
}
}
}
@@ -920,6 +929,7 @@ impl HudAtlas {
height: 1,
sprites: HashMap::new(),
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent: HashSet::new(),
flags: HashMap::new(),
sprite_animations: Vec::new(),
@@ -985,6 +995,7 @@ impl HudAtlas {
translucent: HashSet::new(),
flags: HashMap::new(),
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
@@ -1436,6 +1447,7 @@ impl HudAtlas {
height,
sprites,
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent,
flags,
sprite_animations,
@@ -2890,6 +2902,28 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn object_skin_revision_tracks_changes_and_capacity_clear() {
let mut atlas = HudAtlas::boot();
atlas.set_object_skin("name:player".into(), "skins/server".into());
let revision = atlas.object_skins_revision;
atlas.set_object_skin("name:player".into(), "skins/server".into());
assert_eq!(atlas.object_skins_revision, revision);
atlas.set_object_skin("name:player".into(), "skins/inline".into());
assert_ne!(atlas.object_skins_revision, revision);
for i in 1..1024 {
atlas.set_object_skin(format!("name:{i}"), "skins/server".into());
}
let revision = atlas.object_skins_revision;
atlas.set_object_skin("name:overflow".into(), "skins/server".into());
assert_ne!(atlas.object_skins_revision, revision);
assert_eq!(atlas.object_skins.len(), 1);
let revision = atlas.object_skins_revision;
atlas.clear_object_skins();
assert_ne!(atlas.object_skins_revision, revision);
assert!(atlas.object_skins.is_empty());
}
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
@@ -485,11 +485,6 @@ pub struct HealthHud {
/// `Util.getMillis()` of the last health change.
pub last_health_time: i64,
pub health_blink_time: i64,
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
/// and air layers have run; the selected item name and the action bar sit
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
@@ -655,9 +650,6 @@ impl HealthHud {
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
let row_height = (10 - (rows - 2)).max(3);
let mut y_air = y_base - 10;
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
let offset_index = if i.regeneration {
i.tick % ceil(max_health + 5.0)
} else {
@@ -752,13 +744,11 @@ impl HealthHud {
}
}
y_air -= 10;
self.right_height += 10;
}
// Hud.extractAirBubbles.
let max_air = i.max_air.max(1);
let air = i.air.clamp(0, max_air);
if i.under_water || air < max_air {
self.right_height += 10;
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
let y = y_air - (rows - 1) * 10;
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
@@ -323,13 +323,12 @@ impl Titles {
}
}
}
/// `Hud.extractOverlayMessage`; `bar_height` is
/// `max(leftHeight, rightHeight)`.
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
/// tall the health, food, air and mount rows above the hotbar are.
pub fn overlay_message(
&self,
w: i32,
h: i32,
bar_height: i32,
partial: f32,
font: &Font,
background: u32,
@@ -345,12 +344,11 @@ impl Titles {
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
if alpha > 0 {
let message = message.resolve_legacy();
// The bars' height plus the gap between the item name (59) and the
// overlay (68), at least the default level.
let y_shift = bar_height + (68 - 59);
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
// constant the bars above the hotbar never move.
out.push(GuiDraw::Push(vec![PoseOp::Translate(
(w / 2) as f32,
(h - y_shift.max(68)) as f32,
(h - 68) as f32,
)]));
let color = if self.overlay_animated {
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
@@ -468,13 +466,13 @@ impl ToolHighlight {
self.last = selected;
}
/// `Hud.extractSelectedItemName`; `can_hurt` is
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
/// is `max(leftHeight, rightHeight)`.
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
/// pixels above the bottom edge whatever the bars above the hotbar do,
/// and 14 lower when the mode cannot hurt the player.
pub fn draw(
&self,
w: i32,
h: i32,
bar_height: i32,
can_hurt: bool,
font: &Font,
background: u32,
@@ -495,7 +493,7 @@ impl ToolHighlight {
}
let str_width = width(font, &name);
let x = (w - str_width) / 2;
let mut y = h - bar_height.max(59);
let mut y = h - 59;
if !can_hurt {
y += 14;
}
@@ -1295,3 +1293,92 @@ mod subtitle_tests {
assert!(!o.listening());
}
}
#[cfg(test)]
mod text_position_tests {
use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The missing-glyph metrics let these tests run without game assets.
fn font() -> Font {
Font::load(
Path::new("/nonexistent-horizon-hud-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
)
}
fn held() -> HeldName {
HeldName {
item: "minecraft:stone".into(),
hover_name: Text::literal("Stone"),
rarity_color: 0xFFFF_FFFF,
custom_name: false,
}
}
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
})
}
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
/// game mode cannot hurt the player. The health, food, air and mount rows
/// above the hotbar never move it: with armor and a second row of hearts
/// they reach 69 pixels, and the name stays where vanilla draws it.
#[test]
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
let mut highlight = ToolHighlight {
last: Some(held()),
timer: 40,
};
let f = font();
let draws = highlight.draw(320, 240, true, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59));
// Creative and spectator add the fourteen pixels the experience bar
// and the food row would have taken.
let draws = highlight.draw(320, 240, false, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
// No timer, no name.
highlight.timer = 0;
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
}
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
/// reach.
#[test]
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
let mut titles = Titles::default();
titles.set_overlay(Text::literal("Action bar"), false);
let f = font();
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
let translate = draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(_, y) => Some(*y),
_ => None,
}),
_ => None,
});
assert_eq!(translate, Some((240 - 68) as f32));
// The text is drawn four pixels below that origin, as vanilla's is.
let relative = draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
});
assert_eq!(relative, Some(-4));
}
}
+3
View File
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod block_sprites;
pub mod block_states;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
@@ -27,6 +28,7 @@ pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod client_resources;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -97,6 +99,7 @@ pub mod settings;
pub mod settings_shell;
pub mod shader_options;
pub mod shader_pack_list;
pub mod shader_ui;
pub mod signs;
pub mod skins;
pub mod sound_engine;
+222 -28
View File
@@ -52,6 +52,8 @@ pub struct LightTables {
/// what `light_chunk` stores per cell, so a cell costs 2 bytes and the
/// flood fill never reads the (large) `cells` table.
info: Vec<u32>,
/// `info`'s low 16 bits.
packed: Vec<u16>,
/// `blocked(type_a, type_b, direction)` as a bit per direction.
block_masks: Vec<u8>,
}
@@ -73,6 +75,7 @@ impl LightTables {
faces: 2,
types: 1,
info: Vec::new(),
packed: Vec::new(),
block_masks: Vec::new(),
};
tables.rebuild_cache();
@@ -101,6 +104,9 @@ impl LightTables {
types <= 4096,
"light tables: {types} cell configurations exceed the 12-bit type field"
);
// The region `light_chunk` floods over is filled with 0, and every air
// cell of a section writes `info` of state 0 over it (`states_of`).
debug_assert_eq!(info[0], 0, "state 0 (air) must hold no light info");
let mut block_masks = vec![0u8; types * types];
for a in 0..types {
for b in 0..types {
@@ -121,6 +127,7 @@ impl LightTables {
}
}
self.types = types;
self.packed = info.iter().map(|&i| i as u16).collect();
self.info = info;
self.block_masks = block_masks;
}
@@ -212,6 +219,7 @@ impl LightTables {
faces,
types: 0,
info: Vec::new(),
packed: Vec::new(),
block_masks: Vec::new(),
};
tables.rebuild_cache();
@@ -276,7 +284,8 @@ struct LightScratch {
sky_queue: Vec<u32>,
present: Vec<bool>,
lowest: Vec<i32>,
ids: Box<[u32; 4096]>,
/// What `states_of` keeps between sections.
section: SectionScratch,
}
impl LightScratch {
@@ -289,7 +298,7 @@ impl LightScratch {
sky_queue: Vec::new(),
present: vec![false; W * W],
lowest: vec![0; W * W],
ids: Box::new([0u32; 4096]),
section: SectionScratch::new(),
}
}
@@ -307,10 +316,132 @@ impl LightScratch {
}
}
/// One section's values mapped to the packed light info, and the palette's
/// info (`states_of`, kept between sections).
struct SectionScratch {
stage: Box<[u16; 4096]>,
stage32: Box<[u32; 4096]>,
info: Vec<u32>,
packed: Vec<u16>,
}
impl SectionScratch {
fn new() -> Self {
Self {
stage: Box::new([0u16; 4096]),
stage32: Box::new([0u32; 4096]),
info: Vec::new(),
packed: Vec::new(),
}
}
}
thread_local! {
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
}
/// Where a section sits in the 48-wide region.
#[derive(Clone, Copy)]
struct At {
x: usize,
y: usize,
z: usize,
}
impl At {
/// The region index of the section's row `row` (`ly << 4 | lz`), the
/// start of a run of 16 cells along x.
#[inline]
fn row(self, row: usize) -> usize {
(self.y + (row >> 4)) * (W * W) + (self.z + (row & 15)) * W + self.x
}
}
/// Copy one section's block states into the region's `states` as the packed
/// light info `light_chunk` floods over, and seed `queue` (and `block`) with
/// the cells that emit light. The values are mapped to that info in one pass
/// over the section's packed storage into `stage` (index order), then copied a
/// row of 16 along x at a time: the region's rows are 48 apart, so the two
/// cannot be one.
fn states_of(
section: &Section,
tables: &LightTables,
at: At,
states: &mut [u16],
block: &mut [u8],
queue: &mut Vec<u32>,
scratch: &mut SectionScratch,
) {
if section.blocks.bits() == 0 {
// A section of one state (the caller has skipped the air ones): a row
// of the region is that state's info.
let info = tables.info[section.blocks.get(0) as usize];
for ly in 0..16 {
let mut p = at.row(ly << 4);
for _ in 0..16 {
states[p..p + 16].fill(info as u16);
if info > 0xFFFF {
for x in 0..16 {
block[p + x] = (info >> 16) as u8;
queue.push((p + x) as u32);
}
}
p += W;
}
}
return;
}
// The info of the ids the section stores: a list palette's ids are mapped
// once, a global palette's are state ids, which `tables.info` holds. Only
// a section whose palette emits light (an info's high bits) needs the
// emission of each cell.
let (info, packed, emits) = match section.blocks.palette() {
Some(list) => {
scratch.info.clear();
scratch
.info
.extend(list.iter().map(|&id| tables.info[id as usize]));
scratch.packed.clear();
scratch
.packed
.extend(scratch.info.iter().map(|&i| i as u16));
let emits = scratch.info.iter().any(|&i| i > 0xFFFF);
(&scratch.info[..], &scratch.packed[..], emits)
}
None => (&tables.info[..], &tables.packed[..], true),
};
if emits {
section
.blocks
.unpack_ids_mapped(&mut scratch.stage32[..], info);
for ly in 0..16 {
let mut p = at.row(ly << 4);
for lz in 0..16 {
let i = (ly << 8) | (lz << 4);
for x in 0..16 {
let info = scratch.stage32[i + x];
states[p + x] = info as u16;
if info > 0xFFFF {
block[p + x] = (info >> 16) as u8;
queue.push((p + x) as u32);
}
}
p += W;
}
}
return;
}
section
.blocks
.unpack_ids_mapped(&mut scratch.stage[..], packed);
for ly in 0..16 {
let mut p = at.row(ly << 4);
let (rows, _) = scratch.stage[(ly << 8)..(ly << 8) + 256].as_chunks::<16>();
for row in rows {
states[p..p + 16].copy_from_slice(row);
p += W;
}
}
}
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
/// `sky` is false in dimensions without skylight (the Nether).
pub fn light_chunk(
@@ -356,7 +487,7 @@ fn light_chunk_with_scratch(
let block = &mut scratch.block[..total];
let queue = &mut scratch.queue;
let sky_queue = &mut scratch.sky_queue;
let ids = &mut scratch.ids;
let section_scratch = &mut scratch.section;
for (dz, row) in region.iter().enumerate() {
let z_base = dz * 16;
@@ -367,31 +498,12 @@ fn light_chunk_with_scratch(
if section.is_uniform(0) {
continue;
}
section.unpack(ids);
let y_base = si * 16;
for ly in 0..16 {
let y = y_base + ly;
let y_offset = y * (W * W);
for lz in 0..16 {
let z = z_base + lz;
let p_row = y_offset + z * W + x_base;
let i_row = (ly << 8) | (lz << 4);
for lx in 0..16 {
let id = ids[i_row + lx];
if id == 0 {
continue;
}
let p = p_row + lx;
let info = tables.info[id as usize];
states[p] = info as u16;
let emission = info >> 16;
if emission > 0 {
block[p] = emission as u8;
queue.push(p as u32);
}
}
}
}
let at = At {
x: x_base,
y: si * 16,
z: z_base,
};
states_of(section, tables, at, states, block, queue, section_scratch);
}
}
}
@@ -2195,6 +2307,88 @@ mod tests {
pack_lighting(sections, h, &sky, &block, sky_light)
}
/// 600 states of every dampening, the last 300 of them light sources (a
/// section's palette must hold emitting values too, which take
/// `states_of`'s other pass).
fn info_tables() -> LightTables {
let cells = (0..600u32)
.map(|id| Cell {
dampening: (id % 16) as u8,
emission: if id >= 300 { 14 } else { 0 },
shaped: false,
faces: [EMPTY; 6],
})
.collect();
LightTables::from_parts(cells, vec![false, true, true, true], 2)
}
/// `states_of` writes what walking the section's `unpack`ed state ids
/// writes, for every palette the collector can hold: one value, a list of
/// up to 256 (four to eight bits wide) and the global palette beyond it,
/// with and without a light source in the palette.
#[test]
fn section_states_match_the_unpacked_ids() {
let tables = info_tables();
let at = At { x: 0, y: 0, z: 0 };
let mut ids = Box::new([0u32; 4096]);
// (distinct values, the bits the collector stores them in)
let cases = [
(1, 0),
(3, 4),
(20, 5),
(40, 6),
(80, 7),
(200, 8),
(300, 16),
];
for (distinct, bits) in cases {
for emitting in [false, true] {
let base = if emitting { 300 } else { 0 };
let values: Vec<u32> = (0..4096)
// A pattern that repeats each value the section holds.
.map(|i| base + ((i * 7 + i / 16) % distinct) as u32)
.collect();
let section = Section {
blocks: Section::from_blocks(values.as_slice().try_into().unwrap()),
..Default::default()
};
assert_eq!(
section.blocks.bits(),
bits,
"{distinct} values should be stored in {bits} bits"
);
let mut states = vec![0u16; W * W * 16];
let mut block = vec![0u8; W * W * 16];
let mut queue = Vec::new();
states_of(
&section,
&tables,
at,
&mut states,
&mut block,
&mut queue,
&mut SectionScratch::new(),
);
section.unpack(&mut ids);
let mut want_queue = Vec::new();
for (i, &id) in ids.iter().enumerate() {
let info = tables.info[id as usize];
let p = at.row(i >> 4) + (i & 15);
assert_eq!(
states[p], info as u16,
"{distinct} values, emitting {emitting}: state at {i}"
);
if info > 0xFFFF {
assert_eq!(block[p], (info >> 16) as u8, "emission at {i}");
want_queue.push(p as u32);
}
}
assert_eq!(queue, want_queue, "{distinct} values, emitting {emitting}");
}
}
}
#[test]
fn light_chunk_matches_old_algorithm() {
let t = tables();
+37 -1
View File
@@ -274,6 +274,8 @@ enum Out {
date: NaiveDate,
lines: Vec<String>,
stderr: Option<String>,
/// The message's level, thread and text, for the [`set_line_hook`] callback.
record: (log::Level, String, String),
},
/// Answer once everything before is written.
Flush(mpsc::SyncSender<()>),
@@ -293,12 +295,14 @@ struct Writer {
impl Writer {
fn run(mut self, out: mpsc::Receiver<Out>) {
let stderr = io::stderr();
let mut hook_alive = true;
for msg in out {
match msg {
Out::Write {
date,
lines,
stderr: message,
record: (level, thread, text),
} => {
if let Some(message) = message {
let _ = writeln!(stderr.lock(), "{message}");
@@ -319,6 +323,12 @@ impl Writer {
text.push('\n');
let _ = latest.file.write_all(text.as_bytes());
}
// After the file has it. A callback that panics is not called again: the
// panic's own log line would reach it next.
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
hook_alive = std::panic::catch_unwind(call).is_ok();
}
}
Out::Flush(done) => {
if let Some(latest) = &mut self.file {
@@ -395,6 +405,18 @@ impl Floods {
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
/// lines as `latest.log` got them.
type LineHook = fn(&str, &str, &str, &str);
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
/// the first.
pub fn set_line_hook(hook: LineHook) {
let _ = LINE_HOOK.set(hook);
}
fn ours(target: &str) -> bool {
target.starts_with("horizon")
}
@@ -490,6 +512,7 @@ impl Logger {
date: now.date_naive(),
lines,
stderr,
record: (level, thread.to_owned(), message.to_owned()),
};
if let Err(mpsc::SendError(Out::Write {
stderr: Some(message),
@@ -502,12 +525,25 @@ impl Logger {
}
}
/// The `latest.log` this run writes, once `init` opened it.
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
/// terminal only.
pub fn latest_path() -> Option<PathBuf> {
LATEST_PATH.get().cloned()
}
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
/// still printed when the file cannot be opened.
pub fn init(home: &Path, main_thread: &'static str) {
let dir = logs_dir(home);
let file = match open_latest(&dir) {
Ok(Some(latest)) => Some(latest),
Ok(Some(latest)) => {
let path = dir.join("latest.log");
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
Some(latest)
}
Ok(None) => {
eprintln!(
"Horizon: another game is writing {}; this one logs to the terminal only",
+76 -20
View File
@@ -401,6 +401,7 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
#[cfg(test)]
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
@@ -977,10 +978,11 @@ impl Padded {
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
} else {
own.fill(0);
}
for y in -B..16 + B {
let wy = base_y + y;
@@ -1065,6 +1067,18 @@ fn fill_run(
},
}
}
// Direction order is the same as Direction::ALL; all values are evaluated at compile time.
const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
direction_info(Direction::Down),
direction_info(Direction::Up),
direction_info(Direction::North),
direction_info(Direction::South),
direction_info(Direction::West),
direction_info(Direction::East),
];
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -1080,21 +1094,63 @@ pub struct MeshScratch {
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
for row in pad.blocks.as_chunks::<P>().0 {
let mut bits = 0;
for (x, &id) in row.iter().enumerate() {
bits |= u32::from(solid[id as usize]) << x;
}
rows.push(bits);
}
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
/// Open and fully surrounded cells in each inner row.
fn row_masks(rows: &[u32]) -> ([u16; 256], [u16; 256]) {
let mut open = [0; 256];
let mut hidden = [0; 256];
for y in 0..16 {
for z in 0..16 {
let i = (y + B as usize) * P + z + B as usize;
let r = rows[i];
let around =
r & r << 1 & r >> 1 & rows[i - P] & rows[i + P] & rows[i - 1] & rows[i + 1];
open[y * 16 + z] = !(r >> B) as u16;
hidden[y * 16 + z] = (around >> B) as u16;
}
}
(open, hidden)
}
#[cfg(test)]
#[test]
fn row_masks_match_cell_definition() {
let mut seed = 42u32;
for _ in 0..100 {
let rows: Vec<u32> = (0..P * P)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed & ((1 << P) - 1)
})
.collect();
let (open, hidden) = row_masks(&rows);
for y in 0..16 {
for z in 0..16 {
let cell = |x: usize, y: usize, z: usize| (rows[y * P + z] >> x) & 1 != 0;
for x in 0..16 {
let (x, py, pz) = (x + B as usize, y + B as usize, z + B as usize);
let occupied = cell(x, py, pz);
let enclosed = occupied
&& cell(x - 1, py, pz)
&& cell(x + 1, py, pz)
&& cell(x, py - 1, pz)
&& cell(x, py + 1, pz)
&& cell(x, py, pz - 1)
&& cell(x, py, pz + 1);
assert_eq!((open[y * 16 + z] >> (x - B as usize)) & 1 != 0, !occupied);
assert_eq!((hidden[y * 16 + z] >> (x - B as usize)) & 1 != 0, enclosed);
}
}
}
}
}
#[cfg(test)]
mod leaf_culling_tests;
@@ -1463,7 +1519,8 @@ impl Mesher<'_> {
let pad = &scratch.padded;
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let (open, hidden) = row_masks(opaque);
out.visibility = visibility_rows(&open);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1497,7 +1554,7 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if hidden(opaque, x, y, z) {
if hidden[(y * 16 + z) as usize] >> x & 1 != 0 {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1538,12 +1595,11 @@ impl Mesher<'_> {
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
@@ -229,7 +229,7 @@ pub(super) fn tesselate(
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = d.normal();
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
@@ -238,12 +238,11 @@ pub(super) fn tesselate(
let render_down =
should_render(Direction::Down) && !occluded_by(Direction::Down, MAX_HEIGHT, down);
let sides = [
Direction::North,
Direction::South,
Direction::West,
Direction::East,
]
.map(should_render);
should_render(Direction::North),
should_render(Direction::South),
should_render(Direction::West),
should_render(Direction::East),
];
if !render_up && !render_down && !sides.iter().any(|&s| s) {
return;
}
@@ -356,9 +355,9 @@ pub(super) fn tesselate(
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
target.extend([vertex(v[0]), vertex(v[1]), vertex(v[2]), vertex(v[3])]);
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
target.extend([vertex(v[0]), vertex(v[3]), vertex(v[2]), vertex(v[1])]);
}
};
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
@@ -23,6 +23,7 @@ use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
sync::OnceLock,
};
pub struct BakedFace {
@@ -51,8 +52,11 @@ type VariantKey = (String, [u8; 3], bool);
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
/// All baked alternatives per definition, invalidated before mutable model access.
bounds: OnceLock<Vec<Option<[[f32; 3]; 2]>>>,
/// By state id: its definition in `choices` and the models it selects.
states: Vec<Option<(usize, SelectedModels)>>,
single_models: Vec<Option<u32>>,
/// Every transformed model once, with the variant it was baked from.
baked: Vec<(Variant, BakedModel)>,
/// Standalone models by id: their index in `baked`.
@@ -354,10 +358,19 @@ impl StateModels {
baked.push((variant, model));
}
// Every variant of every definition was baked above.
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
let choices: Vec<_> = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
let single_models = states
.iter()
.map(|state| {
let (choice, selected) = state.as_ref()?;
choices[*choice].single_variant(selected).copied()
})
.collect();
Ok(Self {
choices,
bounds: OnceLock::new(),
states,
single_models,
baked,
standalone: BTreeMap::new(),
})
@@ -408,6 +421,7 @@ impl StateModels {
}
/// Every baked model with the variant it was baked from, to change its faces.
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
self.bounds.take();
self.baked
.iter_mut()
.map(|(variant, model)| (&*variant, model))
@@ -432,6 +446,37 @@ impl StateModels {
let model = self.choices[*choice].particle_variant(selected)?;
self.baked[*model as usize].1.particle
}
/// Envelope of every weighted/multipart alternative in the state's definition.
/// Includes transformed resource-pack faces outside the unit cube. Empty or
/// non-finite geometry has no usable envelope and keeps the original draw path.
pub fn render_bounds(&self, state: BlockStateId) -> Option<[[f32; 3]; 2]> {
let (definition, _) = self.state(state)?;
let bounds = self.bounds.get_or_init(|| {
self.choices
.iter()
.map(|choices| {
let mut lo = glam::Vec3::splat(f32::INFINITY);
let mut hi = glam::Vec3::splat(f32::NEG_INFINITY);
let mut valid = true;
// `map` visits every choice, without consuming the render RNG.
choices.map(|&index| {
for face in &self.baked[index as usize].1.faces {
for p in face.quad.positions {
let p = glam::Vec3::from_array(p);
valid &= p.is_finite();
lo = lo.min(p);
hi = hi.max(p);
}
}
});
(valid && lo.is_finite() && hi.is_finite())
.then_some([lo.to_array(), hi.to_array()])
})
.collect()
});
bounds.get(*definition).copied().flatten()
}
pub fn collect_seeded(
&self,
state: BlockStateId,
@@ -452,6 +497,10 @@ impl StateModels {
out: &mut Vec<&'a BakedModel>,
) -> Result<()> {
out.clear();
if let Some(Some(model)) = self.single_models.get(state.0 as usize) {
out.push(&self.baked[*model as usize].1);
return Ok(());
}
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
}
fn each_seeded<'a>(
@@ -619,3 +668,7 @@ mod tests {
Ok(())
}
}
#[cfg(test)]
#[path = "catalog/bounds_tests.rs"]
mod bounds_tests;
@@ -0,0 +1,115 @@
use super::*;
fn catalog(definition: &str) -> StateModels {
let definition = BlockStateDefinition::from_json(definition).unwrap();
let choices = CompiledChoices::new(&definition)
.unwrap()
.map(|v| if v.model == "horizon:a" { 0 } else { 1 });
let baked = [
([-16, 0, 0], [16, 16, 16], [0, 0, 0]),
([0, -8, -16], [32, 32, 16], [0, 90, 0]),
]
.into_iter()
.enumerate()
.map(|(i, (from, to, turns))| {
let faces: serde_json::Map<String, Value> =
["up", "down", "north", "south", "east", "west"]
.into_iter()
.map(|name| {
(
name.to_owned(),
serde_json::json!({"texture":"#all"}),
)
})
.collect();
let json =
serde_json::json!({"textures":{"all":"horizon:block/test"},"elements":[{"from":from,"to":to,"faces":faces}]}).to_string();
let model = horizon_assets::model::CuboidModel::from_json(&json).unwrap();
let name = format!("horizon:{}", if i == 0 { "a" } else { "b" });
let library =
horizon_assets::resolve::ModelLibrary::new(BTreeMap::from([(name.clone(), model)]))
.unwrap();
let baked = bake_resolved(
Path::new("/nonexistent-horizon-bounds-fixture"),
&mut Sprites::default(),
&library.resolve(&name).unwrap(),
turns,
false,
)
.unwrap();
(
Variant {
model: name,
rotations: turns,
uv_lock: false,
},
baked,
)
})
.collect();
StateModels {
choices: vec![choices],
states: vec![Some((0, SelectedModels::Variant(0)))],
baked,
..StateModels::default()
}
}
#[test]
fn envelopes_cover_weighted_and_multipart_geometry_and_mutation_invalidates_them() {
for (definition, selected) in [
(
r#"{"variants":{"": [{"model":"horizon:a","weight":1},{"model":"horizon:b","weight":3}]}}"#,
SelectedModels::Variant(0),
),
(
r#"{"multipart":[{"apply":{"model":"horizon:a"}},{"apply":{"model":"horizon:b"}}]}"#,
SelectedModels::Multipart(vec![0, 1]),
),
] {
let mut models = catalog(definition);
models.states[0] = Some((0, selected));
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
assert!(
lo[0] <= -1.0 && hi[1] >= 2.0,
"pack geometry exceeds a unit cube"
);
let mut variants = BTreeSet::new();
for i in 0..128 {
let seed = position_seed(BlockPos::new(i * 13, i % 31, -i * 23));
for (variant, model) in models.collect_seeded(BlockStateId(0), seed).unwrap() {
variants.insert(variant.model.clone());
for point in model.faces.iter().flat_map(|f| f.quad.positions) {
assert!((0..3).all(|axis| point[axis] >= lo[axis] && point[axis] <= hi[axis]));
}
}
}
assert_eq!(variants.len(), 2);
models.models_mut().next().unwrap().1.faces[0]
.quad
.positions[0] = [100.0, 2.0, -5.0];
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
assert_eq!(hi[0], 100.0);
assert_eq!(lo[2], -5.0);
// A resource replacement owns a fresh catalog rather than the previous cache.
let replacement = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
assert_eq!(
replacement.render_bounds(BlockStateId(0)),
Some([[-1.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
);
models.models_mut().next().unwrap().1.faces[0]
.quad
.positions[0][0] = f32::NAN;
assert!(models.render_bounds(BlockStateId(0)).is_none());
}
assert!(
StateModels::default()
.render_bounds(BlockStateId(0))
.is_none()
);
let mut empty = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
for (_, model) in empty.models_mut() {
model.faces.clear();
}
assert!(empty.render_bounds(BlockStateId(0)).is_none());
}
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -338,11 +338,21 @@ impl Settings {
.map_or(-1, |i| i as i32)
}
pub fn load(path: &Path) -> Self {
let horizon = path
let mut horizon = path
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
if let Some(dir) = path.parent() {
let (key, _) = crate::shader_pack_list::resolve(
&crate::logging::shared_dir(dir).join(crate::shader_pack_list::DIR),
&horizon.shader_pack,
);
if key != horizon.shader_pack {
horizon.shader_pack = key;
horizon.save(dir);
}
}
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -170,6 +170,11 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a shader pack's screens.
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -292,14 +297,15 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`.
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
lines: &[Line<P, S>],
scroll: f32,
page_name: impl Fn(P) -> &'static str,
page_name: impl Fn(P) -> String,
mut row: impl FnMut(&mut HudBatch, S, f32),
) {
let start = batch.vertices.len();
@@ -325,10 +331,11 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some((name, _)) => (name, style::MUTED, 3.0),
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
};
let name = Text::literal(lang::get(name));
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
continue;
};
@@ -191,49 +191,6 @@ impl ShaderOptions {
.map(|c| Text::legacy(&c.replace("\\n", "\n")))
}
/// `option_label` within `width` as `measure` sizes it: the name shortened (`Bloom Up...: 2`)
/// when the whole doesn't fit, and whether it was.
pub fn option_label_within(
&self,
name: &str,
width: f32,
measure: impl Fn(&Text) -> f32,
) -> (Text, bool) {
let full = self.option_label(name);
if measure(&full) <= width {
return (full, false);
}
// The pack's text, cut a character at a time with the `§` codes it holds kept.
let key = format!("option.{name}");
let caption = self.lang.get(&key).unwrap_or(name);
let value = self.value_text(name);
let mut chars: Vec<char> = caption.chars().collect();
loop {
while chars.len() >= 2 && chars[chars.len() - 2] == '§' {
chars.truncate(chars.len() - 2);
}
if chars.pop().is_none() {
return (full, true);
}
// Without the spaces and codes it ends with.
let mut end = chars.len();
loop {
if end >= 2 && chars[end - 2] == '§' {
end -= 2;
} else if end > 0 && chars[end - 1].is_whitespace() {
end -= 1;
} else {
break;
}
}
let cut: String = chars[..end].iter().collect();
let label = Text::option_name_value(Text::legacy(&format!("{cut}...")), value.clone());
if measure(&label) <= width {
return (label, true);
}
}
}
/// `Name: value`, in the pack's words, the value yellow when it is not the default.
pub fn option_label(&self, name: &str) -> Text {
Text::option_name_value(
@@ -242,8 +199,13 @@ impl ShaderOptions {
)
}
/// Option `name`'s caption in the pack's words.
pub fn option_name(&self, name: &str) -> Text {
self.named(&format!("option.{name}"), name)
}
/// The value of option `name` as the pack names it, yellow when it is not the default.
fn value_text(&self, name: &str) -> Text {
pub fn value_text(&self, name: &str) -> Text {
let Some(option) = self.options.get(name) else {
return Text::literal(String::new());
};
@@ -350,7 +312,7 @@ mod tests {
}
#[test]
fn single_resets_comments_and_fitted_labels() {
fn single_resets_and_comments() {
let mut o = options();
o.lang = Lang::parse(
"option.LEVEL=Level of detail\noption.LEVEL.comment=How much detail.\\nMore costs more.\n",
@@ -364,18 +326,6 @@ mod tests {
Some("How much detail.\nMore costs more.")
);
assert!(o.comment("option.AO").is_none());
// One unit a character.
let measure = |t: &Text| t.plain().chars().count() as f32;
assert!(!o.option_label_within("LEVEL", 40.0, measure).1);
let (fitted, cut) = o.option_label_within("LEVEL", 12.0, measure);
assert!(cut);
assert_eq!(fitted.plain(), "Level...: 2");
o.lang = Lang::parse("option.LEVEL=§aLevel §lof detail§r\n");
let (fitted, _) = o.option_label_within("LEVEL", 12.0, measure);
assert_eq!(fitted.plain(), "Level...: 2");
assert_eq!(fitted.spans[0].style, Text::legacy("§aL").spans[0].style);
let (fitted, _) = o.option_label_within("LEVEL", 14.0, measure);
assert_eq!(fitted.plain(), "Level of...: 2");
}
#[test]
@@ -18,8 +18,10 @@ pub enum Kind {
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
/// The folder or file name in `shaderpacks`.
pub file: String,
/// Native manifest identity (`horizon:<id>`), or a classic filename.
pub key: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
@@ -33,7 +35,7 @@ pub struct Entry {
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
let path = values_pack(&dir.join(&self.file), &self.key);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
@@ -106,7 +108,22 @@ pub fn scan(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
let mut keys = std::collections::HashSet::new();
let mut packs: Vec<Entry> = paths
.iter()
.filter_map(|p| entry(p))
.filter(|e| keys.insert(e.key.clone()))
.collect();
for pack in &mut packs {
if let Some(id) = pack.key.strip_prefix("horizon:") {
pack.values = load_values(
&native_values_pack(&dir.join(&pack.file), id),
&pack.options,
);
}
}
packs.sort_by(|a, b| {
(a.kind == Kind::Classic, a.title.to_lowercase())
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
@@ -122,26 +139,18 @@ fn entry(path: &Path) -> Option<Entry> {
if horizon_shader_api::package::is_pack(path) {
// Its manifest's name; a pack that can't be read is still listed, under its file
// name: picking it says why in the chat.
let manifest = if path.is_dir() {
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
.ok()
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
} else {
horizon_shader_api::Pack::read(path)
.ok()
.map(|p| p.manifest)
};
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
let manifest = horizon_shader_api::Pack::read_manifest(path).ok();
let (key, title, options, pages) = manifest.map_or_else(
|| (file.clone(), file.clone(), Vec::new(), Vec::new()),
|m| (format!("horizon:{}", m.id), m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
key,
file,
title,
kind: Kind::Horizon,
options,
values,
values: Vec::new(),
pages,
});
}
@@ -151,6 +160,7 @@ fn entry(path: &Path) -> Option<Entry> {
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
(zip || path.join("shaders").is_dir()).then(|| Entry {
title: file.clone(),
key: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
@@ -159,6 +169,104 @@ fn entry(path: &Path) -> Option<Entry> {
})
}
/// Resolves a saved identity, migrating a legacy native filename when its manifest or the
/// launcher's `<id>-<version>.hshader` name identifies it. Duplicate IDs use the first filename
/// in lexical order, matching `scan`; versions are free-form strings, not ordered versions.
/// An absent arbitrary old filename cannot be attributed to a manifest safely.
pub fn resolve(dir: &Path, picked: &str) -> (String, PathBuf) {
if picked.is_empty() {
return (String::new(), dir.join(picked));
}
let direct = dir.join(picked);
if direct.exists() && !horizon_shader_api::package::is_pack(&direct) {
return (picked.to_owned(), direct);
}
let mut native: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
let file = path.file_name()?.to_str()?.to_owned();
if file.starts_with('.') || !horizon_shader_api::package::is_pack(&path) {
return None;
}
Some((file, horizon_shader_api::Pack::read_manifest(&path).ok()?))
})
.collect();
native.sort_by(|a, b| a.0.cmp(&b.0));
let id = native
.iter()
.find(|(file, _)| file == picked)
.map(|(_, m)| m.id.as_str())
.or_else(|| picked.strip_prefix("horizon:"))
.or_else(|| {
// Never reinterpret an installed classic pack as a native identity.
if dir.join(picked).exists() {
return None;
}
let stem = picked.strip_suffix(".hshader")?;
native
.iter()
.find(|(_, m)| {
stem.strip_prefix(&m.id)
.is_some_and(|v| v.starts_with('-') && v.len() > 1)
})
.map(|(_, m)| m.id.as_str())
});
if let Some((file, m)) = native.iter().find(|(_, m)| Some(m.id.as_str()) == id) {
let key = format!("horizon:{}", m.id);
if !picked.starts_with("horizon:") {
migrate_values(&dir.join(picked), &key);
}
migrate_values(&dir.join(file), &key);
return (key, dir.join(file));
}
(picked.to_owned(), dir.join(picked))
}
fn values_pack(pack: &Path, key: &str) -> PathBuf {
key.strip_prefix("horizon:").map_or_else(
|| pack.to_path_buf(),
|id| {
pack.parent()
.unwrap_or_else(|| Path::new("."))
.join(".horizon-options")
.join(id)
},
)
}
/// The native options path; imports a filename sidecar once, keeping the original intact.
pub fn native_values_pack(pack: &Path, id: &str) -> PathBuf {
let key = format!("horizon:{id}");
migrate_values(pack, &key);
values_pack(pack, &key)
}
fn migrate_values(pack: &Path, key: &str) {
let target = values_path(&values_pack(pack, key));
if target.exists() {
return;
}
if let Ok(text) = std::fs::read(values_path(pack)) {
let result = std::fs::create_dir_all(target.parent().unwrap()).and_then(|()| {
use std::io::Write;
// Another reader may already have imported the values.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)?;
file.write_all(&text)
});
if let Err(e) = result
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
log::warn!("shader pack options migration: {e}");
}
}
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
@@ -186,8 +294,7 @@ pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
/// Saves non-default values. An empty file records a reset, so old sidecars stay retired.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
@@ -196,14 +303,10 @@ fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, text)
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
@@ -315,6 +418,102 @@ pub fn show_value(kind: &OptionKind, v: f64) -> String {
mod tests {
use super::*;
fn install(dir: &Path, file: &str, version: &str) {
let manifest = format!(
"schema = 1\nid = \"warm\"\nname = \"Warm\"\nversion = \"{version}\"\napi = \"0.1\"\n\
[[post]]\nfile = \"p.wgsl\"\n[[options]]\nid = \"strength\"\nname = \"Strength\"\ntype = \"float\"\ndefault = 1.0\nmin = 0.0\nmax = 2.0\nstep = 0.5\n"
);
let files = [
(horizon_shader_api::MANIFEST.into(), manifest.into_bytes()),
("p.wgsl".into(), b"original test fixture".to_vec()),
]
.into();
let pack = horizon_shader_api::Pack::from_files(files).unwrap();
std::fs::write(dir.join(file), pack.package().unwrap()).unwrap();
}
#[test]
fn native_selection_and_values_survive_install_update_rename_and_reset() {
let dir =
std::env::temp_dir().join(format!("horizon-identity-update-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "warm-1.hshader", "1");
std::fs::write(dir.join("warm-1.hshader.txt"), "strength=1.5\n").unwrap();
let options = dir.join("options.txt");
std::fs::write(&options, "").unwrap();
std::fs::write(
dir.join(crate::graphics::FILE),
"pickedShaderPack:warm-1.hshader\n",
)
.unwrap();
// Use the real settings loader, including persistence of the migrated selection.
// Its shaderpacks folder sits beside options.txt.
let packs = dir.join(DIR);
std::fs::create_dir_all(&packs).unwrap();
std::fs::rename(dir.join("warm-1.hshader"), packs.join("warm-1.hshader")).unwrap();
std::fs::rename(
dir.join("warm-1.hshader.txt"),
packs.join("warm-1.hshader.txt"),
)
.unwrap();
let settings = crate::settings::Settings::load(&options);
assert_eq!(settings.horizon.shader_pack, "horizon:warm");
assert_eq!(
crate::graphics::Graphics::load(&dir).shader_pack,
"horizon:warm"
);
assert_eq!(scan(&packs)[0].values, [1.5]);
install(&packs, "warm-2.hshader", "2");
std::fs::remove_file(packs.join("warm-1.hshader")).unwrap();
let (_, path) = resolve(&packs, &settings.horizon.shader_pack);
assert_eq!(path, packs.join("warm-2.hshader"));
std::fs::rename(path, packs.join("renamed.hshader")).unwrap();
let (_, path) = resolve(&packs, "horizon:warm");
assert_eq!(path, packs.join("renamed.hshader"));
let manifest = horizon_shader_api::Pack::read_manifest(&path).unwrap();
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[1.5]
);
let mut entry = scan(&packs).remove(0);
entry.values[0] = 0.5;
entry.save_values(&packs);
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[0.5]
);
entry.reset_options();
entry.save_values(&packs);
assert_eq!(scan(&packs)[0].values, [1.0]);
assert_eq!(
std::fs::read_to_string(packs.join("warm-1.hshader.txt")).unwrap(),
"strength=1.5\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn migrates_a_removed_registry_filename_and_resolves_duplicates_deterministically() {
let dir = std::env::temp_dir().join(format!(
"horizon-identity-duplicates-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "z.hshader", "newest");
install(&dir, "a.hshader", "oldest");
std::fs::write(dir.join("warm-previous.hshader.txt"), "strength=2\n").unwrap();
let (key, path) = resolve(&dir, "warm-previous.hshader");
assert_eq!(key, "horizon:warm");
assert_eq!(path, dir.join("a.hshader"));
assert_eq!(resolve(&dir, "z.hshader"), (key.clone(), path));
assert_eq!(scan(&dir).len(), 1);
assert_eq!(scan(&dir)[0].values, [2.0]);
// A classic folder named like a native ID keeps its filename identity.
std::fs::create_dir_all(dir.join("warm/shaders")).unwrap();
assert_eq!(resolve(&dir, "warm"), ("warm".into(), dir.join("warm")));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
@@ -397,7 +596,7 @@ mod tests {
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(std::fs::read_to_string(values_path(&pack)).unwrap(), "");
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -417,6 +616,7 @@ mod tests {
};
let mut entry = Entry {
file: "p".into(),
key: "horizon:p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
File diff suppressed because it is too large. Load diff
+31 -11
View File
@@ -258,19 +258,10 @@ fn root_style(component: &Tag) -> Vec<(&'static str, Tag)> {
.filter_map(|key| Some((*key, root?.get(key)?.clone())))
.collect()
}
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
/// `ComponentSerialization.CODEC`: modern NBT strings are literal text, not JSON.
fn component_text(tag: &Tag) -> String {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(serde_json::Value::String(text)) => text,
Ok(v) if v.get("text").is_some() => {
v["text"].as_str().unwrap_or_default().to_owned()
}
_ => s,
}
}
Tag::String(s) => s.to_string_lossy(),
// Component.getString: translations and key binds resolved.
Tag::Compound(_) | Tag::List(_) => {
scoreboard::comp_text(&scoreboard::tag_to_json(tag)).plain()
@@ -323,6 +314,35 @@ mod tests {
decoded.decode(&tag);
assert_eq!(decoded, data);
}
#[test]
fn modern_string_messages_keep_literal_quotes_and_json_text() {
let lines = [
"plain text",
"\"quoted text\"",
r#"{"text":"literal object","bold":true}"#,
"",
];
let mut saved = styled_sign();
let messages = Tag::List(lines.map(|line| Tag::String(line.into())).to_vec());
if let Tag::Compound(front) = saved.get_mut("front_text").unwrap() {
front.insert("messages".into(), messages.clone());
}
let mut data = SignData::default();
data.decode(&saved);
assert_eq!(data.front.lines, lines.map(str::to_owned));
let encoded = Tag::compound(data.encode());
assert_eq!(
encoded.get("front_text").unwrap().get("messages"),
Some(&messages)
);
let mut copied = SignData::default();
copied.decode(&encoded);
assert_eq!(copied.front.lines, data.front.lines);
assert_eq!(
front_messages(&Tag::compound(copied.encode())),
front_messages(&encoded)
);
}
/// A sign as a server sends it: a green "Online" and a plain line, the
/// way `ComponentSerialization.CODEC` writes them to NBT.
fn styled_sign() -> Tag {
@@ -13,7 +13,7 @@ use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
use horizon_core::random::LegacyRandom;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
sync::{Arc, OnceLock, RwLock},
};
/// `SoundSource` indices (vanilla order).
@@ -593,19 +593,25 @@ impl SoundInstance {
}
/// `AbstractSoundInstance.getVolume` (after `getOrResolve`).
pub fn get_volume(&mut self) -> f32 {
self.volume_with(self.volume)
}
fn volume_with(&mut self, volume: f32) -> f32 {
let sample = match &self.sound {
Some(s) => s.volume.sample(&mut self.random),
None => 1.0,
};
self.volume * sample
volume * sample
}
/// `AbstractSoundInstance.getPitch`.
pub fn get_pitch(&mut self) -> f32 {
self.pitch_with(self.pitch)
}
fn pitch_with(&mut self, pitch: f32) -> f32 {
let sample = match &self.sound {
Some(s) => s.pitch.sample(&mut self.random),
None => 1.0,
};
self.pitch * sample
pitch * sample
}
/// `AbstractTickableSoundInstance.stop`.
fn stop(&mut self) {
@@ -872,9 +878,36 @@ struct Handle {
channel: Option<ChannelId>,
streaming: bool,
stopped: bool,
/// Restore the unedited pitch when a later refresh no longer changes it.
pitch_edited: bool,
}
/// One operation about to apply a sound's parameters on the game thread.
/// `starting` is true for initial/delayed/loop-replay starts, false for live
/// tickable or category-volume updates. The identifier is the sound event,
/// not its selected file; source is the vanilla SoundSource index.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct SoundPlayback<'a> {
/// Engine-local handle; not unique across split-screen engines.
pub instance: InstanceId,
pub identifier: &'a str,
pub source: usize,
pub position: [f64; 3],
pub relative: bool,
pub starting: bool,
}
pub type PlaybackHook = fn(&SoundPlayback<'_>, &mut f32, &mut f32, &mut bool);
static PLAYBACK_HOOK: OnceLock<PlaybackHook> = OnceLock::new();
/// Install native playback dispatch before constructing client audio engines.
/// No audio/device thread invokes this callback.
pub fn install_playback_hook(hook: PlaybackHook) -> Result<(), PlaybackHook> {
PLAYBACK_HOOK.set(hook)
}
pub struct SoundEngine {
pub registry: SoundRegistry,
playback_hook: Option<PlaybackHook>,
/// `loaded`: the library opened.
pub loaded: bool,
tick_count: i32,
@@ -912,6 +945,7 @@ impl SoundEngine {
pub fn new(registry: SoundRegistry) -> Self {
Self {
registry,
playback_hook: PLAYBACK_HOOK.get().copied(),
loaded: true,
tick_count: 0,
instances: HashMap::new(),
@@ -934,6 +968,42 @@ impl SoundEngine {
notifications: Vec::new(),
}
}
/// Isolate a fixture's dispatch from process-wide native installation.
#[cfg(any(test, feature = "test-support"))]
pub fn set_test_playback_hook(&mut self, hook: PlaybackHook) {
self.playback_hook = Some(hook);
}
fn playback_parameters(&self, id: InstanceId, starting: bool) -> Option<(f32, f32)> {
let instance = self.instances.get(&id)?;
let (mut volume, mut pitch) = (instance.volume, instance.pitch);
if let Some(hook) = self.playback_hook {
let sound = SoundPlayback {
instance: id,
identifier: &instance.identifier,
source: instance.source,
position: instance.position,
relative: instance.relative,
starting,
};
let mut cancel = false;
hook(&sound, &mut volume, &mut pitch, &mut cancel);
let volume_edited = volume.to_bits() != instance.volume.to_bits();
let pitch_edited = pitch.to_bits() != instance.pitch.to_bits();
if cancel
|| (volume_edited && (!volume.is_finite() || volume < 0.0))
|| (pitch_edited && (!pitch.is_finite() || pitch <= 0.0))
{
return None;
}
}
Some((volume, pitch))
}
fn cancel_playback(&mut self, id: InstanceId, backend: &mut impl Backend) {
if let Some(instance) = self.instances.get_mut(&id) {
instance.stop();
}
self.stop(id, backend);
}
/// Hand an instance to the engine. A retained instance stays until
/// `release`; others go once the engine no longer refers to them.
pub fn add(&mut self, instance: SoundInstance, retain: bool) -> InstanceId {
@@ -1027,10 +1097,27 @@ impl SoundEngine {
continue;
};
if source == instance.source || source == MASTER {
let (v, s) = (instance.get_volume(), instance.source);
let original_pitch = instance.pitch;
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
self.cancel_playback(id, backend);
continue;
};
let instance = self.instances.get_mut(&id).expect("present");
let (v, s) = (instance.volume_with(base_volume), instance.source);
let pitch_edited = base_pitch.to_bits() != original_pitch.to_bits();
// Restore a prior edit, but an always-no-op hook samples exactly as before.
let restore_pitch = self.handles.get(&handle).is_some_and(|h| h.pitch_edited);
let pitch = (pitch_edited || restore_pitch)
.then(|| Self::calculate_pitch(instance.pitch_with(base_pitch)));
let volume = self.calculate_volume(v, s);
if let Some(h) = self.handles.get_mut(&handle) {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
if let Some(pitch) = pitch {
backend.set_pitch(c, pitch);
}
}
}
}
@@ -1207,8 +1294,11 @@ impl SoundEngine {
if sound.sentinel != Sentinel::None {
return PlayResult::NotStarted;
}
let Some((base_volume, base_pitch)) = self.playback_parameters(id, true) else {
return PlayResult::NotStarted;
};
let instance = self.instances.get_mut(&id).expect("present");
let instance_volume = instance.get_volume();
let instance_volume = instance.volume_with(base_volume);
let attenuation_distance = sound.attenuation_distance(instance_volume);
if self.listening {
self.notify(id, subtitle, attenuation_distance);
@@ -1252,6 +1342,7 @@ impl SoundEngine {
channel: Some(channel),
streaming,
stopped: false,
pitch_edited: base_pitch.to_bits() != self.instances[&id].pitch.to_bits(),
},
);
self.delete_time.insert(id, self.tick_count + 20);
@@ -1264,7 +1355,7 @@ impl SoundEngine {
self.instance_by_source.push((source, id));
}
let instance = self.instances.get_mut(&id).expect("present");
let pitch = Self::calculate_pitch(instance.get_pitch());
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
let (attenuation, relative, tickable) =
(instance.attenuation, instance.relative, instance.tickable());
backend.set_pitch(channel, pitch);
@@ -1350,13 +1441,22 @@ impl SoundEngine {
self.stop(id, backend);
continue;
}
let (v, source) = (instance.get_volume(), instance.source);
let pitch = Self::calculate_pitch(instance.get_pitch());
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
self.cancel_playback(id, backend);
continue;
};
let instance = self.instances.get_mut(&id).expect("present");
let pitch_edited = base_pitch.to_bits() != instance.pitch.to_bits();
let (v, source) = (instance.volume_with(base_volume), instance.source);
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
let position = instance.position;
let volume = self.calculate_volume(v, source);
let Some(handle) = self.channel_of(id) else {
continue;
};
if let Some(h) = self.handles.get_mut(&handle) {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
@@ -1370,7 +1470,7 @@ impl SoundEngine {
&& let (Some(sound), Some(subtitle)) =
(instance.sound.clone(), instance.event.clone())
{
let distance = sound.attenuation_distance(instance.get_volume());
let distance = sound.attenuation_distance(instance.volume_with(base_volume));
self.notify(id, subtitle, distance);
}
}
@@ -1995,3 +2095,7 @@ mod entity_bound_tests {
assert_ne!(sound.position, replacement.state().position);
}
}
#[cfg(test)]
#[path = "sound_engine/playback_tests.rs"]
mod playback_tests;
@@ -0,0 +1,188 @@
use super::*;
use horizon_core::random::RandomSource;
fn setup() -> (SoundEngine, test_backend::Null) {
let mut registry = SoundRegistry::default();
registry.add_file(
"test",
br#"{"beep":{"subtitle":"test.beep","sounds":[{"name":"beep","volume":0.5,"pitch":0.5}]}}"#,
&|_, _| true,
);
let mut engine = SoundEngine::new(registry);
engine.playback_hook = None;
engine.listening = true;
(engine, test_backend::Null::default())
}
fn instance() -> SoundInstance {
SoundInstance::at(
"test:beep",
4,
0.8,
1.2,
LegacyRandom::new(1),
[2.0, 3.0, 4.0],
)
}
fn adjust(sound: &SoundPlayback<'_>, volume: &mut f32, pitch: &mut f32, _: &mut bool) {
assert_eq!(sound.identifier, "test:beep");
assert_eq!(sound.source, 4);
assert!(!sound.relative);
*volume *= 0.5;
*pitch = 2.0;
}
#[test]
fn played_parameters_reach_channels_without_changing_the_instance() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let (id, result) = engine.play_new(instance(), true, &mut backend);
assert_eq!(result, PlayResult::Started);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.volume[&channel], 0.2);
assert_eq!(backend.pitch[&channel], 1.0);
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
assert_eq!(engine.instances[&id].volume, 0.8);
assert_eq!(engine.instances[&id].pitch, 1.2);
assert_eq!(engine.notifications.len(), 1);
}
#[test]
fn delayed_and_manually_looped_plays_do_not_compound_edits() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let mut sound = instance();
sound.looping = true;
sound.delay = 2;
let id = engine.add(sound, true);
engine.play_delayed(id, 2);
engine.tick(false, &TickContext::default(), &mut backend);
assert!(backend.volume.is_empty() && engine.notifications.is_empty());
engine.tick(false, &TickContext::default(), &mut backend);
let first = engine.channel(id).unwrap();
assert_eq!(backend.volume[&first], 0.2);
backend.stopped.insert(first, true);
for _ in 0..22 {
engine.tick(false, &TickContext::default(), &mut backend);
}
let replay = engine.channel(id).unwrap();
assert_ne!(first, replay);
assert_eq!(backend.volume[&replay], 0.2);
assert_eq!(engine.instances[&id].volume, 0.8);
}
#[test]
fn tickable_and_category_updates_keep_transient_edits_and_can_cancel() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let mut sound = instance();
sound.ticking = Ticking::Native;
let (id, _) = engine.play_new(sound, true, &mut backend);
let channel = engine.channel(id).unwrap();
let sound = engine.instance_mut(id).unwrap();
sound.volume = 0.4;
sound.position = [8.0, 9.0, 10.0];
engine.tick(false, &TickContext::default(), &mut backend);
assert_eq!(backend.volume[&channel], 0.1);
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
engine.update_category_volume(4, 0.5, &mut backend);
assert_eq!(backend.volume[&channel], 0.05);
assert_eq!(
backend.pitch[&channel], 0.6,
"a later no-pitch edit restores the base pitch"
);
engine.notifications.clear();
engine.set_test_playback_hook(|sound, _, _, cancel| {
assert!(!sound.starting);
*cancel = true;
});
engine.tick(false, &TickContext::default(), &mut backend);
assert!(backend.stopped(channel));
assert!(engine.notifications.is_empty());
}
#[test]
fn cancellation_and_invalid_edits_never_allocate_or_notify() {
let edits: [PlaybackHook; 6] = [
|_, _, _, cancel| *cancel = true,
|_, volume, _, _| *volume = f32::NAN,
|_, volume, _, _| *volume = f32::INFINITY,
|_, volume, _, _| *volume = -1.0,
|_, _, pitch, _| *pitch = 0.0,
|_, _, pitch, _| *pitch = f32::NEG_INFINITY,
];
for hook in edits {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(hook);
let (_, result) = engine.play_new(instance(), false, &mut backend);
assert_eq!(result, PlayResult::NotStarted);
assert!(backend.stopped.is_empty() && engine.notifications.is_empty());
}
}
#[test]
fn no_op_dispatch_preserves_random_sampling_and_failed_pool_behavior() {
let (mut plain, mut plain_backend) = setup();
let (mut hooked, mut hooked_backend) = setup();
hooked.set_test_playback_hook(|_, _, _, _| {});
for (engine, backend) in [
(&mut plain, &mut plain_backend),
(&mut hooked, &mut hooked_backend),
] {
engine.limits = [0, 0];
let id = engine.add(instance(), true);
assert_eq!(engine.play(id, backend), PlayResult::NotStarted);
engine.limits = [247, 8];
assert_eq!(engine.play(id, backend), PlayResult::Started);
}
assert_eq!(plain_backend.volume, hooked_backend.volume);
assert_eq!(plain_backend.pitch, hooked_backend.pitch);
assert_eq!(plain.notifications, hooked.notifications);
assert_eq!(
plain.instances.get_mut(&1).unwrap().random.next_long(),
hooked.instances.get_mut(&1).unwrap().random.next_long()
);
}
#[test]
fn entity_bound_updates_are_intercepted_at_the_new_position() {
let (mut engine, mut backend) = setup();
let entity = SoundEntity::new(42, [2.0, 3.0, 4.0], false);
engine.set_test_playback_hook(|sound, volume, _, _| {
if !sound.starting {
assert_eq!(sound.position, [8.0, 9.0, 10.0]);
}
*volume *= 0.5;
});
let sound = SoundInstance::entity_bound(
"test:beep",
4,
0.8,
1.0,
LegacyRandom::new(1),
entity.clone(),
);
let (id, _) = engine.play_new(sound, true, &mut backend);
entity.update(SoundEntityState {
position: [8.0, 9.0, 10.0],
silent: false,
removed: false,
});
engine.tick(false, &TickContext::default(), &mut backend);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
assert_eq!(backend.volume[&channel], 0.2);
}
#[test]
fn a_volume_only_edit_preserves_legacy_zero_pitch_clamping() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
let mut sound = instance();
sound.pitch = 0.0;
let (id, result) = engine.play_new(sound, true, &mut backend);
assert_eq!(result, PlayResult::Started);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.volume[&channel], 0.2);
assert_eq!(backend.pitch[&channel], 0.5);
}
+179 -29
View File
@@ -12,7 +12,11 @@ use crate::{
scoreboard::{Comp, NumberFormat, Objective, RenderType, Scoreboard, comp_text},
text::Text,
};
use std::{borrow::Cow, cmp::Ordering, collections::HashMap};
use std::{
borrow::Cow,
cmp::Ordering,
collections::{HashMap, HashSet},
};
/// `GameType.SPECTATOR` id.
pub const SPECTATOR: i32 = 3;
@@ -154,12 +158,14 @@ fn compare_utf16(a: &str, b: &str) -> Ordering {
/// `String.compareToIgnoreCase` (per code point: upper then lower case).
fn compare_ignore_case(a: &str, b: &str) -> Ordering {
fn simple(c: char, upper: bool) -> char {
let mut it: Box<dyn Iterator<Item = char>> = if upper {
Box::new(c.to_uppercase())
let pair = if upper {
let mut it = c.to_uppercase();
(it.next(), it.next())
} else {
Box::new(c.to_lowercase())
let mut it = c.to_lowercase();
(it.next(), it.next())
};
match (it.next(), it.next()) {
match pair {
(Some(m), None) => m,
_ => c,
}
@@ -189,23 +195,25 @@ fn compare_ignore_case(a: &str, b: &str) -> Ordering {
/// list order first, spectators last, team name, profile name ignoring
/// case), at most 80 players.
pub fn sorted_players<'a>(players: &'a [TabPlayer], board: &Scoreboard) -> Vec<&'a TabPlayer> {
let team = |p: &TabPlayer| {
board
.players_team(&p.name)
.map_or("", |t| t.name.as_str())
.to_owned()
};
let mut out: Vec<&TabPlayer> = players.iter().collect();
out.sort_by(|a, b| {
let mut out: Vec<_> = players
.iter()
.map(|p| {
(
p,
board.players_team(&p.name).map_or("", |t| t.name.as_str()),
)
})
.collect();
out.sort_by(|(a, team_a), (b, team_b)| {
a.list_order
.wrapping_neg()
.cmp(&b.list_order.wrapping_neg())
.then_with(|| (a.game_mode == SPECTATOR).cmp(&(b.game_mode == SPECTATOR)))
.then_with(|| compare_utf16(&team(a), &team(b)))
.then_with(|| compare_utf16(team_a, team_b))
.then_with(|| compare_ignore_case(&a.name, &b.name))
});
out.truncate(80);
out
out.into_iter().map(|(p, _)| p).collect()
}
/// What `setVisible(true)` has the narrator say:
@@ -347,6 +355,26 @@ pub struct TabList {
pub footer: Option<Text>,
visible: bool,
health: HashMap<u128, HealthState>,
cache: Option<LayoutCache>,
order: Option<(Vec<TabPlayer>, Scoreboard, Vec<u128>)>,
}
#[derive(Clone)]
struct LayoutCache {
players: Vec<TabPlayer>,
board: Scoreboard,
objective: Option<Objective>,
header: Option<Text>,
footer: Option<Text>,
language: Option<std::sync::Arc<crate::lang::Language>>,
dimensions: (i32, bool, u32, u64),
flags: Vec<(bool, bool)>,
draws: Vec<GuiDraw>,
hearts: Vec<(usize, i32, i32, i32, u128, i32)>,
}
impl std::fmt::Debug for LayoutCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LayoutCache").finish_non_exhaustive()
}
}
struct Entry {
name: Text,
@@ -371,10 +399,87 @@ impl TabList {
self.header = None;
self.footer = None;
}
/// `extractRenderState`.
/// Share the cached display order with skin residency selection.
pub fn displayed_ids(&mut self, players: &[TabPlayer], board: &Scoreboard) -> &[u128] {
if self.order.as_ref().is_none_or(|(old, previous, _)| {
old.len() != players.len()
|| old.iter().zip(players).any(|(a, b)| {
a.uuid != b.uuid
|| a.name != b.name
|| a.list_order != b.list_order
|| a.game_mode != b.game_mode
})
|| previous != board
}) {
let ids = sorted_players(players, board)
.iter()
.map(|p| p.uuid)
.collect();
self.order = Some((players.to_vec(), board.clone(), ids));
}
&self.order.as_ref().unwrap().2
}
/// `extractRenderState`: retain text, measurements and geometry, not animated hearts.
pub fn layout(&mut self, i: &TabInputs, font: &Font) -> Vec<GuiDraw> {
let language = crate::lang::current();
let dimensions = (i.screen_width, i.online_mode, i.background, font.epoch());
let flags: Vec<_> = i
.players
.iter()
.map(|p| ((i.upside_down)(p.uuid), (i.horizon)(p.uuid)))
.collect();
let valid = self.cache.as_ref().is_some_and(|c| {
c.players == i.players
&& c.board == *i.board
&& c.objective.as_ref() == i.objective
&& c.header == self.header
&& c.footer == self.footer
&& c.dimensions == dimensions
&& c.flags == flags
&& match (&c.language, &language) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
});
if !valid {
let mut hearts = Vec::new();
let draws = self.build_layout(i, font, &mut hearts);
self.cache = Some(LayoutCache {
players: i.players.to_vec(),
board: i.board.clone(),
objective: i.objective.cloned(),
header: self.header.clone(),
footer: self.footer.clone(),
language,
dimensions,
flags,
draws,
hearts,
});
}
let cache = self.cache.take().unwrap();
let mut out = Vec::with_capacity(cache.draws.len());
let mut start = 0;
for &(at, y, left, right, id, score) in &cache.hearts {
out.extend_from_slice(&cache.draws[start..at]);
self.hearts(y, left, right, id, score, i.gui_ticks, font, &mut out);
start = at;
}
out.extend_from_slice(&cache.draws[start..]);
self.cache = Some(cache);
out
}
fn build_layout(
&mut self,
i: &TabInputs,
font: &Font,
hearts: &mut Vec<(usize, i32, i32, i32, u128, i32)>,
) -> Vec<GuiDraw> {
let width = |t: &Text| font.width_text(t) as i32;
let players = sorted_players(i.players, i.board);
let ids = self.displayed_ids(i.players, i.board);
let by_id: HashMap<_, _> = i.players.iter().map(|p| (p.uuid, p)).collect();
let players: Vec<_> = ids.iter().map(|id| by_id[id]).collect();
let spacer = font.width(" ") as i32;
let mut max_name = 0;
let mut max_score = 0;
@@ -417,8 +522,8 @@ impl TabList {
entries.push(entry);
}
if !self.health.is_empty() {
self.health
.retain(|id, _| players.iter().any(|p| p.uuid == *id));
let displayed: HashSet<_> = players.iter().map(|p| p.uuid).collect();
self.health.retain(|id, _| displayed.contains(id));
}
let slots = players.len() as i32;
let mut rows = slots;
@@ -532,16 +637,7 @@ impl TabList {
let right = left + width_for_score;
if right - left > 5 {
if objective.render_type == RenderType::Hearts {
self.hearts(
yo,
left,
right,
p.uuid,
entry.score,
i.gui_ticks,
font,
&mut out,
);
hearts.push((out.len(), yo, left, right, p.uuid, entry.score));
} else if let Some(text) = &entry.formatted {
out.push(GuiDraw::Text {
text: text.clone(),
@@ -691,6 +787,60 @@ mod tests {
assert!(list.set_visible(true));
}
#[test]
fn cached_tab_layout_matches_rebuilt_layout_and_animated_health() {
let font = Font::metrics(std::path::Path::new("missing-tab-test-resources"));
let mut board = Scoreboard::default();
board.add_objective(
"hp",
"dummy",
serde_json::json!("hp"),
RenderType::Hearts,
false,
None,
);
board.add_team("red");
board.add_to_team("Alice", "red");
board.score_mut("Alice", "hp").value = 20;
let mut players = vec![TabPlayer::new(1, "Alice"), TabPlayer::new(2, "Bob")];
let mut cached = TabList::default();
let mut fresh = TabList::default();
cached.header = Some(Text::literal("Header"));
fresh.header = cached.header.clone();
for frame in 0..40 {
match frame {
2 => {
players[0].display_name =
Some(serde_json::json!({"text": "Changed", "obfuscated": true}))
}
3 => board.team_mut("red").unwrap().prefix = serde_json::json!("prefix"),
4 => board.score_mut("Alice", "hp").value = 9,
5 => players.push(TabPlayer::new(3, "Charlie")),
6 => {
players.pop();
}
_ => {}
}
let inputs = TabInputs {
players: &players,
board: &board,
objective: board.objective("hp"),
screen_width: if frame < 7 { 400 } else { 300 },
online_mode: true,
gui_ticks: frame,
background: 0x20ffffff,
upside_down: &|_| false,
horizon: &|_| false,
};
fresh.cache = None;
assert_eq!(
cached.layout(&inputs, &font),
fresh.layout(&inputs, &font),
"frame {frame}"
);
}
}
#[test]
fn order_ping_and_skins() {
let board = Scoreboard::default();
@@ -63,6 +63,11 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
// Whether the solid shaders carry HDR output's lighting (a pipeline constant): the
// renderer builds its solid terrain without it for the frames HDR is off, where
// `hdr_light.z` is 0, since even a branch never taken costs Apple GPUs ~15% of a
// solid pixel's time (gfx.rs's `SolidPipeline`).
override HDR_LIGHTING: bool = true;
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
@@ -620,7 +625,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -637,7 +642,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -665,7 +670,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// fully fogged mountain would stand out from the fog behind it.
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Instellings",
"horizon.mods.in_world": " (los die wêreld om te verander)",
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
"horizon.shader_packs.empty": "Geen shader packs: plaas dit in die shaderpacks-lêergids",
"horizon.shader_options.open": "Shader-opsies...",
"horizon.shader_options.profile": "Profiel",
"horizon.shader_options.loading": "Lees tans die shader-pakket se opsies...",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
"horizon.multiplayer.cancel_reconnecting": "Kanselleer Herkoppel",
@@ -475,5 +472,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Ander spelers se spore en auras bly versteek; hul ander kosmetiese items wys steeds. By verstek af.",
"horizon.extras.cosmetics.shown": "Getoon",
"horizon.extras.cosmetics.hidden": "Versteek",
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies"
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies",
"Installed": "Geïnstalleer",
"Apply": "Pas toe",
"Reset all options": "Stel alle opsies terug",
"A Horizon shader pack.": "'n Horizon-skadupakket.",
"An OptiFine or Iris shader pack.": "'n OptiFine- of Iris-skadupakket.",
"In use: press to turn shaders off.": "In gebruik: druk om skadus af te skakel.",
"Press to use it.": "Druk om dit te gebruik.",
"Put shader pack .zip files or folders in it.": "Sit skadupakket-.zip-lêers of -vouers daarin.",
"Puts every option of this pack back to its default.": "Stel elke opsie van hierdie pakket terug na sy verstek.",
"Sets many options at once, as the pack groups them.": "Stel baie opsies op een slag, soos die pakket hulle groepeer.",
"No shader packs yet: open the pack folder and put some in it.": "Nog geen skadupakkette nie: maak die pakketvouer oop en sit 'n paar daarin.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Kies 'n skadupakket. As jy die een in gebruik kies, word skadus afgeskakel.",
"Changed options apply with Apply, or when you leave.": "Gewysigde opsies word toegepas met Pas toe, of wanneer jy verlaat.",
"Pack settings": "Pakketopsies",
"Built-in settings": "Ingeboude opsies",
"%s settings": "%s-opsies",
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket."
}
@@ -77,10 +77,7 @@
"horizon.mods.settings": "الإعدادات",
"horizon.mods.in_world": " (اترك العالم ليتغير)",
"horizon.mods.open_folder": "افتح مجلد التعديل",
"horizon.shader_packs.empty": "لا توجد حزم تظليل: ضعها في مجلدshaderpacks",
"horizon.shader_options.open": "خيارات التظليل...",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.shader_options.loading": "قراءة خيارات حزمة التظليل...",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
"horizon.multiplayer.cancel_reconnecting": "إلغاء إعادة الاتصال",
@@ -494,5 +491,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "تبقى آثار وهالات اللاعبين الآخرين مخفية؛ وتظل مستحضراتهم الأخرى ظاهرة. معطّل افتراضيًا.",
"horizon.extras.cosmetics.shown": "معروض",
"horizon.extras.cosmetics.hidden": "مخفي",
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط"
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط",
"Installed": "المثبّتة",
"Apply": "تطبيق",
"Reset all options": "إعادة ضبط كل الخيارات",
"A Horizon shader pack.": "حزمة شيدر من Horizon.",
"An OptiFine or Iris shader pack.": "حزمة شيدر من OptiFine أو Iris.",
"In use: press to turn shaders off.": "قيد الاستخدام: اضغط لإيقاف الشيدرات.",
"Press to use it.": "اضغط لاستخدامها.",
"Put shader pack .zip files or folders in it.": "ضع ملفات .zip أو مجلدات حزم الشيدر فيه.",
"Puts every option of this pack back to its default.": "يعيد كل خيار في هذه الحزمة إلى قيمته الافتراضية.",
"Sets many options at once, as the pack groups them.": "يضبط عدة خيارات دفعة واحدة، كما تجمعها الحزمة.",
"No shader packs yet: open the pack folder and put some in it.": "لا توجد حزم شيدر بعد: افتح مجلد الحزم وضع بعضها فيه.",
"Pick a shader pack. Picking the one in use turns shaders off.": "اختر حزمة شيدر. اختيار الحزمة قيد الاستخدام يوقف الشيدرات.",
"Changed options apply with Apply, or when you leave.": "تُطبَّق الخيارات المعدّلة بزر تطبيق، أو عند المغادرة.",
"Pack settings": "إعدادات الحزمة",
"Built-in settings": "الإعدادات المدمجة",
"%s settings": "إعدادات %s",
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط."
}
@@ -71,10 +71,7 @@
"horizon.mods.settings": "Parametrlər",
"horizon.mods.in_world": " (Dəyişmək üçün dünyanı tərk et)",
"horizon.mods.open_folder": "Mod Qovluğunu açın",
"horizon.shader_packs.empty": "Şeyder paketləri yoxdur: onları shaderpacks qovluğuna qoyun",
"horizon.shader_options.open": "Shader Seçimləri...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Şeyder paketinin seçimləri oxunur...",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
"horizon.multiplayer.cancel_reconnecting": "Yenidən qoşulmanı ləğv edin",
@@ -466,5 +463,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Digər oyunçuların izləri və auraları gizli qalır; digər kosmetikaları görünməyə davam edir. Defolt olaraq söndürülüb.",
"horizon.extras.cosmetics.shown": "Göstərildi",
"horizon.extras.cosmetics.hidden": "Gizli",
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri"
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri",
"Installed": "Quraşdırılmış",
"Apply": "Tətbiq et",
"Reset all options": "Bütün seçimləri sıfırla",
"A Horizon shader pack.": "Horizon şeyder paketi.",
"An OptiFine or Iris shader pack.": "OptiFine və ya Iris şeyder paketi.",
"In use: press to turn shaders off.": "İstifadədədir: şeyderləri söndürmək üçün basın.",
"Press to use it.": "İstifadə etmək üçün basın.",
"Put shader pack .zip files or folders in it.": "Şeyder paketlərinin .zip fayllarını və ya qovluqlarını bura qoyun.",
"Puts every option of this pack back to its default.": "Bu paketin bütün seçimlərini ilkin dəyərinə qaytarır.",
"Sets many options at once, as the pack groups them.": "Paketin qruplaşdırdığı kimi, bir çox seçimi birdən təyin edir.",
"No shader packs yet: open the pack folder and put some in it.": "Hələ şeyder paketi yoxdur: paket qovluğunu açıb bir neçəsini qoyun.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Şeyder paketi seçin. İstifadədə olanı seçmək şeyderləri söndürür.",
"Changed options apply with Apply, or when you leave.": "Dəyişdirilmiş seçimlər Tətbiq et ilə və ya çıxdıqda tətbiq olunur.",
"Pack settings": "Paket seçimləri",
"Built-in settings": "Daxili seçimlər",
"%s settings": "%s seçimləri",
"Settings made by the pack's author: they change only this pack.": "Paketin müəllifinin yaratdığı seçimlər: yalnız bu paketə təsir edir."
}
@@ -74,10 +74,7 @@
"horizon.mods.settings": "Көйләүҙәр",
"horizon.mods.in_world": " (донъяны үҙгәртергә ҡалдырырға)",
"horizon.mods.open_folder": "Мод папкаһын асыу",
"horizon.shader_packs.empty": "Шейдер пакеттары юҡ: уларҙы шейдерпакеттар папкаһына һалығыҙ",
"horizon.shader_options.open": "Шейдер варианттары...",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Шейдер пакетының варианттарын уҡыу...",
"horizon.agent.copy_details": "Тоташыу тураһында мәғлүмәтте күсерергә",
"horizon.multiplayer.join": "Ҡушылыу",
"horizon.multiplayer.cancel_reconnecting": "Яңынан тоташтырыуҙы юҡҡа сығарыу",
@@ -492,5 +489,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Башҡа уйынсыларҙың эҙҙәре һәм ауралары йәшерен ҡала; уларҙың башҡа косметикаһы күренә. Ғәҙәттә һүндерелгән.",
"horizon.extras.cosmetics.shown": "Күрһәтелгән",
"horizon.extras.cosmetics.hidden": "Йәшерен",
"horizon.extras.cosmetics.name_tags": "Исем билдәләре генә"
"horizon.extras.cosmetics.name_tags": "Исем билдәләре генә",
"Installed": "Урынлаштырылған",
"Apply": "Ҡулланырға",
"Reset all options": "Бөтә көйләүҙәрҙе ташларға",
"A Horizon shader pack.": "Horizon шейдер пакеты.",
"An OptiFine or Iris shader pack.": "OptiFine йәки Iris шейдер пакеты.",
"In use: press to turn shaders off.": "Ҡулланыла: шейдерҙарҙы һүндереү өсөн баҫығыҙ.",
"Press to use it.": "Ҡулланыу өсөн баҫығыҙ.",
"Put shader pack .zip files or folders in it.": "Шейдер пакеттарының .zip файлдарын йәки папкаларын шунда һалығыҙ.",
"Puts every option of this pack back to its default.": "Был пакеттың һәр көйләүен ғәҙәти хәленә ҡайтара.",
"Sets many options at once, as the pack groups them.": "Пакет төркөмләгәнсә, күп көйләүҙе бер юлы урынлаштыра.",
"No shader packs yet: open the pack folder and put some in it.": "Шейдер пакеттары әлегә юҡ: пакеттар папкаһын асып, берәй нисәһен һалығыҙ.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Шейдер пакетын һайлағыҙ. Ҡулланылғанын һайлау шейдерҙарҙы һүндерә.",
"Changed options apply with Apply, or when you leave.": "Үҙгәртелгән көйләүҙәр «Ҡулланырға» менән йәки сыҡҡанда ҡулланыла.",
"Pack settings": "Пакет көйләүҙәре",
"Built-in settings": "Эске көйләүҙәр",
"%s settings": "%s көйләүҙәре",
"Settings made by the pack's author: they change only this pack.": "Пакет авторы төҙөгән көйләүҙәр: улар тик был пакетты үҙгәртә."
}
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Налады",
"horizon.mods.in_world": " (пакінуць свет змяніцца)",
"horizon.mods.open_folder": "Адкрыць тэчку Mod",
"horizon.shader_packs.empty": "Пакетаў шэйдэраў няма: змесціце іх у тэчку шэйдэраў",
"horizon.shader_options.open": "Параметры шэйдара...",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Чытанне параметраў пакета шэйдараў...",
"horizon.agent.copy_details": "Скапіруйце звесткі аб падключэнні",
"horizon.multiplayer.join": "Далучайцеся",
"horizon.multiplayer.cancel_reconnecting": "Адмяніць паўторнае падключэнне",
@@ -478,5 +475,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Сляды і аўры іншых гульцоў схаваныя; іх астатняя касметыка бачная. Па змаўчанні выкл.",
"horizon.extras.cosmetics.shown": "Паказана",
"horizon.extras.cosmetics.hidden": "Схаваны",
"horizon.extras.cosmetics.name_tags": "Толькі біркі з імёнамі"
"horizon.extras.cosmetics.name_tags": "Толькі біркі з імёнамі",
"Installed": "Усталяваныя",
"Apply": "Ужыць",
"Reset all options": "Скінуць усе налады",
"A Horizon shader pack.": "Пакет шэйдараў Horizon.",
"An OptiFine or Iris shader pack.": "Пакет шэйдараў OptiFine або Iris.",
"In use: press to turn shaders off.": "Выкарыстоўваецца: націсніце, каб выключыць шэйдары.",
"Press to use it.": "Націсніце, каб выкарыстаць.",
"Put shader pack .zip files or folders in it.": "Пакладзіце ў яе .zip-файлы або папкі пакетаў шэйдараў.",
"Puts every option of this pack back to its default.": "Вяртае ўсе налады гэтага пакета да прадвызначаных.",
"Sets many options at once, as the pack groups them.": "Задае адразу многія налады так, як іх групуе пакет.",
"No shader packs yet: open the pack folder and put some in it.": "Пакетаў шэйдараў пакуль няма: адкрыйце папку пакетаў і пакладзіце туды некалькі.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Выберыце пакет шэйдараў. Выбар пакета, які выкарыстоўваецца, выключае шэйдары.",
"Changed options apply with Apply, or when you leave.": "Змененыя налады ўжываюцца кнопкай «Ужыць» або пры выхадзе.",
"Pack settings": "Налады пакета",
"Built-in settings": "Убудаваныя налады",
"%s settings": "Налады %s",
"Settings made by the pack's author: they change only this pack.": "Налады, зробленыя аўтарам пакета: яны змяняюць толькі гэты пакет."
}
@@ -70,10 +70,7 @@
"horizon.mods.settings": "Настройки",
"horizon.mods.in_world": " (оставете света да се промени)",
"horizon.mods.open_folder": "Отворете папката на Mod",
"horizon.shader_packs.empty": "Без шейдър пакети: поставете ги в папката shaderpacks",
"horizon.shader_options.open": "Опции за шейдър...",
"horizon.shader_options.profile": "Профил",
"horizon.shader_options.loading": "Четене на опциите на шейдър пакета...",
"horizon.agent.copy_details": "Копиране на подробности за връзката",
"horizon.multiplayer.join": "Присъединете се",
"horizon.multiplayer.cancel_reconnecting": "Отказ Повторно свързване",
@@ -474,5 +471,22 @@
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Скрити",
"horizon.extras.cosmetics.name_tags": "Само етикети с имена",
"Shader Packs...": "Шейдър пакети..."
"Shader Packs...": "Шейдър пакети...",
"Installed": "Инсталирани",
"Apply": "Приложи",
"Reset all options": "Нулирай всички опции",
"A Horizon shader pack.": "Пакет шейдъри на Horizon.",
"An OptiFine or Iris shader pack.": "Пакет шейдъри на OptiFine или Iris.",
"In use: press to turn shaders off.": "В употреба: натиснете, за да изключите шейдърите.",
"Press to use it.": "Натиснете, за да го използвате.",
"Put shader pack .zip files or folders in it.": "Сложете в нея .zip файлове или папки с пакети шейдъри.",
"Puts every option of this pack back to its default.": "Връща всяка опция на този пакет към стойността по подразбиране.",
"Sets many options at once, as the pack groups them.": "Задава много опции наведнъж, както ги групира пакетът.",
"No shader packs yet: open the pack folder and put some in it.": "Още няма пакети шейдъри: отворете папката с пакети и сложете няколко.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Изберете пакет шейдъри. Изборът на използвания го изключва.",
"Changed options apply with Apply, or when you leave.": "Променените опции се прилагат с Приложи или при излизане.",
"Pack settings": "Настройки на пакета",
"Built-in settings": "Вградени настройки",
"%s settings": "Настройки на %s",
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет."
}
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Arventennoù",
"horizon.mods.in_world": " (lezel ar bed da cheñch)",
"horizon.mods.open_folder": "Digeriñ an teuliad mod",
"horizon.shader_packs.empty": "Pakad skeudennoù ebet : lakait anezho e teuliad ar pakadoù skeudennoù",
"horizon.shader_options.open": "Dibarzhioù skeudenniñ...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "O lenn dibaboù ar pakad skeudennoù...",
"horizon.agent.copy_details": "Eilañ munudoù ar c'hennask",
"horizon.multiplayer.join": "Kemer perzh",
"horizon.multiplayer.cancel_reconnecting": "Nullañ an adkennaskañ",
@@ -477,5 +474,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Kuzhet e chom roudoù hag aouraoù ar c'hoarierien all; diskouezet e vez o c'hinkladurioù all. Diweredekaet dre ziouer.",
"horizon.extras.cosmetics.shown": "Diskouezet",
"horizon.extras.cosmetics.hidden": "Kuzhet",
"horizon.extras.cosmetics.name_tags": "Tikedennoù anvioù hepken"
"horizon.extras.cosmetics.name_tags": "Tikedennoù anvioù hepken",
"Installed": "Staliet",
"Apply": "Arloañ",
"Reset all options": "Adderaouekaat an holl zibarzhioù",
"A Horizon shader pack.": "Ur pakad skeudennerioù Horizon.",
"An OptiFine or Iris shader pack.": "Ur pakad skeudennerioù OptiFine pe Iris.",
"In use: press to turn shaders off.": "Implijet: pouezit evit lazhañ ar skeudennerioù.",
"Press to use it.": "Pouezit evit e implijout.",
"Put shader pack .zip files or folders in it.": "Lakait e-barzh restroù .zip pe teuliadoù pakadoù skeudennerioù.",
"Puts every option of this pack back to its default.": "Adlakaat a ra pep dibarzh eus ar pakad-mañ d'e dalvoudegezh dre ziouer.",
"Sets many options at once, as the pack groups them.": "Lakaat a ra kalz dibarzhioù war un dro, evel ma vez strollet gant ar pakad.",
"No shader packs yet: open the pack folder and put some in it.": "Pakad skeudennerioù ebet c'hoazh: digorit teuliad ar pakadoù ha lakait un nebeut e-barzh.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Dibabit ur pakad skeudennerioù. Dibab an hini implijet a laz ar skeudennerioù.",
"Changed options apply with Apply, or when you leave.": "An dibarzhioù cheñchet a vez arloet gant Arloañ, pe pa guitait.",
"Pack settings": "Arventennoù ar pakad",
"Built-in settings": "Arventennoù enframmet",
"%s settings": "Arventennoù %s",
"Settings made by the pack's author: they change only this pack.": "Arventennoù graet gant oberer ar pakad: cheñch a reont nemet ar pakad-mañ."
}
@@ -58,10 +58,7 @@
"horizon.mods.settings": "Postavke",
"horizon.mods.in_world": " (ostavi svijet da se promijeni)",
"horizon.mods.open_folder": "Otvorite Mod folder",
"horizon.shader_packs.empty": "Nema paketa shadera: stavite ih u mapu shaderpacks",
"horizon.shader_options.open": "Opcije shadera...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Čitanje opcija paketa shadera...",
"horizon.agent.copy_details": "Kopiraj detalje veze",
"horizon.multiplayer.join": "Pridružite se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
@@ -419,5 +416,22 @@
"horizon.extras.cosmetics.shown": "Prikazano",
"horizon.extras.cosmetics.hidden": "Skriveno",
"horizon.extras.cosmetics.name_tags": "Samo oznake sa imenom",
"Shader Packs...": "Shader paketi..."
"Shader Packs...": "Shader paketi...",
"Installed": "Instalirani",
"Apply": "Primijeni",
"Reset all options": "Resetuj sve opcije",
"A Horizon shader pack.": "Horizon paket šejdera.",
"An OptiFine or Iris shader pack.": "OptiFine ili Iris paket šejdera.",
"In use: press to turn shaders off.": "U upotrebi: pritisni da isključiš šejdere.",
"Press to use it.": "Pritisni da ga koristiš.",
"Put shader pack .zip files or folders in it.": "Stavi u njega .zip datoteke ili mape paketa šejdera.",
"Puts every option of this pack back to its default.": "Vraća svaku opciju ovog paketa na zadanu vrijednost.",
"Sets many options at once, as the pack groups them.": "Postavlja mnogo opcija odjednom, kako ih paket grupiše.",
"No shader packs yet: open the pack folder and put some in it.": "Još nema paketa šejdera: otvori mapu paketa i stavi nekoliko u nju.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Odaberi paket šejdera. Odabir paketa koji se koristi isključuje šejdere.",
"Changed options apply with Apply, or when you leave.": "Promijenjene opcije se primjenjuju pomoću Primijeni ili kada izađeš.",
"Pack settings": "Postavke paketa",
"Built-in settings": "Ugrađene postavke",
"%s settings": "Postavke za %s",
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket."
}
@@ -69,10 +69,7 @@
"horizon.mods.settings": "Configuració",
"horizon.mods.in_world": " (deixar el món per canviar)",
"horizon.mods.open_folder": "Obriu la carpeta de modificacions",
"horizon.shader_packs.empty": "Sense paquets d'ombres: col·loqueu-los a la carpeta shaderpacks",
"horizon.shader_options.open": "Opcions d'ombrejat...",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Llegint les opcions del paquet shader...",
"horizon.agent.copy_details": "Copia els detalls de la connexió",
"horizon.multiplayer.join": "Uneix-te",
"horizon.multiplayer.cancel_reconnecting": "Cancel·la la tornada a connectar",
@@ -467,5 +464,22 @@
"horizon.extras.cosmetics.shown": "Es mostra",
"horizon.extras.cosmetics.hidden": "Ocult",
"horizon.extras.cosmetics.name_tags": "Només etiquetes de nom",
"Shader Packs...": "Paquets d'ombres..."
"Shader Packs...": "Paquets d'ombres...",
"Installed": "Instal·lats",
"Apply": "Aplica",
"Reset all options": "Restableix totes les opcions",
"A Horizon shader pack.": "Un paquet de shaders d'Horizon.",
"An OptiFine or Iris shader pack.": "Un paquet de shaders d'OptiFine o Iris.",
"In use: press to turn shaders off.": "En ús: prem per desactivar els shaders.",
"Press to use it.": "Prem per fer-lo servir.",
"Put shader pack .zip files or folders in it.": "Hi pots posar fitxers .zip o carpetes de paquets de shaders.",
"Puts every option of this pack back to its default.": "Torna totes les opcions d'aquest paquet al valor per defecte.",
"Sets many options at once, as the pack groups them.": "Defineix moltes opcions alhora, tal com les agrupa el paquet.",
"No shader packs yet: open the pack folder and put some in it.": "Encara no hi ha cap paquet de shaders: obre la carpeta de paquets i hi poses algun.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Tria un paquet de shaders. Triar el que està en ús desactiva els shaders.",
"Changed options apply with Apply, or when you leave.": "Les opcions canviades s'apliquen amb Aplica, o quan surts.",
"Pack settings": "Configuració del paquet",
"Built-in settings": "Configuració integrada",
"%s settings": "Configuració de %s",
"Settings made by the pack's author: they change only this pack.": "Configuració feta per l'autor del paquet: només canvia aquest paquet."
}
@@ -67,11 +67,7 @@
"horizon.mods.settings": "Nastavení",
"horizon.mods.in_world": " (nechat svět změnit)",
"horizon.mods.open_folder": "Otevřete složku Mod",
"horizon.shader_packs.empty": "Žádné balíčky shaderů: vložte je do složky shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizont)",
"horizon.shader_options.open": "Možnosti stínování...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Čtení možností sady shaderů...",
"horizon.agent.copy_details": "Kopírovat podrobnosti o připojení",
"horizon.multiplayer.join": "Připojte se",
"horizon.multiplayer.cancel_reconnecting": "Zrušit opětovné připojení",
@@ -456,5 +452,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Stopy a aury ostatních hráčů zůstanou skryté; jejich ostatní kosmetika je dál vidět. Ve výchozím stavu vypnuto.",
"horizon.extras.cosmetics.shown": "Zobrazeno",
"horizon.extras.cosmetics.hidden": "Skrytá",
"horizon.extras.cosmetics.name_tags": "Pouze jmenovky"
"horizon.extras.cosmetics.name_tags": "Pouze jmenovky",
"Installed": "Nainstalované",
"Apply": "Použít",
"Reset all options": "Obnovit všechny možnosti",
"A Horizon shader pack.": "Balíček shaderů Horizon.",
"An OptiFine or Iris shader pack.": "Balíček shaderů OptiFine nebo Iris.",
"In use: press to turn shaders off.": "Používá se: stisknutím vypnete shadery.",
"Press to use it.": "Stisknutím jej použijete.",
"Put shader pack .zip files or folders in it.": "Vložte do ní soubory .zip nebo složky balíčků shaderů.",
"Puts every option of this pack back to its default.": "Vrátí všechny možnosti tohoto balíčku na výchozí hodnoty.",
"Sets many options at once, as the pack groups them.": "Nastaví mnoho možností najednou, tak jak je balíček seskupuje.",
"No shader packs yet: open the pack folder and put some in it.": "Zatím žádné balíčky shaderů: otevřete složku balíčků a vložte do ní nějaké.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Vyberte balíček shaderů. Výběr používaného balíčku shadery vypne.",
"Changed options apply with Apply, or when you leave.": "Změněné možnosti se použijí tlačítkem Použít nebo při odchodu.",
"Pack settings": "Nastavení balíčku",
"Built-in settings": "Vestavěná nastavení",
"%s settings": "Nastavení %s",
"Settings made by the pack's author: they change only this pack.": "Nastavení vytvořená autorem balíčku: mění jen tento balíček."
}
@@ -73,10 +73,7 @@
"horizon.mods.settings": "Настройкӑсем",
"horizon.mods.in_world": " (тӗнчене улӑштарма хӑвар)",
"horizon.mods.open_folder": "Мод папкине уҫӑр",
"horizon.shader_packs.empty": "Шейдер пакетӗсем ҫук: вӗсене шейдер пакетӗсем папкӑна лартӑр",
"horizon.shader_options.open": "Шейдер опцийӗсем...",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Шейдер пакетӗн опцийӗсене вулатӑп...",
"horizon.agent.copy_details": "Ҫыхӑну даннӑйӗсене копилесе илӗр",
"horizon.multiplayer.join": "Пӗрлешӗр",
"horizon.multiplayer.cancel_reconnecting": "Ҫӗнӗрен ҫыхӑнассине пӑрахӑҫла",
@@ -488,5 +485,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Ытти вӑййисен йӗрӗсем тата аурисем пытарӑнса юлаҫҫӗ; вӗсен ытти косметики курӑнать. Йӑлана кура сӳнтернӗ.",
"horizon.extras.cosmetics.shown": "Кӑтартнӑ",
"horizon.extras.cosmetics.hidden": "Пытарнӑ",
"horizon.extras.cosmetics.name_tags": "Ят паллисем кӑна"
"horizon.extras.cosmetics.name_tags": "Ят паллисем кӑна",
"Installed": "Лартнă",
"Apply": "Усă курасси",
"Reset all options": "Пур ĕнерлевсене таçтан лартасси",
"A Horizon shader pack.": "Horizon шейдер пакечĕ.",
"An OptiFine or Iris shader pack.": "OptiFine е Iris шейдер пакечĕ.",
"In use: press to turn shaders off.": "Усă курăнать: шейдерсене сӳнтермелли пусăр.",
"Press to use it.": "Усă курма пусăр.",
"Put shader pack .zip files or folders in it.": "Шейдер пакечĕсен .zip файлĕсене е папкисене унта хурăр.",
"Puts every option of this pack back to its default.": "Ку пакетăн пур ĕнерлевне те пуçламăш хакне таврăнтарать.",
"Sets many options at once, as the pack groups them.": "Пакет ушкăнланă евĕр нумай ĕнерлеве пĕр харăсах лартать.",
"No shader packs yet: open the pack folder and put some in it.": "Шейдер пакечĕсем халлĕхе çук: пакетсен папкине уçса темиçе хурăр.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Шейдер пакечĕ суйлăр. Усă куракана суйласан шейдерсем сӳнеççĕ.",
"Changed options apply with Apply, or when you leave.": "Улшăннă ĕнерлевсем «Усă курасси» пусăмпа е тухнă чухне усă курăнаççĕ.",
"Pack settings": "Пакет ĕнерлевĕсем",
"Built-in settings": "Йĕркеленĕ ĕнерлевсем",
"%s settings": "%s ĕнерлевĕсем",
"Settings made by the pack's author: they change only this pack.": "Пакет авторĕ тунă ĕнерлевсем: вĕсем ҫак пакета çеç улăштараççĕ."
}
@@ -67,10 +67,7 @@
"horizon.mods.settings": "Gosodiadau",
"horizon.mods.in_world": " (gadewch y byd i newid)",
"horizon.mods.open_folder": "Agor Ffolder Mod",
"horizon.shader_packs.empty": "Dim pecynnau lliwiwr: rhowch nhw yn y ffolder shaderpacks",
"horizon.shader_options.open": "Opsiynau Shader...",
"horizon.shader_options.profile": "Proffil",
"horizon.shader_options.loading": "Darllen opsiynau'r pecyn lliwiwr...",
"horizon.agent.copy_details": "Copïo Manylion Cysylltiad",
"horizon.multiplayer.join": "Ymuno",
"horizon.multiplayer.cancel_reconnecting": "Canslo Ailgysylltu",
@@ -463,5 +460,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Mae llwybrau ac awrâu chwaraewyr eraill yn aros yn gudd; mae eu colur arall yn dal i ddangos. I ffwrdd yn ddiofyn.",
"horizon.extras.cosmetics.shown": "Dangosir",
"horizon.extras.cosmetics.hidden": "Cudd",
"horizon.extras.cosmetics.name_tags": "Tagiau enw yn unig"
"horizon.extras.cosmetics.name_tags": "Tagiau enw yn unig",
"Installed": "Wedi'u gosod",
"Apply": "Gweithredu",
"Reset all options": "Ailosod pob dewis",
"A Horizon shader pack.": "Pecyn shaders Horizon.",
"An OptiFine or Iris shader pack.": "Pecyn shaders OptiFine neu Iris.",
"In use: press to turn shaders off.": "Yn cael ei ddefnyddio: pwyswch i ddiffodd shaders.",
"Press to use it.": "Pwyswch i'w ddefnyddio.",
"Put shader pack .zip files or folders in it.": "Rhowch ffeiliau .zip neu ffolderi pecynnau shaders ynddo.",
"Puts every option of this pack back to its default.": "Yn rhoi pob dewis o'r pecyn hwn yn ôl i'w ragosodiad.",
"Sets many options at once, as the pack groups them.": "Yn gosod llawer o ddewisiadau ar unwaith, fel mae'r pecyn yn eu grwpio.",
"No shader packs yet: open the pack folder and put some in it.": "Dim pecynnau shaders eto: agorwch ffolder y pecynnau a rhowch rai ynddo.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Dewiswch becyn shaders. Mae dewis yr un sy'n cael ei ddefnyddio yn diffodd shaders.",
"Changed options apply with Apply, or when you leave.": "Mae dewisiadau a newidiwyd yn cael eu gweithredu gyda Gweithredu, neu pan fyddwch yn gadael.",
"Pack settings": "Gosodiadau'r pecyn",
"Built-in settings": "Gosodiadau adeiledig",
"%s settings": "Gosodiadau %s",
"Settings made by the pack's author: they change only this pack.": "Gosodiadau a wnaed gan awdur y pecyn: maent yn newid y pecyn hwn yn unig."
}
@@ -60,10 +60,7 @@
"horizon.mods.settings": "Indstillinger",
"horizon.mods.in_world": " (lad verden ændre sig)",
"horizon.mods.open_folder": "Åbn Mod-mappe",
"horizon.shader_packs.empty": "Ingen shader-pakker: læg dem i shaderpacks-mappen",
"horizon.shader_options.open": "Indstillinger for skygge...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Læser shader-pakkens muligheder...",
"horizon.agent.copy_details": "Kopier forbindelsesoplysninger",
"horizon.multiplayer.join": "Deltag",
"horizon.multiplayer.cancel_reconnecting": "Annuller Gentilslutning",
@@ -444,5 +441,22 @@
"horizon.extras.cosmetics.shown": "Vist",
"horizon.extras.cosmetics.hidden": "Skjult",
"horizon.extras.cosmetics.name_tags": "Kun navneskilte",
"Shader Packs...": "Shader-pakker..."
"Shader Packs...": "Shader-pakker...",
"Installed": "Installeret",
"Apply": "Anvend",
"Reset all options": "Nulstil alle indstillinger",
"A Horizon shader pack.": "En Horizon-shaderpakke.",
"An OptiFine or Iris shader pack.": "En OptiFine- eller Iris-shaderpakke.",
"In use: press to turn shaders off.": "I brug: tryk for at slå shaders fra.",
"Press to use it.": "Tryk for at bruge den.",
"Put shader pack .zip files or folders in it.": "Læg .zip-filer eller mapper med shaderpakker i den.",
"Puts every option of this pack back to its default.": "Sætter alle denne pakkes indstillinger tilbage til standard.",
"Sets many options at once, as the pack groups them.": "Sætter mange indstillinger på én gang, som pakken grupperer dem.",
"No shader packs yet: open the pack folder and put some in it.": "Ingen shaderpakker endnu: åbn pakkemappen og læg nogle i den.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Vælg en shaderpakke. Vælger du den, der er i brug, slås shaders fra.",
"Changed options apply with Apply, or when you leave.": "Ændrede indstillinger træder i kraft med Anvend, eller når du forlader skærmen.",
"Pack settings": "Pakkeindstillinger",
"Built-in settings": "Indbyggede indstillinger",
"%s settings": "Indstillinger for %s",
"Settings made by the pack's author: they change only this pack.": "Indstillinger lavet af pakkens forfatter: de ændrer kun denne pakke."
}
@@ -65,11 +65,7 @@
"horizon.mods.dev": " (Entwicklung)",
"horizon.mods.settings": "Einstellungen",
"horizon.mods.open_folder": "Modordner öffnen",
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader-Optionen …",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
@@ -306,6 +302,23 @@
"Shaders": "Shader",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Shader-Pakete, die von Horizon und die aus shaders/-Ordnern, und ihre Optionen.",
"Shader packs": "Shader-Pakete",
"Installed": "Installiert",
"Apply": "Anwenden",
"Reset all options": "Alle Optionen zurücksetzen",
"A Horizon shader pack.": "Ein Horizon-Shader-Paket.",
"An OptiFine or Iris shader pack.": "Ein OptiFine- oder Iris-Shader-Paket.",
"In use: press to turn shaders off.": "In Verwendung: Drücken, um Shader auszuschalten.",
"Press to use it.": "Drücken, um es zu verwenden.",
"Put shader pack .zip files or folders in it.": "Lege Shader-Paket-.zip-Dateien oder -Ordner hinein.",
"Puts every option of this pack back to its default.": "Setzt jede Option dieses Pakets auf den Standardwert zurück.",
"Sets many options at once, as the pack groups them.": "Stellt viele Optionen auf einmal ein, so wie das Paket sie gruppiert.",
"No shader packs yet: open the pack folder and put some in it.": "Noch keine Shader-Pakete: Öffne den Paketordner und lege welche hinein.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Wähle ein Shader-Paket. Das verwendete erneut zu wählen schaltet Shader aus.",
"Changed options apply with Apply, or when you leave.": "Geänderte Optionen gelten mit Anwenden oder beim Verlassen.",
"Pack settings": "Paketeinstellungen",
"Built-in settings": "Integrierte Einstellungen",
"%s settings": "Einstellungen für %s",
"Settings made by the pack's author: they change only this pack.": "Vom Autor des Pakets erstellte Einstellungen: Sie ändern nur dieses Paket.",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Das Preset von Vanilla: setzt nur die Optionen von Vanilla, wie Minecraft.",
"Fullscreen takes the display over, at the mode below.": "Der Vollbildmodus übernimmt den Bildschirm, im Modus darunter.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Der Anzeigemodus des exklusiven Vollbilds. Gilt, wenn du das Menü verlässt.",
@@ -67,10 +67,7 @@
"horizon.mods.settings": "Ρυθμίσεις",
"horizon.mods.in_world": " (άσε τον κόσμο να αλλάξει)",
"horizon.mods.open_folder": "Ανοίξτε το φάκελο Mod",
"horizon.shader_packs.empty": "Χωρίς πακέτα shader: τοποθετήστε τα στο φάκελο shaderpacks",
"horizon.shader_options.open": "Επιλογές Shader...",
"horizon.shader_options.profile": "Προφίλ",
"horizon.shader_options.loading": "Διαβάζοντας τις επιλογές του πακέτου shader...",
"horizon.agent.copy_details": "Αντιγραφή στοιχείων σύνδεσης",
"horizon.multiplayer.join": "Εγγραφείτε",
"horizon.multiplayer.cancel_reconnecting": "Ακύρωση επανασύνδεσης",
@@ -466,5 +463,22 @@
"horizon.extras.cosmetics.shown": "Εμφανίζεται",
"horizon.extras.cosmetics.hidden": "Κρυφό",
"horizon.extras.cosmetics.name_tags": "Μόνο ετικέτες ονομάτων",
"Shader Packs...": "Πακέτα Shader..."
"Shader Packs...": "Πακέτα Shader...",
"Installed": "Εγκατεστημένα",
"Apply": "Εφαρμογή",
"Reset all options": "Επαναφορά όλων των επιλογών",
"A Horizon shader pack.": "Ένα πακέτο shader του Horizon.",
"An OptiFine or Iris shader pack.": "Ένα πακέτο shader του OptiFine ή του Iris.",
"In use: press to turn shaders off.": "Σε χρήση: πατήστε για απενεργοποίηση των shader.",
"Press to use it.": "Πατήστε για χρήση.",
"Put shader pack .zip files or folders in it.": "Βάλτε μέσα αρχεία .zip ή φακέλους πακέτων shader.",
"Puts every option of this pack back to its default.": "Επαναφέρει κάθε επιλογή αυτού του πακέτου στην προεπιλογή της.",
"Sets many options at once, as the pack groups them.": "Ορίζει πολλές επιλογές ταυτόχρονα, όπως τις ομαδοποιεί το πακέτο.",
"No shader packs yet: open the pack folder and put some in it.": "Δεν υπάρχουν ακόμη πακέτα shader: ανοίξτε το φάκελο πακέτων και βάλτε μερικά.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Επιλέξτε ένα πακέτο shader. Η επιλογή αυτού που χρησιμοποιείται απενεργοποιεί τα shader.",
"Changed options apply with Apply, or when you leave.": "Οι αλλαγμένες επιλογές εφαρμόζονται με το Εφαρμογή ή όταν φύγετε.",
"Pack settings": "Ρυθμίσεις πακέτου",
"Built-in settings": "Ενσωματωμένες ρυθμίσεις",
"%s settings": "Ρυθμίσεις %s",
"Settings made by the pack's author: they change only this pack.": "Ρυθμίσεις από τον δημιουργό του πακέτου: αλλάζουν μόνο αυτό το πακέτο."
}
@@ -120,11 +120,7 @@
"horizon.mods.switch.worldgen": "changes world generation",
"horizon.mods.switch.registries": "adds blocks, items or other registry entries",
"horizon.mods.switch.restart": "Restart the game to apply %s: they add blocks, items or world generation.",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader Options...",
"horizon.shader_options.profile": "Profile",
"horizon.shader_options.loading": "Reading the shader pack's options...",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
@@ -497,6 +493,23 @@
"Shaders": "Shaders",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Shader packs, Horizon's and shaders/ folder ones, and their options.",
"Shader packs": "Shader packs",
"Installed": "Installed",
"Apply": "Apply",
"Reset all options": "Reset all options",
"A Horizon shader pack.": "A Horizon shader pack.",
"An OptiFine or Iris shader pack.": "An OptiFine or Iris shader pack.",
"In use: press to turn shaders off.": "In use: press to turn shaders off.",
"Press to use it.": "Press to use it.",
"Put shader pack .zip files or folders in it.": "Put shader pack .zip files or folders in it.",
"Puts every option of this pack back to its default.": "Puts every option of this pack back to its default.",
"Sets many options at once, as the pack groups them.": "Sets many options at once, as the pack groups them.",
"No shader packs yet: open the pack folder and put some in it.": "No shader packs yet: open the pack folder and put some in it.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
"Pack settings": "Pack settings",
"Built-in settings": "Built-in settings",
"%s settings": "%s settings",
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanilla's own preset: sets only vanilla's options, as Minecraft does.",
"Fullscreen takes the display over, at the mode below.": "Fullscreen takes the display over, at the mode below.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "The display mode exclusive fullscreen uses. Applies when you leave.",
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Agordoj",
"horizon.mods.in_world": " (lasu la mondon ŝanĝi)",
"horizon.mods.open_folder": "Malfermu Mod-dosierujon",
"horizon.shader_packs.empty": "Neniuj ombraj pakoj: metu ilin en la dosierujon de ombraj pakoj",
"horizon.shader_options.open": "Ombrilaj Opcioj...",
"horizon.shader_options.profile": "Profilo",
"horizon.shader_options.loading": "Legante la opciojn de la ombra pako...",
"horizon.agent.copy_details": "Kopiu Detalojn pri Konekto",
"horizon.multiplayer.join": "Aliĝu",
"horizon.multiplayer.cancel_reconnecting": "Nuligi Rekonekti",
@@ -471,5 +468,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Spuroj kaj aŭroj de aliaj ludantoj restas kaŝitaj; iliaj aliaj kosmetikaĵoj ankoraŭ montriĝas. Defaŭlte malŝaltita.",
"horizon.extras.cosmetics.shown": "Montrita",
"horizon.extras.cosmetics.hidden": "Kaŝita",
"horizon.extras.cosmetics.name_tags": "Nomaj etikedoj nur"
"horizon.extras.cosmetics.name_tags": "Nomaj etikedoj nur",
"Installed": "Instalitaj",
"Apply": "Apliki",
"Reset all options": "Restarigi ĉiujn agordojn",
"A Horizon shader pack.": "Ombrilo-pakaĵo de Horizon.",
"An OptiFine or Iris shader pack.": "Ombrilo-pakaĵo de OptiFine aŭ Iris.",
"In use: press to turn shaders off.": "Uzata: premu por malŝalti ombrilojn.",
"Press to use it.": "Premu por uzi ĝin.",
"Put shader pack .zip files or folders in it.": "Metu en ĝin .zip-dosierojn aŭ dosierujojn de ombrilo-pakaĵoj.",
"Puts every option of this pack back to its default.": "Restarigas ĉiun agordon de ĉi tiu pakaĵo al ĝia defaŭlto.",
"Sets many options at once, as the pack groups them.": "Agordas multajn agordojn samtempe, kiel la pakaĵo grupigas ilin.",
"No shader packs yet: open the pack folder and put some in it.": "Ankoraŭ neniu ombrilo-pakaĵo: malfermu la pakaĵujon kaj metu kelkajn en ĝin.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elektu ombrilo-pakaĵon. Elekti la uzatan malŝaltas ombrilojn.",
"Changed options apply with Apply, or when you leave.": "Ŝanĝitaj agordoj efikas per Apliki, aŭ kiam vi foriras.",
"Pack settings": "Agordoj de la pakaĵo",
"Built-in settings": "Enkonstruitaj agordoj",
"%s settings": "Agordoj de %s",
"Settings made by the pack's author: they change only this pack.": "Agordoj faritaj de la aŭtoro de la pakaĵo: ili ŝanĝas nur ĉi tiun pakaĵon."
}
@@ -54,8 +54,6 @@
"horizon.mods.dev": " (Desarrollo)",
"horizon.mods.settings": "Ajustes",
"horizon.mods.open_folder": "Abrir carpeta de mods",
"horizon.shader_packs.empty": "No hay paquetes de shaders: ponlos en la carpeta shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copiar datos de conexión",
"horizon.multiplayer.join": "Unirse",
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
@@ -194,9 +192,7 @@
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…",
"Particle Culling": "Particle Culling",
"Draws every particle.": "Draws every particle.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
@@ -256,5 +252,22 @@
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)",
"Shader Packs...": "Paquetes de shaders..."
"Shader Packs...": "Paquetes de shaders...",
"Installed": "Instalados",
"Apply": "Aplicar",
"Reset all options": "Restablecer todas las opciones",
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
"Press to use it.": "Pulsa para usarlo.",
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
"Pack settings": "Ajustes del paquete",
"Built-in settings": "Ajustes integrados",
"%s settings": "Ajustes de %s",
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete."
}
@@ -70,10 +70,7 @@
"horizon.mods.settings": "Seaded",
"horizon.mods.in_world": " (jätke maailm muutuma)",
"horizon.mods.open_folder": "Avage kaust Mod",
"horizon.shader_packs.empty": "Varjupakkideta: pange need varjupakkide kausta",
"horizon.shader_options.open": "Varjundi valikud...",
"horizon.shader_options.profile": "Profiil",
"horizon.shader_options.loading": "Varjutuspaketi valikute lugemine...",
"horizon.agent.copy_details": "Kopeerige ühenduse üksikasjad",
"horizon.multiplayer.join": "Liitu",
"horizon.multiplayer.cancel_reconnecting": "Tühista uuesti ühendamine",
@@ -467,5 +464,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Teiste mängijate jäljed ja aurad jäävad peidetuks; nende muu kosmeetika on endiselt näha. Vaikimisi väljas.",
"horizon.extras.cosmetics.shown": "Näidatud",
"horizon.extras.cosmetics.hidden": "Peidetud",
"horizon.extras.cosmetics.name_tags": "Ainult nimesildid"
"horizon.extras.cosmetics.name_tags": "Ainult nimesildid",
"Installed": "Paigaldatud",
"Apply": "Rakenda",
"Reset all options": "Lähtesta kõik valikud",
"A Horizon shader pack.": "Horizoni varjutajapakett.",
"An OptiFine or Iris shader pack.": "OptiFine'i või Irise varjutajapakett.",
"In use: press to turn shaders off.": "Kasutusel: vajuta, et varjutajad välja lülitada.",
"Press to use it.": "Vajuta, et seda kasutada.",
"Put shader pack .zip files or folders in it.": "Pane sinna varjutajapakettide .zip-failid või kaustad.",
"Puts every option of this pack back to its default.": "Seab selle paketi kõik valikud tagasi vaikeväärtustele.",
"Sets many options at once, as the pack groups them.": "Seab paljud valikud korraga, nii nagu pakett neid rühmitab.",
"No shader packs yet: open the pack folder and put some in it.": "Varjutajapakette veel pole: ava paketikaust ja pane sinna mõned.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Vali varjutajapakett. Kasutusel oleva valimine lülitab varjutajad välja.",
"Changed options apply with Apply, or when you leave.": "Muudetud valikud rakenduvad nupuga Rakenda või väljumisel.",
"Pack settings": "Paketi sätted",
"Built-in settings": "Sisseehitatud sätted",
"%s settings": "%s sätted",
"Settings made by the pack's author: they change only this pack.": "Paketi autori tehtud sätted: need muudavad ainult seda paketti."
}
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Ezarpenak",
"horizon.mods.in_world": " (utzi mundua aldatzen)",
"horizon.mods.open_folder": "Ireki Mod karpeta",
"horizon.shader_packs.empty": "Ez dago itzal-paketerik: jarri itzazu itzal-paketeen karpetan",
"horizon.shader_options.open": "Itzalaren aukerak...",
"horizon.shader_options.profile": "Profila",
"horizon.shader_options.loading": "Shader paketearen aukerak irakurtzen...",
"horizon.agent.copy_details": "Kopiatu konexioaren xehetasunak",
"horizon.multiplayer.join": "Sartu",
"horizon.multiplayer.cancel_reconnecting": "Utzi berriro konektatzea",
@@ -472,5 +469,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "Beste jokalarien arrastoak eta aurak ezkutuan geratzen dira; haien beste kosmetikoak ikusten jarraitzen dute. Desaktibatuta lehenespenez.",
"horizon.extras.cosmetics.shown": "Erakutsita",
"horizon.extras.cosmetics.hidden": "Ezkutuan",
"horizon.extras.cosmetics.name_tags": "Izena etiketak soilik"
"horizon.extras.cosmetics.name_tags": "Izena etiketak soilik",
"Installed": "Instalatuta",
"Apply": "Aplikatu",
"Reset all options": "Berrezarri aukera guztiak",
"A Horizon shader pack.": "Horizon shader pakete bat.",
"An OptiFine or Iris shader pack.": "OptiFine edo Iris shader pakete bat.",
"In use: press to turn shaders off.": "Erabileran: sakatu shaderrak desaktibatzeko.",
"Press to use it.": "Sakatu erabiltzeko.",
"Put shader pack .zip files or folders in it.": "Jarri shader pakete-en .zip fitxategiak edo karpetak bertan.",
"Puts every option of this pack back to its default.": "Pakete honen aukera guztiak lehenetsira itzultzen ditu.",
"Sets many options at once, as the pack groups them.": "Aukera asko batera ezartzen ditu, paketeak taldekatzen dituen moduan.",
"No shader packs yet: open the pack folder and put some in it.": "Ez dago shader paketerik oraindik: ireki paketeen karpeta eta jarri batzuk bertan.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Aukeratu shader pakete bat. Erabileran dagoena aukeratzeak shaderrak desaktibatzen ditu.",
"Changed options apply with Apply, or when you leave.": "Aldatutako aukerak Aplikatu botoiarekin aplikatzen dira, edo irtetean.",
"Pack settings": "Paketearen ezarpenak",
"Built-in settings": "Ezarpen integratuak",
"%s settings": "%s(r)en ezarpenak",
"Settings made by the pack's author: they change only this pack.": "Paketearen egileak egindako ezarpenak: pakete hau soilik aldatzen dute."
}
@@ -69,10 +69,7 @@
"horizon.mods.settings": "تنظیمات",
"horizon.mods.in_world": " (دنیا را رها کن تا تغییر کند)",
"horizon.mods.open_folder": "پوشه Mod را باز کنید",
"horizon.shader_packs.empty": "بدون بسته های سایه زن: آنها را در پوشه shaderpacks قرار دهید",
"horizon.shader_options.open": "گزینه های سایه زن...",
"horizon.shader_options.profile": "نمایه",
"horizon.shader_options.loading": "خواندن گزینه های بسته سایه زن...",
"horizon.agent.copy_details": "کپی جزئیات اتصال",
"horizon.multiplayer.join": "بپیوندید",
"horizon.multiplayer.cancel_reconnecting": "لغو اتصال مجدد",
@@ -477,5 +474,22 @@
"horizon.extras.cosmetics_hide_fx.tooltip": "ردها و هاله‌های بازیکنان دیگر پنهان می‌مانند؛ دیگر آرایه‌هایشان همچنان دیده می‌شوند. به‌طور پیش‌فرض خاموش.",
"horizon.extras.cosmetics.shown": "نشان داده شده است",
"horizon.extras.cosmetics.hidden": "پنهان شده است",
"horizon.extras.cosmetics.name_tags": "فقط برچسب های نام"
"horizon.extras.cosmetics.name_tags": "فقط برچسب های نام",
"Installed": "نصب‌شده",
"Apply": "اعمال",
"Reset all options": "بازنشانی همه گزینه‌ها",
"A Horizon shader pack.": "یک بسته شیدر Horizon.",
"An OptiFine or Iris shader pack.": "یک بسته شیدر OptiFine یا Iris.",
"In use: press to turn shaders off.": "در حال استفاده: برای خاموش کردن شیدرها فشار دهید.",
"Press to use it.": "برای استفاده فشار دهید.",
"Put shader pack .zip files or folders in it.": "فایل‌های .zip یا پوشه‌های بسته‌های شیدر را در آن بگذارید.",
"Puts every option of this pack back to its default.": "همه گزینه‌های این بسته را به پیش‌فرض برمی‌گرداند.",
"Sets many options at once, as the pack groups them.": "چندین گزینه را یکجا تنظیم می‌کند، همان‌طور که بسته آن‌ها را گروه‌بندی کرده است.",
"No shader packs yet: open the pack folder and put some in it.": "هنوز بسته شیدری نیست: پوشه بسته‌ها را باز کنید و چند تا در آن بگذارید.",
"Pick a shader pack. Picking the one in use turns shaders off.": "یک بسته شیدر انتخاب کنید. انتخاب بسته در حال استفاده شیدرها را خاموش می‌کند.",
"Changed options apply with Apply, or when you leave.": "گزینه‌های تغییرکرده با اعمال یا هنگام خروج اعمال می‌شوند.",
"Pack settings": "تنظیمات بسته",
"Built-in settings": "تنظیمات داخلی",
"%s settings": "تنظیمات %s",
"Settings made by the pack's author: they change only this pack.": "تنظیماتی که سازنده بسته ساخته است: فقط همین بسته را تغییر می‌دهند."
}
@@ -67,10 +67,7 @@
"horizon.mods.settings": "Asetukset",
"horizon.mods.in_world": " (jätä maailma muuttumaan)",
"horizon.mods.open_folder": "Avaa Mod-kansio",
"horizon.shader_packs.empty": "Ei Shader-paketteja: laita ne Shaderpacks-kansioon",
"horizon.shader_options.open": "Varjostimen asetukset...",
"horizon.shader_options.profile": "Profiili",
"horizon.shader_options.loading": "Shader-paketin vaihtoehtojen lukeminen...",
"horizon.agent.copy_details": "Kopioi yhteystiedot",
"horizon.multiplayer.join": "Liity",
"horizon.multiplayer.cancel_reconnecting": "Peruuta yhteyden muodostaminen",
@@ -453,5 +450,22 @@
"horizon.extras.cosmetics.shown": "Näytetään",
"horizon.extras.cosmetics.hidden": "Piilotettu",
"horizon.extras.cosmetics.name_tags": "Vain nimilaput",
"Shader Packs...": "Shader Packit..."
"Shader Packs...": "Shader Packit...",
"Installed": "Asennetut",
"Apply": "Käytä",
"Reset all options": "Nollaa kaikki asetukset",
"A Horizon shader pack.": "Horizon-varjostinpaketti.",
"An OptiFine or Iris shader pack.": "OptiFine- tai Iris-varjostinpaketti.",
"In use: press to turn shaders off.": "Käytössä: paina, niin varjostimet poistuvat käytöstä.",
"Press to use it.": "Paina käyttääksesi sitä.",
"Put shader pack .zip files or folders in it.": "Laita siihen varjostinpakettien .zip-tiedostoja tai kansioita.",
"Puts every option of this pack back to its default.": "Palauttaa kaikki tämän paketin asetukset oletusarvoihin.",
"Sets many options at once, as the pack groups them.": "Asettaa monta asetusta kerralla sillä tavalla kuin paketti ryhmittelee ne.",
"No shader packs yet: open the pack folder and put some in it.": "Varjostinpaketteja ei vielä ole: avaa pakettikansio ja laita siihen joitakin.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Valitse varjostinpaketti. Käytössä olevan valitseminen poistaa varjostimet käytöstä.",
"Changed options apply with Apply, or when you leave.": "Muutetut asetukset otetaan käyttöön Käytä-painikkeella tai poistuessasi.",
"Pack settings": "Paketin asetukset",
"Built-in settings": "Sisäänrakennetut asetukset",
"%s settings": "%s: asetukset",
"Settings made by the pack's author: they change only this pack.": "Paketin tekijän tekemät asetukset: ne muuttavat vain tätä pakettia."
}
Loaded 100 of 873 files, more files were not shown because too many files have changed in this diff. Show more