Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 2f26231d5c Random ticks: read each chunk's ticked blocks together before ticking them
Each ticking section's block read is a cache miss, waited for one after the
other between the ticks. tick_chunk_random now reads the positions the ticks
will draw first, all together: the misses overlap and the ticks find their
blocks in cache. The positions come from the same Level.randValue sequence,
read without being advanced; the draws, the ticks and their order are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:00 +01:00
Ludovic Marie 4e833c2fe6 Merge pull request #5299 from Qwerner88880/win-features-fix
🤖 Agent: merged main, feature list = union with main's Win32_System_Console; code unchanged since review
2026-10-31 20:02:16 +01:00
Ludovic Marie 0530214eac Merge pull request #4785 from WooCaash/fix/4549-recipe-book-servers
🤖 Agent: merged main again: the verify failure was main's hub.rs unreachable-pattern clippy error, fixed in 825a6bbcf.
2026-10-31 20:02:11 +01:00
Ludovic Marie 07ee94939d Merge pull request #4891 from sirrayi/fix/interact-packet-target
🤖 Agent: requeue: head moved only by main merges (rdbtCVS / the author).
2026-10-31 20:02:07 +01:00
Ludovic Marie 4ba0479a7d Merge pull request #5317 from sirrayi/fix/mob-body-armor-wear
🤖 Agent: reverted the guest-tool broadcast (#5392 does it with the player half); same mob-armour content as reviewed
2026-10-31 19:59:49 +01:00
Ludovic Marie 854bce76e4 Merge pull request #4996 from Solenopsisbot/headless/loop-handle
🤖 Agent: merged main; main's new control_command_inner now takes &dyn EventLoopCtl (the land check's E0425); cargo check of bins and tests passes
2026-10-31 19:59:45 +01:00
Ludovic Marie 06da0b9fd6 Merge pull request #5059 from MSedgeMC/fix/xp-orb-unstuck
🤖 Agent: requeued on rdbtCVS's merge of main (Python suite fix)
2026-10-31 19:59:42 +01:00
Ludovic Marie 8acdff48fc Merge pull request #5392 from xFedeT/fix/player-equipment-break-event
🤖 Agent: the player half of onEquippedItemBroken (hands 47/48, armour, shield, guest breaks broadcast); complements #5317 (mobs) without touching creatures.rs
2026-10-31 19:59:37 +01:00
Ludovic Marie 16d7a9e870 Merge pull request #5084 from reservedbytes/fix/sulfur-cube-tnt-explodes
🤖 Agent: requeued on rdbtCVS's merge of main (Python suite fix)
2026-10-31 19:59:32 +01:00
Ludovic Marie c26c4cedca Merge pull request #5131 from Qwerner88880/hooks-chunk-player
🤖 Agent: merged main myself (lan.rs: main's wake_woken_guests before this PR's Option-returning lan_process_events); native_ tests pass locally.
2026-10-31 19:59:27 +01:00
Ludovic Marie 56a6bfa5b5 Merge pull request #5377 from xFedeT/fix/shulker-bullet-hurt
🤖 Agent: ShulkerBullet.hurtServer on a player's blow (sound, 15 crit, destroy), pick radius 0 as Entity.getPickRadius; test
2026-10-31 19:59:23 +01:00
Ludovic Marie 26116c31e4 Merge pull request #4484 from quintyo/fix/recipe-book-close-item
🤖 Agent: merged main; container_screen.rs keeps main's creative-page test beside this PR's recipe-book test (8 pass).
2026-10-31 19:59:20 +01:00
Ludovic MarieandClaude Opus 5.5 6237b86c0b Revert "fix(mobs): a guest's tool breaking reaches the other clients"
This reverts commit 3d5138cfd: #5392 broadcasts the player's equipment
breaks (both hands, armour, shield, a guest's included) through
onEquippedItemBroken, so with both the guest's main-hand break would be
sent twice. This PR stays the mobs' half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 35f85fd88d Merge origin/main into headless/loop-handle
control.rs: main's control_command_inner takes the loop handle
(&dyn EventLoopCtl) like the rest of the game's methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:02 +01:00
Ludovic Marie 2d39fb84ca Merge pull request #5397 from horizon-rust/fix/launcher-lang-ja-ptbr-tr
🤖 Agent: main's launcher i18n test fails on 17 strings missing in ja/pt-br/tr; added them, every table has the same 500 keys
2026-10-31 19:55:11 +01:00
Ludovic Marie fd1e6c104f Merge pull request #4638 from elythepreciouscat/feat/ui-slider-keyboard-controls
🤖 Agent: merged main (title.rs: focused_button beside main's Experiments fields); client lib, bin and tests build.
2026-10-31 19:55:07 +01:00
Ludovic Marie 50b900579a Merge pull request #5139 from oskal675-design/fix/gateway-exit-storage
🤖 Agent: merged main myself (world_loading.rs: the gateway migration beside main's packed biomes); builds locally.
2026-10-31 19:55:02 +01:00
Ludovic Marie 82149460e3 Merge pull request #4584 from doodlyyousuf/fix/mob-minecart-board
🤖 Agent: merged main again: the verify failure was main's hub.rs unreachable-pattern clippy error, fixed in 825a6bbcf.
2026-10-31 19:54:57 +01:00
Ludovic Marie 3e531ead7b Merge pull request #5394 from abit-foggy/fix/intel-gen12-vulkan-flicker
🤖 Agent: is_broken_intel_vulkan extended to Gen12 device ranges/names (#4599), same Vulkan+Intel gate as Gen11's
2026-10-31 19:54:52 +01:00
Ludovic Marie d7708ced16 Merge pull request #5391 from horizon-rust/docs/no-rebase-after-maintainer-push
🤖 Agent: docs-only CONTRIBUTING rule (merge main, no rebase after a maintainer push); public tree check passed.
2026-10-31 19:54:48 +01:00
Ludovic Marie 3d0bc068f6 Merge pull request #5174 from reservedbytes/feat/mod-mob-effect-hooks
🤖 Agent: merged main myself (native_hooks.rs: the effect hooks beside #5170's damage hook); build and native_ tests pass locally.
2026-10-31 19:54:42 +01:00
Ludovic Marie be872e629d Merge pull request #4413 from ESHAYAT102/feat/native-visual-hooks
🤖 Agent: requeued after merging main: terrain.wgsl's TerrainOut keeps main's water-mirror height at location 9 and this PR's emissive layer moves to 10; horizon-client-render's 767 lib tests pass.
2026-10-31 19:54:36 +01:00
Ludovic Marie 792ee6e67a Merge pull request #5325 from arismoko/native-component-types
🤖 Agent: read the transport slice: custom component IDs behind a OnceLock table, checked in the fallback arm only (vanilla ids unchanged), unnegotiated stacks dropped; two integration tests
2026-10-31 19:54:32 +01:00
Ludovic Marie 87c52a279d Merge pull request #5355 from MSedgeMC/fix/spore-blossom-limit
🤖 Agent: ParticleLimit.SPORE_BLOSSOM after the provider (RNG order kept); added a size guard before the scan; test passes
2026-10-31 19:54:28 +01:00
Ludovic Marie 6badfbf436 Merge pull request #5203 from Qwerner88880/hooks-fluid-move
🤖 Agent: reviewed and fixed (see my comment); merged with main, fluid tests and clippy pass locally.
2026-10-31 19:54:23 +01:00
Ludovic Marie b42aa496ee Merge remote-tracking branch 'origin/main' into sweepc-4996 2026-10-31 19:52:28 +01:00
Ludovic Marie 8a5e34b485 Merge main into #5299 (windows-sys features: main's System_Console beside this PR's Gdi/UI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-threads/Cargo.toml
2026-10-31 19:51:09 +01:00
Ludovic MarieandClaude Opus 5.5 48af991e36 Launcher: the 17 settings and releases strings ja, pt-br and tr lacked
#5226 added the proxy, game arguments, release headers and sponsor strings
to the other languages; ja, pt-br and tr (added beside it) never got them,
so the launcher's every_language_has_every_string_the_code_asks_for test
fails on main and on every PR's launcher check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:56 +01:00
Ludovic Marie dc8ec33576 Merge remote-tracking branch 'origin/main' into sweep/5139-m3
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
2026-10-31 19:48:48 +01:00
Ludovic Marie 5d16a611c3 Merge pull request #5233 from iseaprojects/i18n/es-es-complete
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). es_es.json completed (198/198 keys, placeholders kept, JSON checked); asset only, no code.
2026-10-31 19:48:29 +01:00
Ludovic Marie a2dbd47419 Merge pull request #5253 from reservedbytes/fix/sulfur-cube-burning-arrow
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla's SulfurCube.hurtServer: a burning arrow or trident lights an explosive cube's long fuse at the hit (tnt_explodes), after the hurt; pending sounds become flags so primed and hit both play; unit tests.
2026-10-31 19:48:22 +01:00
Ludovic Marie dd4194f728 Merge main into #4638
# Conflicts:
#	crates/horizon-client/src/title.rs
2026-10-31 19:48:00 +01:00
Ludovic Marie 049c0a54e7 Merge pull request #4447 from PzmuV1517/shader-packs/07
🤖 Agent: merged #4445's new head (86e0d83db, main merged) up the stack; #4790 also merges current main (lib.rs: shader_options beside main's split_profiles); top of stack builds clean (client lib+bin).
2026-10-31 19:45:57 +01:00
Ludovic Marie 008ea17e50 Merge pull request #4446 from PzmuV1517/shader-packs/06
🤖 Agent: merged #4445's new head (86e0d83db, main merged) up the stack; #4790 also merges current main (lib.rs: shader_options beside main's split_profiles); top of stack builds clean (client lib+bin).
2026-10-31 19:45:07 +01:00
Ludovic Marie f71747f7cc Merge main into #4785 (main's hub.rs clippy fix) 2026-10-31 19:44:02 +01:00
Ludovic Marie 21a0572afe Merge main into #4584 (main's hub.rs clippy fix) 2026-10-31 19:43:58 +01:00
sirrayiandDeepSeek Harness 3d5138cfd6 fix(mobs): a guest's tool breaking reaches the other clients
`LivingEntity.onEquippedItemBroken` broadcasts the equipment-break event for
the hand, so every client plays the break and its crumbs. A guest's tool
breaks on the host (`Game::damage_held` acting for the guest), and until now
nothing told the other clients: it was heard only by the window that
happened to be simulating. The break now goes through
`Game::lan_entity_event` with `equipment_break_event(MainHand)` (47).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 92bc5e7728 refactor(mobs): the equipment-break event comes from the vanilla method
`LivingEntity.entityEventForEquipmentBreak` has a client-side inverse here
already (`item_break::equipment_break_slot`); the wear added for #5317 spelled
the event ids out itself. `item_break::equipment_break_event` is that method
by name, `hurt_mob_armor` asks it for the slot's id, and a test pins the two
as inverses of each other over every slot.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 009f5d0de4 fix(mobs): every worn piece wears when its mob is hurt
`LivingEntity.doHurtEquipment` covers all the equipment a living entity
wears, not only a horse's body armour: `Game::hurt_mob_armor` now takes
`max(1, damage / 4)` off the helmet, chestplate, leggings and boots too,
Unbreaking and the fire-resistant spare applied, and a piece that reaches
its maximum empties its slot and broadcasts the equipment-break event for
it (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52 for the armour,
65 for the body).

`tools/port_map`: `LivingEntity.entityEventForEquipmentBreak` is `inline`
now, with what it still misses written in its note.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 0854972109 fix(mobs): a horse's body armour wears when the horse is hurt
`Horse.hurtArmor` (`LivingEntity.doHurtEquipment`) was not modelled at all:
`Mob::damage_with` had no notion of worn durability, so a horse's armour
kept its full durability however often the horse was hit, and the piece
never broke (the row of #3512).

`damage_with` now reports the hit's dealt amount for a mob wearing body
armour (`MobEvent::ArmorHurt`, which carries the damage kind), and the host
applies it in `game/creatures.rs::Game::hurt_mob_armor` the way the player's
own `hurt_armor` does: `max(1, damage / 4)` with Unbreaking applied, no
wear for a piece that does not wear when hurt or for a fire hit on a
fire-resistant one, and at the item's maximum the slot empties and the
equipment-break event (65, the body slot) goes out. A wolf's armour keeps
its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
Ludovic Marie 47f372fee8 Merge pull request #5085 from k3yh/fix/nbt-long-string-save
🤖 Agent: merged main again (nbt.rs: kept both new tests); nbt tests pass
2026-10-31 19:43:25 +01:00
Ludovic Marie 1c60198bd3 Merge pull request #5199 from reservedbytes/fix/guest-blast-advancement-cause
🤖 Agent: Requeued at 352c6336a: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 e56a64273b CONTRIBUTING: merge main, don't rebase, once a maintainer pushed to your branch
Contributors' agents that rebase and force-push over maintainer fixups
erase them (#5188, #5203). Ask for merges of main instead, and say what
happens if the fixes get erased anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:54 +01:00
Ludovic Marie e0af08b8b1 Merge pull request #5172 from kacpherek/fix/frame-map-updates
🤖 Agent: Requeued at 38ca64911: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:42:45 +01:00
Ludovic Marie 07dec696b7 Merge pull request #5157 from mulareal/fix-skin-pack-reload-5097
🤖 Agent: Requeued at 190e96938 after reading what came in since (approvals, main merges for #5251's fix); nothing blocking.
2026-10-31 19:42:33 +01:00
Ludovic Marie 94aa6231da Merge pull request #5124 from mulareal/fix-item-slime-bounce-5080
🤖 Agent: Requeued at b4980bc77 after reading what came in since (approvals, main merges for #5251's fix); nothing blocking.
2026-10-31 19:42:16 +01:00
Ludovic Marie f0f5eac5fc Merge pull request #5291 from horizon-rust/fix/ci-native-mods-strings
🤖 Agent: re-read after the author's update: only crates/horizon-mod-registry/Cargo.lock regenerated against main (adds zip and its deps), so mod-registry's --locked clippy passes; the launcher strings reached main elsewhere.
2026-10-31 19:41:59 +01:00
Ludovic Marie f67740ab8d Merge remote-tracking branch 'origin/main' into sweep/5174-m3
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-31 19:40:24 +01:00
Ludovic MarieandClaude Opus 5.5 a11de0a95f Merge origin/main into headless/loop-handle (main's Python suite fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:09 +01:00
Ludovic Marie dfd94a7e51 Merge pull request #5069 from masa-masa-0314/feat/trade-rebalance
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:39:37 +01:00
Ludovic Marie 8cd2d69e21 Merge pull request #5063 from ludovicthenot/fix/ride-non-mob-vehicles
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:46 +01:00
Ludovic Marie cc65cfef36 Merge pull request #5057 from MSedgeMC/fix/conversion-save
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:39 +01:00
Ludovic Marie 5be990276d Merge pull request #5002 from doodlyyousuf/fix/4974-dialog-held-keys
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:29 +01:00
Ludovic MarieandClaude Opus 5.5 839399c373 Merge origin/main into fix/ride-non-mob-vehicles (main's Python suite fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:23 +01:00
Ludovic Marie b0d667e0c8 Merge pull request #5058 from reservedbytes/fix/death-message-held-item
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:09 +01:00
Ludovic Marie 6993869f71 Merge pull request #5314 from elythepreciouscat/perf/terrain-frame-budget
🤖 Agent: read the diff: a software (DeviceType::Cpu) adapter skips the GPU occlusion pass by default, HORIZON_GPU_CULL still forces it either way; hardware unchanged; measured +5 % FPS on llvmpipe; unit test. Merged main, dropping the branch's duplicate #5251 fixes.
2026-10-31 19:37:56 +01:00
Ludovic Marie cd62766252 Merge pull request #5308 from loicpiller/fix/4166-inventory-crafting-shortcuts
🤖 Agent: read the diff: screens read Shift/Ctrl from the OS modifier word (InputWithModifiers.hasShiftDown), the word reconciles stale physical keys and held latches on ModifiersChanged (toggles, pad holds and rebinds kept), Shift-drag distributes a carried stack, pad Y quick-moves, Ctrl+Q reads the same word, horizon-ctl key/release keep it in sync; new lan_e2e checks. Merged main: #5208's per-frame heal reads the same word, so the two agree; cargo check -p horizon-client --lib --bins --tests clean.
2026-10-31 19:37:27 +01:00
Ludovic Marie d4ee991627 Merge pull request #5055 from Teiwazik/fix/pack-signature-reads-directory
Packs: don't hash a large zip's bytes on every overlay check
2026-10-31 19:37:18 +01:00
Ludovic Marie ba51a54f99 Merge pull request #5083 from reservedbytes/fix/wither-skull-dragon
fix(dragon): a wither skull hits the ender dragon's parts and explodes
2026-10-31 19:37:14 +01:00
Ludovic Marie e4e9cfb691 Merge pull request #5113 from TobiasLogic/shaderpacks/smooth-first-value
Shader packs: custom uniforms' smooth starts from the first real value
2026-10-31 19:37:10 +01:00
Ludovic Marie b9b88227a8 Merge pull request #4967 from MSedgeMC/fix/golem-anger-and-cracks
🤖 Agent: merged main; golem.rs keeps both main's and this PR's test modules; golem tests pass (12).
2026-10-31 19:36:39 +01:00
Federico TaglianiandClaude Sonnet 5.5 9aeae198d1 A player's broken tool, shield or armour is told to every client
LivingEntity.onEquippedItemBroken broadcasts entityEventForEquipmentBreak
(47 hand, 48 off hand, 49 to 52 armour) and each client plays breakItem: the
stack's break sound and the crumbs. The acting guest's breaks go out with
their entity id when their turn ends; the shield's sound is its break_sound
component, not a sound the server plays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:36:31 +01:00
Ludovic Marie b863e7cb4c Merge pull request #5311 from mariuslozo-boop/tooling/windows-lan-e2e
🤖 Agent: read the diff: lan_e2e/agent_control_e2e run on Windows (junction fallback on WinError 1314, no vscreen), horizon-ctl launch clears HANDLE_FLAG_INHERIT on its std handles before spawning the game (cfg(windows) only); Linux/macOS unchanged; Python tests. Proof on Windows 11 in the body.
2026-10-31 19:33:39 +01:00
Ludovic Marie c27101174a Merge pull request #5312 from sirrayi/fix/chat-normalize-message
🤖 Agent: read the diff against vanilla's ChatScreen.normalizeChatMessage: String.trim, normalizeSpace (Java whitespace) and the 256 UTF-16 unit cut before sending and before the history; unit tests.
2026-10-31 19:33:06 +01:00
Ludovic Marie 6491049437 Merge remote-tracking branch 'origin/main' into sweep/5131-m3
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 19:33:01 +01:00
abit-foggy 4bb84b9efe fix(render): enable flat-draw workaround on Intel Gen12 Vulkan (#4599) 2026-10-31 08:32:47 -10:00
Ludovic Marie aa71cc480e Merge pull request #4326 from DUzzL/fix/issue-4321-creative-armor-swap
🤖 Agent: survival: a replaced armour piece that doesn't fit is thrown from the eyes like Player.drop(stack, false), as the other Inventory.add-else-drop paths; 2 lines after #4231 took the rest; rdbtCVS approved. Parity rerun (lunge_motion failure unrelated).
2026-10-31 19:32:29 +01:00
rdbtCVSandClaude Opus 5.5 8c51ac2208 Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:31:14 +00:00
rdbtCVSandClaude Opus 5.5 0d600c843e Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:30:17 +00:00
Ludovic MarieandClaude Opus 5.5 f3eebb902a Merge main into feat/native-visual-hooks: terrain's emissive layer moves to location 10
Main's water mirror took @location(9) for the camera-relative height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:28:51 +01:00
rdbtCVSandClaude Opus 5.5 21569d4a8d Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:28:46 +00:00
sirrayi 8ec84484b5 Merge main into #4891 once more, for #5148's machine-menu test
Main merged #5148, which appends its stock-layout test to the same place at
the end of `game::tests` where this PR appends its two, so the hunk
conflicted. Both sides are whole `#[test]` functions: kept both, each with
its own closing brace, in main's order after this PR's.

`cargo test -p horizon-client --bins` runs all three green
(`a_mod_screen_on_a_machine_menu_takes_the_stock_layout_out` from #5148, and
this PR's `an_interact_packet_clicks_the_entity_it_names_not_the_nearest` and
`an_interact_packet_cannot_name_a_mob_the_pick_would_refuse`).
2026-10-31 18:28:31 +00:00
Ludovic MarieandClaude Opus 5.5 5a3941068b Particles: skip the spore blossom scan while fewer particles are alive than its limit
The scan over live and queued particles only runs once there are at least
ParticleLimit.SPORE_BLOSSOM of them in all, so a spore blossom costs nothing
extra in the common case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:07 +01:00
Ludovic Marie 1b6e55d4f3 Merge pull request #5306 from sirrayi/mods/content-block-state-properties
🤖 Agent: review points at 79ce64782 fixed with tests; opt-in mod field, model-states slice left for #5290
2026-10-31 19:23:57 +01:00
Ludovic Marie eb157cc15e Merge pull request #5319 from readerkid/fix/shulker-box-collision
🤖 Agent: shulker box collision follows the lid and pushes entities as moveCollidedEntities; reviewed the diff
2026-10-31 19:23:53 +01:00
Ludovic Marie 4afc31571c Merge pull request #5329 from horizon-rust/server-admin/management-transport
🤖 Agent: management protocol 6a (WebSocket transport, auth, JSON-RPC errors, rpc.discover): parity harness vs vanilla 26.3 passed (20 exchanges), pr_ready --quick and horizon-server's 77 tests pass.
2026-10-31 19:23:50 +01:00
Ludovic Marie f0dc95398f Merge pull request #5171 from levzzz5154/mods/native-entity-leave
🤖 Agent: merged main (kept #5167's join beside the leave hook), leave hook added on main's new removal paths; native_ 31 passed, clippy clean.
2026-10-31 19:23:46 +01:00
Ludovic Marie 6696029c7e Merge pull request #5220 from ErikKalikas/mod-block-entity-live
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla's Level.sendBlockUpdated / ChunkHolder.broadcastChanges: native_block_entity_sync puts a loaded position in the block log, so the hub sends the block and its update tag once a tick; lan_bot test; tested in game by arismoko. Merged main, API number moved to a hook fragment.
2026-10-31 19:23:42 +01:00
Ludovic Marie a2716978fa Merge pull request #5287 from Sylviromi/menu-button-v2
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff: native_menu_button_v2 fires right after native_menu_button with the clicking player's cursor stack (clickMenuButton(Player, int)); a change is written back and sent with SetCursorItem; API number from a hook fragment. Marked ready.
2026-10-31 19:23:38 +01:00
Ludovic Marie 748bd46de0 Merge pull request #5295 from xFedeT/fix/piglin-admire-seen-brute-pickup
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla: PiglinBrute.wantsToPickUp (golden axe only), StartAdmiringItemIfSeen(119) with StopAdmiringIfItemTooFarAway(9) and StopAdmiringIfTiredOfTryingToReachItem(200, 200), wasHurtBy clearing the memory and ADMIRING_DISABLED for a player's blow; unit tests.
2026-10-31 19:23:34 +01:00
Ludovic Marie 56d0f44cf2 Merge pull request #4445 from PzmuV1517/shader-packs/05
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 5/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:30 +01:00
Ludovic Marie 040b5d809a Merge pull request #4444 from PzmuV1517/shader-packs/04
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 4/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:26 +01:00
Ludovic Marie 879f3cd540 Merge pull request #4443 from PzmuV1517/shader-packs/03
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 3/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:23 +01:00
Ludovic Marie 2e333474f2 Merge pull request #4442 from PzmuV1517/shader-packs/02
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 2/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:19 +01:00
Ludovic Marie b5e85e66a5 Merge pull request #3670 from dacrab/feat/launcher-appimage
🤖 Agent: Linux AppImage: merged main, scoped $APPIMAGE to $APPDIR, rpath instead of LD_LIBRARY_PATH, MCP/shortcuts use update_target; launcher clippy + tests pass.
2026-10-31 19:23:15 +01:00
Ludovic Marie f5e106b66f Merge pull request #5164 from arismoko/hook-fragments-workflow
🤖 Agent: Requeued at d8882f9cb: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:23:10 +01:00
Ludovic Marie 9b5b56b68b Merge pull request #5262 from some-du6e/feat/debug-entries-content
🤖 Agent: merged main and resolved debug_keys.rs: main's F3 Density line (#4389) is now debug_entry_lines' chunk_generation_stats arm, unchanged; cargo check -p horizon-client --bins clean. Reviewed earlier: vanilla's DebugEntry* lines and six debug renderers, profile datafixes; nothing extra per frame with F3 closed.
2026-10-31 19:23:07 +01:00
Ludovic Marie 6bd31ce03a Merge pull request #4377 from 4404c/secure-chat-v2/3-client
🤖 Agent: secure chat 3/3 after #4376: the client signs chat and /msg on enforce-secure-profile servers (fixes the official server's 'missing profile public key'); Chat Delay flush marks messages processed, command signing waits for the server's tree, reconfiguration ack as vanilla; tested by 4404c with a real account; main merged by the author.
2026-10-31 19:23:03 +01:00
Ludovic MarieandClaude Opus 5.5 5b56e48f99 Keep #5188's head and the move's fixes over the rebase
The rebase dropped 24349e636's fixes to native_move_fluids (inverted put-back
check, a tank marked with no fluid in it, other tanks written away; and a
room limit so a cauldron's thirds never need to go back) and #5188's newer
head. This puts them back on #5188's head, with the move in fluids_move.json
as in the rebase.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:22:37 +01:00
Ludovic MarieandClaude Opus 5.5 9314523024 Fluid tanks: clamp a bad capacity instead of renumbering the tanks
A tank dropped for an invalid capacity shifted the later tanks' numbers, so
the face and fill lists reached the wrong tanks (rdbtCVS's review). Also fixes
write_tanks' doc: an empty tank is written as minecraft:empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:21 +01:00
Federico TaglianiandClaude Sonnet 5.5 f286230b3e A blow from a player pops a shulker bullet
ShulkerBullet.hurtServer: the hurt sound, fifteen crit particles and destroy.
The bullet is pickable and attackable (no pick radius, unlike
AbstractHurtingProjectile), so the crosshair finds it, a guest's attack
packet reaches it, and the blow plays Player.attack's sounds for a target
that is not a mob.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:21:19 +01:00
Ludovic Marie 122729e403 Merge pull request #5369 from horizon-rust/fix/main-build-perf-bench-test
Main's Python tests pass again: perf_bench fixture, trusted reviewers list
2026-10-31 19:21:11 +01:00
Ludovic MarieandClaude Opus 5.5 72fabfede5 tests: main's Python suite passes again (perf_bench fixture, trusted reviewers)
- test_table_distinguishes_missing_scene_load_from_zero: #4407's later
  commit makes table() refuse reports without matching conditions, so its
  fixtures now carry the same full conditions as the contract tests.
- test_the_repos_list_is_the_nightlys: the list names Qwerner88880 since
  f35210d2d; 10c71a65d updated test_nightly_set but not this test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:20:13 +01:00
Ludovic Marie 2750e7932f Merge main into #4484
# Conflicts:
#	crates/horizon-client/src/container_screen.rs
2026-10-31 19:17:31 +01:00
Ludovic Marie a734600373 Merge pull request #4932 from reservedbytes/fix/structure-shape-pass
🤖 Agent: merged main (picks up #5251's Audio fix that failed the earlier verify); the touched crates check clean.
2026-10-31 19:16:23 +01:00
Ludovic Marie b127bd2f65 Merge pull request #4398 from Teiwazik/shader/s5b-mirror-option
🤖 Agent: Shader API [reflection] option and mirror-eye facing culling after #4379; only native shader packs see it; rdbtCVS's points fixed; merged up the stack, shader-api tests and a horizon-client check pass.
2026-10-31 19:16:17 +01:00
Ludovic Marie f7782167d3 Merge pull request #5170 from reservedbytes/feat/native-hurt-server-v2
🤖 Agent: Reviewed; merged main, hook moved to a fragment, canceled hits credit no attacker. native_ tests 30 passed, health 8 passed locally.
2026-10-31 19:16:11 +01:00
Ludovic Marie 03c073e4ff Merge pull request #4379 from Teiwazik/shader/s5-water-mirror
🤖 Agent: Shader API water mirror (reflection binding, half resolution, mirrored terrain matrix tested; the sky limit documented) after #4359; rdbtCVS's points fixed; merged up the stack, Frame layout 528 bytes.
2026-10-31 19:16:05 +01:00
Ludovic Marie d30588e308 Merge pull request #4345 from Teiwazik/shader/s2-frame-motion
🤖 Agent: Shader API frame motion (previous_view_projection, camera_motion, delta_time, eased eye light/water/lava) after #4335; rdbtCVS approved; merged with #4335 and main's #4186 fields, layout tests and reference updated.
2026-10-31 19:14:50 +01:00
Ludovic Marie 7bc0a113d2 Merge pull request #5282 from Ashura006/feat/server-pack-in-list
🤖 Agent: read the diff against vanilla's DownloadedPackSource (DOWNLOADED_PACK_SELECTION): active server/world packs show in the Resource Packs list as required, fixed at the top, and their server/ ids stay out of options.txt; unit test.
2026-10-31 19:14:43 +01:00
Ludovic Marie e508458cf8 Merge pull request #5283 from MSedgeMC/fix/falling-block-hidden
🤖 Agent: read the diff against vanilla's FallingBlockRenderer.shouldRender: a falling block whose state equals its cell's is not drawn; one block read per falling block per frame; unit test.
2026-10-31 19:14:37 +01:00
Ludovic Marie d70fc3ec76 Merge pull request #5211 from mulareal/fix-bed-sleep-wakeup-5121
🤖 Agent: re-read the author's rebase onto main (only context in lan.rs changed). read the diff against vanilla: model light probed at the pose's eye height (crouch render offset undone, Joansitoh's review addressed), setPosToBed and xRot 0 on entering and while sleeping, stopSleeping's findStandUpPosition + teleport on wake for the local player and guests. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:32 +01:00
Ludovic Marie d22c3c59cf Merge pull request #5089 from MSedgeMC/fix/locked-containers
🤖 Agent: LockCode.canUnlock (spectator or the main-hand item) and createMenu's refusal (action bar + block.chest.locked, both halves of a double chest) match vanilla; the lock stays in the loader's source tag, so saves keep it; approved by rdbtCVS's agent
2026-10-31 19:14:27 +01:00
Ludovic Marie 0c5ce8e7c8 Merge pull request #5243 from apeirs/fix/uuid-target-any-entity
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: a UUID target resolves through the level's entities (ServerLevel.getEntity), not only mobs; the scoreboard holder lookup shares it; execute on owner keeps only living owners (EntityReference.getLivingEntity). Corpus cases added to command_exec; CI parity runs them. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:22 +01:00
Ludovic Marie f351b64659 Merge pull request #5252 from reservedbytes/fix/sulfur-cube-immune-types
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against the 26.3 tag sulfur_cube_with_block_immune_to: the eight missing damage types added to the cube's immune list (fireworks left to #4997); unit tests. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:17 +01:00
Ludovic Marie bbed9b695a Merge pull request #5184 from TobiasLogic/shaderpacks/terrain-normals-everywhere
🤖 Agent: read the diff: flat draws now exist wherever the device supports them, but frames without a pack keep per-section draws off Metal/Intel Gen11 (min_sections usize::MAX), so only a pack's terrain and shadow pass draw flat; gives packs gl_Normal and mc_Entity on NVIDIA/AMD, measured black-box. CI green, parity runs on the head merged into main.
2026-10-31 19:14:12 +01:00
Ludovic Marie 58a3beedc3 Merge pull request #5160 from TobiasLogic/shaderpacks/no-face-shading
🤖 Agent: read the diff: with a shaders/ pack (oldLighting unset) terrain and fluid vertices drop the baked face shade, measured black-box against Iris with a probe pack (no source read); without a pack, lighting::cardinal as before. CI green, parity runs on the head merged into main.
2026-10-31 19:14:05 +01:00
Ludovic Marie bdce08d7ed Merge pull request #5148 from sirrayi/fix/mod-screen-on-machine-menus
🤖 Agent: read the diff: while a mod screen is shown on a hopper or 3x3 menu, the stock grid no longer answers first in slot_position, so the mod's slots and inventory_y apply; unchanged without a mod screen. Tested in game by arismoko; CI green, parity runs on the head merged into main.
2026-10-31 19:13:59 +01:00
sirrayiandDeepSeek Harness 79ce64782a Content mods: the state tables fan out per combination, and a declared
default no longer needs the template's properties

Both points arismoko raised by reading at e7f4440f0.

1. The NBT tables and the collision overrides carried one row per *template*
   state, not per combination, so `facts.json` and the tables disagreed about
   how many states a block has.

   `nbt` (`states` lists, `cached_shapes` arrays) now emits a row per
   combination in the order `states` numbers them in — a template state's
   combinations are one run, and `Rename::id` maps the template state to the
   first of it — with the declared properties on each row (the `properties`
   compound `BlockState::to_nbt` writes; without them `BlockTable::check`
   fails with "state identities differ" even once the lengths agree).

   `collision_shapes` wrote `first + i`, so only the first combination of each
   template state got a shape and the rest kept the table's -1. It now covers
   every combination of every template state, all pointing at that state's one
   shape: the geometry is the template's, the properties are the block's.

   `structure-blocks.json` declared `states` as the template's states alone;
   it now counts them times the fan-out, so the declared total and the lists
   agree.

2. A `default_state` key the block declares itself was looked up on the
   template anyway, so `{"template": "minecraft:stone",
   "state_properties": {"lit": ...}, "default_state": {"lit": "true"}}` was
   refused with "template default has no state properties" — stone has one
   state and no properties at all. `combinations` already checks a declared
   key's value, and only a key the template has narrows which of its states is
   the default, so the lookup now happens only when such a key is present.

Three tests, each checked red before green: with the old `nbt` the table gets
4 rows where `facts.json` has 6; with the old `collision_shapes` the block's
last state maps to -1; with the old count `structure-blocks.json` says 4 where
its own lists hold 6; and with the old `default_state` guard the property-less
template is refused.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:13:15 +00:00
MSedgeMC 04a8b278aa Cap spore blossom air particles at vanilla*s limit 2026-10-31 23:42:35 +05:30
Ludovic MarieandClaude Opus 5.5 d346e90232 Merge main (#5130's item handler beside the fluid handler)
Both modules declared the same native_simulates; the fluids now use the item
handler's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:10:40 +01:00
huiandZCode AI agent (GLM-5.3) a5fcdd7f7c Native mods: move a block's fluids to another block
native_move_fluids moves up to its max between two blocks, one fluid,
native_move_items' shape: what the target won't take goes back into the
source, past the fill sides' leave. The source's tanks just gave the fluid
up, so they have the room for exactly what left them, and a cauldron
levels back up by the thirds it gave, which a drain hands out whole.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 19:06:21 +01:00
Ludovic MarieandClaude Opus 5.5 c63f053e28 Merge main; leave hooks for main's new item and orb removals
Merges origin/main (#5167's join hook beside this leave hook). Main's blast
on experience orbs, a mob picking up a whole stack and native_set_item_entity
emptying a stack now tell native_entity_leave_level too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:05:24 +01:00
rdbtCVSandClaude Opus 5.5 374171dac6 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:04:35 +00:00
Ludovic Marie 2b8c6e90b3 Merge pull request #5310 from rdbtCVS/ci/single-verify-job
🤖 Agent: read the workflow: verify runs its python, fmt/clippy and test steps in one job again (one runner per run instead of four, the runner queue was 300 deep); the required check is still verify, the only one branch protection requires; no game code.
2026-10-31 19:04:17 +01:00
Ludovic Marie 63bd80498b Merge pull request #5297 from sirrayi/fix/dispenser-egg-entity-data
🤖 Agent: read the diff against vanilla: the dispenser's spawn egg now runs the same EntityType.updateCustomEntityTag step (apply_spawn_egg_entity_data, with its guards) a player's egg does.
2026-10-31 19:04:12 +01:00
Ludovic MarieandClaude Opus 5.5 f2b2d3c1c0 Merge main into #5220 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:02:49 +01:00
huiandZCode AI agent (GLM-5.3) c1efef9859 horizon-threads: turn on the windows-sys features boot_paint uses
boot_paint.rs imports Win32::Foundation, Win32::Graphics::Gdi and
Win32::UI::WindowsAndMessaging, none of which were in the dependency's
feature list, so every Windows build of main fails at horizon-threads
(E0433, the items are configured out). Linux CI never compiles this
file, so it stays green while Windows builds, including the launcher's,
are broken. The same file passed the font count mutably where the API
takes it by const - Linux clippy cannot see that either.

(The hub.rs duplicate S::Attack arm this pull request first also fixed
has since landed on main by itself.)

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 19:02:43 +01:00
Ludovic Marie 737540db27 Merge pull request #5067 from eymenwsmc/main
🤖 Agent: merged main (clean); every launcher language has the same 483 strings; approved as code owner
2026-10-31 19:02:15 +01:00
Ludovic MarieandClaude Opus 5.5 48439d8a5d Merge main into #5233 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:01:59 +01:00
Ludovic Marie ad04330b99 Merge pull request #5241 from horizon-rust/chore/trusted-qwerner
🤖 Agent: Ludovic's decision (2026-10-31), one line in .github/trusted-reviewers.txt; merged main (verify had failed on main's Audio break, fixed by #5251).
2026-10-31 19:01:14 +01:00
Ludovic Marie 6bb01ed6c9 Merge pull request #5277 from arismoko/native-stop-flush
🤖 Agent: read the diff: queued native mod messages are flushed after native_server_stopping while the host and players are still there, on the integrated and dedicated servers; in-game proof on both paths in the body.
2026-10-31 19:01:08 +01:00
Ludovic MarieandClaude Opus 5.5 5c8560ecbc Merge main into #5253 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:56 +01:00
Ludovic Marie df36444423 Merge pull request #5268 from horizon-rust/feat/native-sidebar
🤖 Agent: its owner merged main into the reviewed head (95cdad8b3, unchanged). read the diff: helpers only (per-player sidebar objective, Tab header/footer, tick stats, sleep-count opt-out for AFK players, yaw/pitch on NativeServerPlayer), nothing per tick; the hook fragment is stamped after merge. pr_ready green and the SMP proof on a dedicated server with two clients, per the body.
2026-10-31 19:00:53 +01:00
Ludovic Marie 64b84a7801 Merge pull request #5254 from reservedbytes/fix/sulfur-cube-knockback-sync
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: SulfurCube.knockback sets needsSync before setDeltaMovement; unit test.
2026-10-31 19:00:25 +01:00
Ludovic MarieandClaude Opus 5.5 62d1ce8e1b Merge main into #5287 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:12 +01:00
Ludovic MarieandClaude Opus 5.5 90d357679b Merge main into the AppImage launcher; scope $APPIMAGE, rpath instead of LD_LIBRARY_PATH
- appimage_path: only when this binary runs from inside $APPDIR, so a launcher started by another
  AppImage's program never overwrites that program's file.
- AppRun no longer exports LD_LIBRARY_PATH (the game and every other child inherited it):
  patchelf sets the binary's and libraries' run paths; CI installs patchelf.
- Agent (MCP) configs and desktop shortcuts start update_target(), not the copy in the mount.
- The AppImage is built from $BIN like the archives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:08 +01:00
Ludovic Marie 06f08d851d Merge pull request #5264 from TobiasLogic/shaderpacks/infinity-nan-texelfetch
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: x / 0.0 and sqrt of a negative literal go through a private hz_zero global naga can't fold (GL's run-time inf/NaN), texelFetch1D/2D/3D map to texelFetch; unit test. Shader-pack translator only.
2026-10-31 18:59:59 +01:00
Ludovic Marie bfbe5ba649 Merge pull request #3986 from DUzzL/fix/issue-3971-bots-fight-behaviour
🤖 Agent: merged main again (the earlier CI failure was main's broken Audio initializer, fixed by f80c0360d); clippy -D warnings and the horizon-bots tests pass on the merge. Approved by rdbtCVS; bots-only, no game code.
2026-10-31 18:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 932b14d591 Merge main into #5295 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:59:18 +01:00
Ludovic MarieandClaude Opus 5.5 10c71a65de Trusted reviewers test: the list now names Qwerner88880 too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:57:55 +01:00
levzzz a58ad8b2ee Merge remote-tracking branch 'hr/main' into qw-5131 2026-10-31 22:57:43 +05:00
Ludovic MarieandClaude Opus 5.5 ad64e4376f Merge main into #5241
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:57:15 +01:00
Ludovic Marie bff326d927 Merge branch 'a3-4444' into a3-4445 2026-10-31 18:56:26 +01:00
Ludovic Marie 723ecbaa7f Merge branch 'a3-4443' into a3-4444 2026-10-31 18:56:26 +01:00
Ludovic Marie 86a257635f Merge branch 'a3-4442' into a3-4443 2026-10-31 18:56:25 +01:00
Ludovic Marie 6173f174b1 Merge remote-tracking branch 'origin/main' into a3-4442 2026-10-31 18:56:01 +01:00
arismoko 2238e66444 Carry private native item components over the wire 2026-10-31 12:52:56 -05:00
Ludovic MarieandClaude Opus 5.5 0aa1e5e37c Merge origin/main into fix/nbt-long-string-save
nbt.rs tests: keep this branch's long-string test and main's root-name test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:52:25 +01:00
levzzz bcc3cc5e53 Merge pull request #5130 from Qwerner88880/hooks-item-handler
Native mods: read and take a block's items through a side, and move them (API next)
2026-10-31 22:52:22 +05:00
Ludovic Marie 193eec30a1 Merge pull request #5285 from horizon-rust/feat/official-server-from-links
Official server: play.playhorizon.dev from links.json; launcher and game move old entries, count follows SRV
2026-10-31 18:51:38 +01:00
Ludovic MarieandClaude Opus 5.5 043d3ff075 Launcher: live test of the official count through SRV (ignored by default)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:50:36 +01:00
Ludovic Marie 2776df4716 Merge pull request #5226 from josegil1909/fix/launcher-settings-and-headers-i18n
🤖 Agent: read the diff: remaining launcher strings (proxy, game arguments, releases, patch notes, sponsor, mod verdicts) go through tr() with entries in the seven catalogs; no behaviour change.
2026-10-31 18:50:25 +01:00
Ludovic Marie 91229df006 Merge pull request #5227 from MSedgeMC/fix/shears-crop-growing-plant
🤖 Agent: read the diff against vanilla's ShearsItem.useOn: shears on a growing plant head below age 25 set getMaxAgeState with block.growing_plant.crop, a block_change game event and one wear; bodies and max-age heads untouched. New use_item_on cases; CI parity runs them.
2026-10-31 18:50:21 +01:00
Ludovic Marie 50a45cb4be Merge pull request #4327 from MSedgeMC/fix/4124-dig-clock
🤖 Agent: a resumed dig keeps the first START's clock (ServerPlayerGameMode destroyPos/destroyProgressStart, 0.7 split) on the server and the client; dig_clock, dig_stop and instant_digging LAN checks pass per the author; elythepreciouscat's remote_mining fix included; rdbtCVS approved; verify, clippy, tests green. Parity runs on the merged head.
2026-10-31 18:50:16 +01:00
Ludovic Marie 19e9621e03 Merge pull request #5228 from MSedgeMC/fix/stem-pick-block
🤖 Agent: read the diff against vanilla's StemBlock/AttachedStemBlock.getCloneItemStack: pick-block on a stem gives its seed; unit test.
2026-10-31 18:50:12 +01:00
Ludovic Marie 0a541d4134 Merge pull request #5229 from MSedgeMC/fix/player-head-name
🤖 Agent: read the diff against vanilla's PlayerHeadItem.getName: a profile with a name titles the head block.minecraft.player_head.named in tooltips and command feedback; custom name still wins; unit test.
2026-10-31 18:50:08 +01:00
Ludovic Marie e2a58b30ff Merge pull request #3763 from spidercraft/fsr-frame-interpolation
🤖 Agent: FSR 3.1 frame interpolation (MIT, AMD FidelityFX 1.1.4) ported to WGSL, still unused (skip-release); rdbtCVS's div_ceil point fixed in 58b2c98d3; naga-validated, GPU tests by the author; verify green.
2026-10-31 18:50:03 +01:00
Ludovic MarieandClaude Opus 5.5 1e7bb94391 Merge origin/main into feat/trade-rebalance (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:50:01 +01:00
Ludovic Marie 5baa329c3b Merge pull request #5045 from Teiwazik/fix/pack-list-reads-no-whole-zip
🤖 Agent: verified: Zip::open now keeps the file and reads only the central directory onward (as Java's ZipFile), entries read at their offsets with no shared cursor; parsing unchanged; tests cover lazy reads, long directories and broken END counts. Thanks!
2026-10-31 18:49:58 +01:00
Ludovic Marie 5cdf4cc329 Merge pull request #5009 from reservedbytes/feat/mod-mob-effect-icons
🤖 Agent: verified against Hud.getMobEffectSprite (mob_effect/ prefix, namespace kept) and MissingTextureAtlasSprite.generateMissingImage (magenta where y<8 xor x<8). Thanks!
2026-10-31 18:49:54 +01:00
Ludovic Marie 60a576f19c Merge pull request #5236 from TobiasLogic/shaderpacks/if-conditions
🤖 Agent: read the diff: the shaders/ preprocessor's #if evaluates float literals (integer / and % kept without one), and an unreadable condition is a warning reading false instead of skipping the program; unit tests. CI green, parity runs on the head merged into main.
2026-10-31 18:49:49 +01:00
Ludovic Marie f2bbd60357 Merge pull request #5232 from TobiasLogic/shaderpacks/sky-cloud-attributes
🤖 Agent: read the diff: sky, celestial and cloud lightmap coords, the sky normal, cloud face normals (w 0, the game's own clouds unchanged) and blockEntityId -1 by default, as a shaders/ pack reads them in Iris (observed with a probe pack, no source read). CI green, parity runs on the head merged into main.
2026-10-31 18:49:45 +01:00
Ludovic Marie cca4b0bfa7 Merge pull request #4456 from reservedbytes/fix/terrain-sampling
🤖 Agent: None now samples terrain as vanilla (linear clamped sampler, sampleNearest unless RGSS; terrain.fsh), and distant tiles repeat their sprite per block, which also fixes RGSS/Anisotropic stripes on main since #4843 (checked in game by the author). rdbtCVS's change request was addressed (dismissed with his re-review). verify green.
2026-10-31 18:49:40 +01:00
Ludovic Marie 5e0dba5db4 Merge pull request #4441 from PzmuV1517/shader-packs/01
🤖 Agent: shader packs 1/12: multi-line macro calls, struct varyings, property continuations; naga 30.0.1 vendored unchanged (MIT/Apache, notices added) with fixes on top; review points fixed. CI green.
2026-10-31 18:49:35 +01:00
Ludovic Marie 703a0a75f4 Merge pull request #4220 from elythepreciouscat/perf/opengl-indirect-multidraw
🤖 Agent: GL backend: indirect execution without compute shaders, batched indirect draws with the same strides/offsets; rdbtCVS checked fmt/clippy on third_party/wgpu-hal by hand and approved. CI green.
2026-10-31 18:49:31 +01:00
Ludovic Marie d7f8774e6e Merge pull request #4111 from senjinthedragon/hdr-glow-2
🤖 Agent: HDR tiers for lightning, beacons, crystals, fireworks, orbs; marks follow their draws on classic and OIT paths; checked in game by the author; rdbtCVS approved. CI green, parity pending.
2026-10-31 18:49:27 +01:00
Ludovic Marie ef423d9520 Merge pull request #5137 from Blzs2005/fix/slider-video-modes
🤖 Agent: read the diff: video modes are listed once per monitor instead of at every slider step (about 28 ms each on Windows); with_option keeps the cached list. Unit test; approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 18:49:23 +01:00
rdbtCVSandClaude Opus 5.5 d2f7950962 Merge origin/main (picks up #5251's and #5304's build fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:49:14 +00:00
Ludovic MarieandClaude Opus 5.5 3159ecb0cc Merge origin/main into fix/ride-non-mob-vehicles (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:49:09 +01:00
Ludovic MarieandClaude Opus 5.5 0e11d408de Merge origin/main into fix/conversion-save (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:48:09 +01:00
Ludovic MarieandClaude Opus 5.5 5dd9748bac Merge origin/main into fix/4974-dialog-held-keys (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:20 +01:00
Ludovic MarieandClaude Opus 5.5 a190c13824 Merge origin/main into headless/loop-handle (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:35 +01:00
Ludovic MarieandClaude Opus 5.5 8017536dc0 Merge origin/main into fix/death-message-held-item (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:42 +01:00
rdbtCVSandClaude Opus 5.5 5108719261 Merge origin/main into xp orb unstuck
entities.rs: main's ExperienceOrb::hurt sits before this branch's box_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:45:37 +00:00
Ludovic MarieandClaude Opus 5.5 150ca81725 Merge main into #5314; main's own build fixes (#5251) replace this branch's copies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:03 +01:00
sirrayi 5c766a673c The knot comment says why a packet does not reach a knot
The comment claimed a knot is a block under the crosshair rather than an
entity id. On a server it is both: `knot_ids` (`game/lan_entities.rs`)
gives every fence knot a `LeashFenceKnotEntity` id, so a guest's Interact
naming one arrives with an id that `interact_knot` cannot use, because it
starts from the block pick.

The comment now says that instead of stating a false reason. No behaviour
changes: knot ids were not resolved before this PR either.

rdbtCVS's open nit at 44c54ba8b. Also rewords the same claim in the body.
2026-10-31 17:43:46 +00:00
sirrayi d9429e6731 Merge main into #4891 again, for 825a6bbcf
The previous merge point (`06485bfef`) is ten commits behind main, and it
still has what `825a6bbcf` fixed: two `S::Attack` arms in `Hub::process_inbox`,
the second unreachable, which is what makes `clippy -D warnings` fail on this
branch as it did on main. Merging current main takes that fix.

No conflicts, and no file this PR changes is resolved by hand here.
2026-10-31 17:43:31 +00:00
Ludovic Marie 2dd60d922d Merge the stack below (main merged) into #4447 2026-10-31 18:43:23 +01:00
Ludovic Marie b5abcf04ea Merge the stack below (main merged) into #4446 2026-10-31 18:43:21 +01:00
Ludovic Marie 9660021979 Merge remote-tracking branch 'origin/main' into a3-pr-4413b
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-31 18:40:56 +01:00
Ludovic MarieandClaude Opus 5.5 8fa56fc67a Merge main into #5308 (#5208 landed beside it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:39:57 +01:00
ElyTheCatandCodex 026069b8a1 Skip software occlusion culling by default
Co-Authored-By: Codex <[email protected]>
2026-10-31 21:09:52 +03:30
ElyTheCatandCodex 403ec1317f Fix current client build regressions
Co-Authored-By: Codex <[email protected]>
2026-10-31 21:09:48 +03:30
sirrayiandDeepSeek Harness e7f4440f05 Content mods: a block declares its own state properties (#5290)
A content block's states were exactly its template's, copied row for row, and
`default_state` could only overlay values on properties the template already
had. So a pipe could not have a connection per side, and a machine could not
have `facing` and `lit`.

A block file may now declare `state_properties` — a boolean, or an enum of
values — and the block's states become its template's states times their
combinations, appended after the template's run so a block's states stay one
contiguous run in registry order.

The combination order puts every property at its declared default first, and
`Rename` carries how many states a block gives each of its template's, so a
template state still moves to exactly one state: the block's default. The
`facts.json` defaults and `render-definitions.json` mappings are unchanged.

Refused: a name its template already has, a bad name or value, a
`default_state` value the property does not have, and more combinations than
a world holds (2^20) — the last before the product is built.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:36:25 +00:00
Ludovic MarieandClaude Opus 5.5 65a8b2aff0 horizon-server: vanilla's management protocol, its transport (JSON-RPC over a WebSocket)
management-server-* in server.properties ([properties] in server.toml) turns on
vanilla 26.3's management protocol, served by the supervisor so it answers while
the game restarts. As net.minecraft.server.jsonrpc does: the secret as a bearer
token or in the minecraft-v1 subprotocol from an allowed origin (401 bodies
included), JSON-RPC errors and batches as Connection handles them, by-name and
positional parameters as IncomingRpcMethod checks them, a generated secret
written back, and a wrong secret or TLS setting stopping the start.

This first part: the WebSocket (std only, websocket.rs) and rpc.discover
(vanilla's OpenRPC document, management_schema.rs). The other methods answer an
internal error saying they aren't in Horizon yet; they come next.

tools/test_management_protocol_parity.py sends the vanilla server and Horizon's
the same messages and handshakes and compares the answers, and rpc.discover with
the data generator's json-rpc-api-schema.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:36:16 +01:00
Ludovic MarieandClaude Opus 5.5 cc008d9526 Merge main into #5262; the F3 Density line moves into debug_entry_lines
Main's DebugEntryChunkGeneration line (#4389) was drawn inline in
draw_debug_overlay_under, which this branch replaced by debug_entry_lines:
it is now that function's chunk_generation_stats arm, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:35:49 +01:00
Ludovic MarieandClaude Opus 5.5 a84512a757 Merge origin/main (one Attack arm fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:34:23 +01:00
Ludovic MarieandClaude Opus 5.5 133a655baf Merge origin/main into main (Turkish)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:32:28 +01:00
NazaandClaude Opus 5.5 5501d1d355 A chat message that fails validation counts as processed, not shown
#5149's handle_chat_message_error had no tracker to tell. With one, a signed message that
failed validation never reached it, and the client's last-seen window drifted from the
server's. It now marks the signature as ChatListener.handleChatMessageError does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:32:14 -03:00
Ludovic Marie b8cf85f8c3 Merge pull request #5304 from horizon-rust/fix/main-build-hub-attack-arm
Main builds again: one Attack arm in the LAN guest inbox
2026-10-31 18:31:13 +01:00
sirrayiandDeepSeek Harness 2b9d830a3f tooling(port-map): ChatScreen.send's two rows
`ChatScreen.handleChatInput` and `ChatScreen.normalizeChatMessage` had no
verdict at all: the ledger reads them as unported rather than as the port they
are. Both point at what the message becomes on the way to the server now.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:31:04 +00:00
sirrayiandDeepSeek Harness f630fd43bb ChatScreen.normalizeChatMessage: the message a chat screen sends
A chat line went out as typed: Horizon sent the raw box text, where vanilla
sends `ChatScreen.normalizeChatMessage(s)` — `String.trim`, then
`StringUtils.normalizeSpace` (one space for every run of whitespace), then
`StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
`ServerboundChatPacket`'s `stringUtf8(256)` decodes).

Two player-visible differences:

- `send_chat` cut the message with `chars().take(256)`, so 256 emoji passed as
  512 UTF-16 units and the server refused the packet; a cut at 256 units is
  what vanilla does (129 emoji become 128).
- runs of spaces stayed: `hello   there` reached other players with its three
  spaces, and a line of nothing but spaces was sent, where vanilla sends
  nothing and remembers nothing in the history.

The normalization sits in `Game::handle_chat_input` (ChatScreen.handleChatInput),
so the chat box, the chat drafts and the control socket's `chat` all send what
a vanilla chat screen would. `java_whitespace` is reused from `ui.rs`, so
U+00A0 stays (Java whitespace excludes the no-break spaces) and U+3000 and
Java's U+001C..U+001F separators collapse.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:31:04 +00:00
sirrayiandDeepSeek Harness 9ef1541c02 fix(mobs): a dispensed spawn egg applies its entity_data too
`EntityType.updateCustomEntityTag` was ported for a player's egg
(`block_entity_item::spawn_egg_entity_data` then
`creatures.rs::Game::apply_spawn_egg_entity_data`) but not for the
dispenser's, whose `dispense_spawn_egg` stops after
`entity_nbt::apply_item_components`: a configured egg
(`/give @s pig_spawn_egg[minecraft:entity_data={…}]`) dispensed onto a block
spawned an unconfigured mob, which is the half of the
`EntityType.updateCustomEntityTag` row that pointed at
`game/dispenser.rs`.

`apply_spawn_egg_entity_data` becomes `pub(super)` and the dispenser calls it
after the components, as the player's path does.

`tools/port_map/game-005.tsv`: that row is `port` now.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:30:49 +00:00
Ludovic MarieandClaude Opus 5.5 825a6bbcf9 lan: one Attack arm in the guest inbox (main's clippy fails)
The anticheat's Attack arm and #5123's arm that keeps moves queued behind
an attack both landed in process_inbox; the second was unreachable, so
clippy -D warnings failed on main and #5123's behaviour was lost. The
anticheat arm now also holds the queued move when it forwards the attack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:49 +01:00
rdbtCVSandClaude Opus 5.5 4db04d87d5 CI: run verify's checks in one job again
f2a99d623 split verify into python, clippy and tests jobs plus a gathering
verify: four GitHub-hosted runners per checks run instead of one. With every
pull request push and every merge to main starting checks, that filled the
organisation's shared runner pool, and every workflow's jobs (labels, overlap
notes, other pull requests' checks) queued behind it for tens of minutes.

verify runs the public-tree check, the Python tests, rustfmt, clippy and the
Rust tests one after another again, on one runner. A pull request's
public-tree check still reads only its own commits (--range base..head).

Closes #5302

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:29:41 +00:00
Ludovic Marie 3b1e813d9e Merge pull request #5300 from horizon-rust/fix/mod-registry-lockfile
Mod registry: refresh its Cargo.lock (Railway deploys failing)
2026-10-31 18:29:19 +01:00
Ludovic Marie b783268374 Merge main into #4967
# Conflicts:
#	crates/horizon-client/src/mobs/golem.rs
2026-10-31 18:28:50 +01:00
Ludovic Marie 097cc8e882 Merge pull request #5100 from kacpherek/fix/trackers-blocks
🤖 Agent: merged main (#5251's build fix: the verify failure was main's Audio compile break); requeued
2026-10-31 18:28:34 +01:00
Ludovic Marie eec161a4cc Merge pull request #4011 from DUzzL/fix/issue-4001-boat-drowning
🤖 Agent: merged main. Main's 45c36d872 had added the client side of the same rule (RiddenBoat in motion/fluids.rs), so I kept main's type and logic and this PR now fills it for served players (lan_actions.rs → boats.rs riding_boat_fluid_box), where the air supply and drowning are decided; port map row updated. cargo check -p horizon-client --bins passes, fmt clean. Approved by rdbtCVS.
2026-10-31 18:28:30 +01:00
Ludovic MarieandClaude Opus 5.5 b4956c8a93 Mod registry: refresh its own Cargo.lock for horizon-mods' new zip dependency
horizon-mods gained the zip crate, but the registry's separate lockfile
wasn't updated, so Railway's `cargo build --locked` refused to update it
and every deploy of horizon-mod-registry failed (one failure mail per
merge to main). Minimal update: only the new packages are added.

Checked: the Dockerfile's exact build (same files copied, --release
--locked) finishes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:28:26 +01:00
Ludovic Marie ee98c8e5ab Merge pull request #5208 from nathaniel-ramos-dev/fix/stuck-sneak-on-focus-loss
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: on focus loss a held (non-toggle) sneak/sprint/attack/use mapping is released before the re-sync, toggles keep their latch (ToggleKeyMapping.setDown/release); each frame a Shift/Control the keyboard's modifier state reports up clears its stale physical key and held latch. CI failures were main's Audio build break (#5251).
2026-10-31 18:28:26 +01:00
Ludovic Marie 8fd9690bff Merge pull request #4176 from Domanffe/feat/launcher-accent-hue
🤖 Agent: merged main (clean) on top of Domanffe's 2b2c2893e, which fixes dimaniojk's two findings (drag off the strip, the CLI's hue); earlier CI failures were main's broken Audio initializer. Launcher-only; cargo fmt clean. Approved by rdbtCVS.
2026-10-31 18:28:21 +01:00
Ludovic Marie 8f7ec89520 Merge #4975's head (a7ce858e0) into the trade_rebalance branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
2026-10-31 18:28:20 +01:00
Ludovic Marie c2606393fc Merge pull request #4389 from xFedeT/feat/f3-density-line
🤖 Agent: F3 Density line as NoiseBasedChunkGenerator.addDebugScreenInfo under the chunk_generation_stats entry, sampled on a new feet block from the server thread's generator; main merged (conflicts with the per-entry F3 and explorer maps resolved, both kept), horizon-client checks clean; rdbtCVS approved.
2026-10-31 18:28:17 +01:00
Ludovic Marie be1b857a80 Merge pull request #4335 from Teiwazik/shader/s1-material-behind
🤖 Agent: Shader API: the material stage gets opaque_depth/opaque_color copies taken before translucent terrain and MaterialInput.screen (fragment position over resolution, as Teiwazik showed); rdbtCVS approved after merging #4186's tags; main merged. Only native shader packs see it.
2026-10-31 18:28:13 +01:00
Ludovic Marie 1b789e5f10 Merge pull request #3985 from DUzzL/fix/issue-3978-skip-software-adapter
🤖 Agent: merged main into the branch (main's broken Audio initializer made the earlier CI fail, fixed on main by f80c0360d); cargo check -p horizon-client-render passes. Change read: two adapter passes, GPUs first, software only with a warning. Approved by rdbtCVS.
2026-10-31 18:28:08 +01:00
Ludovic Marie 73fe322f3f Merge pull request #4440 from masa-masa-0314/fix/function-tick-order
🤖 Agent: functions tick before the levels (MinecraftServer.tickChildren) and an item added this tick first ticks on the next (EntityTickList), so a fresh drop reports Age 0; the freeze dimaniojk found is fixed (only items with a later id are skipped); type_specific/player checks ServerPlayer and input.sneak on the server. rdbtCVS approved; main merged.
2026-10-31 18:28:03 +01:00
readerkidandopencode d2e17a70c0 Shulker boxes: grow the lid's hitbox and push what it meets (#4882)
The lid animated but the collision queries always answered "no box": a
closed box collided as its plain block and an opening one never grew. The
collision view now reads the boxes' lid progress, the level ticks those
lids on its server pass (not only the client-only animation), and an
opening lid applies moveCollidedEntities to the bodies the game moves.

Vanilla: ShulkerBoxBlock.getCollisionShape / getShape (isClosed),
ShulkerBoxBlockEntity.getBoundingBox / getProgressDeltaAabb /
moveCollidedEntities.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 10:27:59 -07:00
Ludovic Marie d148bcb28b Merge pull request #4958 from MSedgeMC/fix/mooshroom-variant-component
🤖 Agent: merged main (port map: main's suspicious stew rows kept, this PR's variant rows). Mooshroom variant component as MushroomCow.get; test fails without it.
2026-10-31 18:27:58 +01:00
Ludovic Marie c5705fe8c9 Merge pull request #5237 from horizon-rust/perf/spawn-lookups
🤖 Agent: read the diff: placement checked before the spawn-list lookup (both pure reads, no RNG), species placement cached in MobData, LocalMobCapCalculator's lists flattened; draws and positions unchanged; A/B on playhorizon −18 % spawn_natural. CI parity runs on the head merged into main.
2026-10-31 18:27:52 +01:00
Ludovic Marie 16ed874dad Merge main into #4891
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 18:27:50 +01:00
Ludovic Marie 685cf4a18c Merge pull request #4407 from SlaVKsVolks/contribute/horizon-benchmark-report-20261030
🤖 Agent: perf_bench --table prints the missing-value marker when a report has no scene-load measurement, keeps a measured 0.0 and the mean of measured scenes; regression test covers all three. Tooling only; rdbtCVS approved; CI green. Marked ready from draft.
2026-10-31 18:27:48 +01:00
Ludovic Marie ae8f6c2448 Merge pull request #5129 from AurelienBelloir/test/lists-lock-fork-retry
🤖 Agent: requeued at the same head: the merger stopped only on rdbtCVS's approving review.
2026-10-31 18:27:44 +01:00
Ludovic Marie c0b65f1d24 Merge pull request #5072 from oskal675-design/feat/launcher-shell-completions
🤖 Agent: verified: horizon-launcher completions <shell> via clap_complete over the existing command tree, no network or file writes, test per shell; approved as code owner. Thanks!
2026-10-31 18:27:39 +01:00
Ludovic Marie c072a3e493 Merge pull request #5073 from mulareal/fix-entity-shadow-cull-4965
🤖 Agent: verified: vanilla's ENTITY_SHADOW / OIT_ENTITY_SHADOW pipelines take RenderPipeline.Builder's default cull(true); the shadow quads wind +Y (test added), so the underside is culled. Thanks!
2026-10-31 18:27:34 +01:00
Ludovic Marie 06485bfef8 Merge pull request #5090 from k3yh/fix/nbt-root-name
🤖 Agent: verified against NbtIo.readUnnamedTag (StringTag.skipString): a file root's name that isn't modified UTF-8 reads as empty; keys and truncated names still fail. Proof: CI parity. Thanks!
2026-10-31 18:27:30 +01:00
rdbtCVSandClaude Opus 5.5 d86d2ec555 Regenerate the mod-registry lockfile: main's --locked clippy rejects it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:27:07 +00:00
Ludovic MarieandClaude Opus 5.5 cc080bf72a Launcher: Cargo.lock for horizon-protocol
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:49 +01:00
Ludovic MarieandClaude Opus 5.5 4ba9e95bf9 Merge main; native damage hook as a fragment; canceled hits credit no attacker
Merges origin/main, moves the living_incoming_damage hook to
tools/native-hook-fragments (main's API stays as is), and drops the attackers
a canceled hit noted, so the next hit of that damage type isn't credited to
them (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:40 +01:00
Ludovic MarieandClaude Opus 5.5 78e288212a Merge origin/main into fix/long-string-disk-write
nbt.rs: the Writer keeps both flags, this branch's empty_long_strings
and main's canonical_nan (both on for the disk writer, off for the
network one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:17 +01:00
loicpillerandClaude Opus 5.5 54ded421ce fix(input): read Ctrl+Q and agent releases from the same modifier state
Q over a slot and Q in play decided whether to throw the whole stack from
physical_keys, while every other screen check reads Control from the OS
modifier word (has_control_down). The two could disagree after a lost or
lagging key event. drop_whole_stack now takes the modifier word; a latched
sprint toggle still never counts as Control.

The control socket's `release` cleared keys, physical keys and modifiers
but not the sneak/sprint toggle mappings, so a sneak pressed through
`key shift down` came back on the next sync_toggle. It now releases both
mappings as their key's release does (a toggled one stays latched) unless a
controller still holds the action, then resyncs the held keys.

Also:
- `key` reports an unknown action, with its name, from the single early
  check; the later match arm that repeated it could no longer run.
- lan_e2e check_recipe_shift reads the recipe buttons before the tab loop,
  so a book without a 35x27 tab fails on its assertion and target dump
  instead of a NameError.

Validation on macOS arm64:
- cargo test --locked -p horizon-client --bin horizon-client: 359 passed,
  3 ignored (including stack_drop_requires_ctrl_even_with_sprint_latched,
  now driven through modifiers::set_key).
- cargo clippy --locked -p horizon-client --lib --bins --tests
  -- -D warnings, cargo fmt --all --check, lan_e2e.py syntax and
  tools/check_public_tree.py: passed.

Still to run with prepared resources: lan_e2e --check recipe-shift
--check modifier-release, and Ctrl+Q in an inventory and in play.

Refs #4166

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandClaude Opus 5.5 da36881cc7 fix(input): keep synthesized Shift clicks and agent releases on the modifier word
Screens now read Shift from the OS modifier word (has_shift_down), but two
synthesized shift-clicks still only inserted ShiftLeft into physical_keys:
the controller's Y quick-move in an inventory (pad_menu.rs) and horizon-ctl's
`station ... shift` click (station_control.rs). Both became plain pickups.
They now go through modifiers::hold/restore, which sets the SHIFT bit with
the physical key and afterwards puts back exactly what was held before:
a Right Shift already down, or an OS word set without a physical key, stays.

The control socket's `key <mod> up` returned before releasing the modifier
when a server dialog or the sign editor was open, or with no world, leaving
SHIFT set so every later inventory click became a QUICK_MOVE. The release
that already ran after `tap` whatever the handler did now runs after `up`
too (released_modifier), so no early return can keep a modifier down.

Tests: modifiers::a_held_click_restores_the_previous_state and
control::taps_and_ups_always_release_their_key.

Validation on macOS arm64:
- cargo test --locked -p horizon-client-input --lib: 64 passed.
- cargo test --locked -p horizon-client --bin horizon-client control::tests:
  passed.
- cargo clippy --locked -p horizon-client-input -p horizon-client --lib
  --bins --tests -- -D warnings, cargo fmt --all and
  tools/check_public_tree.py: passed.

Still to run on a machine with prepared resources: controller Y on a slot
in the survival and creative inventories, and lan_e2e --check
modifier-release --check clicks.

Refs #4166

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandOpenAI Codex gpt-6.1-sol f014dd11ed fix(input): reconcile screen modifiers without releasing other bindings
Read Shift and Control for screens from the OS modifier word instead of
inferring them from cached physical key events. This follows Minecraft
Java 26.3 InputWithModifiers.hasShiftDown/hasControlDown and prevents a
missed key release from turning subsequent inventory clicks into transfers.

Reconcile lost releases only for physical modifiers that were recorded as
held and are still assigned to the affected sneak/sprint mapping. Preserve
rebound non-modifier keys, controller-held actions and keyboard/controller
toggles. Include controller releases delayed until the next simulation tick.
This avoids cancelling crouch bound to C when the player presses Alt, or
cancelling a controller hold because the keyboard does not hold Shift.

Keep synthesized modifier presses and releases consistent with the OS word.
Releasing one side preserves the bit while the other side is still held.
Validate control key actions before mutating state. Run tap cleanup after
the command handler even if it returns early or fails, and clear the modifier
word on the control socket's release command. This prevents key shift tap
or key shift down followed by release from leaving later clicks modified.

Add five input tests covering modifier press/release, left/right holds,
rebindings, missing physical keys, controller holds and controller toggles.
Add the modifier-release LAN check: after a Shift tap or release, a normal
inventory click must pick up ten diamonds and closing must preserve the
server's count. Remove the obsolete pad-toggle getters introduced by the
previous broad reconciliation, since ToggleKeys already handles toggles.

Validation on macOS arm64 on main 9069aea3e:
- cargo test --locked -p horizon-client-input --lib: 63 passed.
- cargo clippy --locked -p horizon-client-input --all-targets -- -D warnings:
  passed.
- cargo clippy --locked -p horizon-client --lib --bins --tests -- -D warnings
  -A unreachable-patterns: passed. The allowed lint is main's duplicate
  S::Attack arm in lan/hub.rs, a file this branch does not touch.
- cargo fmt --all --check, Python harness syntax, git diff --check and
  tools/check_public_tree.py: passed.

Prepared Minecraft resources are absent here. The new modifier-release
check and final game-level verification remain pending on the dedicated
machine; prior LAN results must not be treated as validation of this change.
Rebuild binaries from this commit, then run:
  python3 tools/lan_e2e.py --check modifier-release --check clicks \
    --check shift-double-click --check recipe-shift --check shift-drag \
    --check crafting --check wheel --out .local/validation-4166-review
Also rerun pr_ready.py with the appropriate main remote as its base.

Manual checks still required: hold rebound crouch/sprint while pressing
and releasing Alt; repeat with controller holds and toggle modes; confirm
lost Shift releases still recover with the default sneak binding. Verify
Shift taps and release before normal inventory clicks. Do not use the
deprecated in-frame path; keep game, launcher and XDG homes in fresh
scratch directories as required by AGENTS.md.

Refs #4166

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandOpenAI Codex gpt-6.1-sol 4f1c3ce532 fix(inventory): distribute carried stacks during Shift drags
A carried stack already starts QUICK_CRAFT in Clicks::press, but
inventory_press only initialized its hovered-slot collection without Shift.
Holding Shift therefore prevented the distribution gesture from completing.

Start the drag with either modifier state. Keep PICKUP_ALL restricted to
non-Shift double-clicks, and leave the Shift drag initially empty so a
press/release without any dragged slot retains its QUICK_MOVE behavior.
Follow Minecraft Java 26.3 AbstractContainerScreen.mouseClicked,
mouseDragged and mouseReleased; use the existing menu and server click path.

Add three LAN checks driven by real pointer events, rather than click-slot:
- shift-drag: spread 20 diamonds across three crafting slots as 6/6/6 with
  two carried, or 1/1/1 with seventeen carried, with and without Shift.
- shift-double-click: transfer chest stacks of 8, 13 and 21 diamonds while
  keeping a carried stack of 32; spaced clicks transfer only the first pile.
- recipe-shift: place two diamond-block recipe sets from 20 diamonds using
  the recipe book, then Shift-click the result to produce two blocks and
  retain two diamonds.

The latter two behaviors already worked and receive regression coverage.
Resolve pointer targets from the current UI and include movement inside the
starting slot so the drag actually traverses all selected slots. Check
carried stacks, grid contents and server inventory counts after closure.

Evidence reported by the dedicated-machine agent before this rebase:
Shift drags failed on the old base and passed after the fix; clicks,
shift-double-click, recipe-shift, shift-drag, crafting and wheel passed.
The container_screen, menu_click, place_recipe, recipe_book and
recipe_book_view differential harnesses were also reported passing.
Vanilla expectations came from the pinned sources and Java oracles; the
vanilla graphical client was not exercised. Those game runs cannot be
repeated on this checkout because its prepared resources are absent.

Refs #4166

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-31 18:23:50 +01:00
Ludovic MarieandClaude Opus 5.5 976cd3be1b Merge origin/main into the trade_rebalance branch
Conflicts: datapack.rs takes both sides' kinds (trade_set,
villager_trade and dialog); game.rs saves through main's
write_level_for_role, then each placed player to their own file;
lan.rs syncs dialog ids, then sends the world's own biome table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:49 +01:00
Federico TaglianiandClaude Sonnet 5.5 c7ddfb8ebc Piglin brutes pick up a golden axe; piglins admire a loved item they only see
PiglinBrute.wantsToPickUp takes only a golden axe, through Mob.aiStep's
looting loop. StartAdmiringItemIfSeen sets ADMIRING_ITEM (119 ticks) for a
loved item in sight, and StopAdmiringIfItemTooFarAway(9) and
StopAdmiringIfTiredOfTryingToReachItem(200, 200) end it. A blow from a
player disables admiring for 400 ticks (ADMIRING_DISABLED).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 18:23:35 +01:00
Ludovic Marie f25edbe119 Merge main into #4785 2026-10-31 18:22:40 +01:00
Ludovic Marie 9831e6d146 Merge main into #4932 2026-10-31 18:22:36 +01:00
Ludovic Marie 86e0d83dbb Merge branch 'a3-4444' into a3-4445 2026-10-31 18:21:44 +01:00
Ludovic Marie 8de268aad8 Merge branch 'a3-4443' into a3-4444 2026-10-31 18:21:42 +01:00
Ludovic Marie de0508a004 Merge branch 'a3-4442' into a3-4443 2026-10-31 18:21:41 +01:00
Ludovic MarieandClaude Opus 5.5 0c3831d711 Merge main into shader-packs/02 (zip 9.1.3 from #3832 beside this part's half and png)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:21:40 +01:00
Ludovic Marie 9069aea3e2 Merge pull request #3826 from martipops/split/10-profile-page
🤖 Agent: read the diff at 7fc284b (rdbtCVS's main merges + Escape-as-Cancel fix): the splitscreen profile page as a model with its store and tests in horizon-client-render, not wired into play yet; dimaniojk's paging finding fixed. Part of the splitscreen series whose earlier parts merged.
2026-10-31 18:21:05 +01:00
Ludovic Marie 136796628c Merge pull request #5267 from reservedbytes/fix/content-identity-items
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: an items-only content mod now writes the identity marker, and the identity hashes the mods' item and mob effect entries per registry; a root without mod entries keeps its block-only hash (unit test). Fails safe with the content-mods mismatch message across versions.
2026-10-31 18:21:00 +01:00
Ludovic Marie 6825d43665 Merge pull request #5247 from horizon-rust/perf/spans-mob-phases
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. two spans (spawn_natural, tick_creature_entities) inside tick_creatures, timed only when spans are on; no behaviour change. CI failures were main's Audio build break (#5251).
2026-10-31 18:20:56 +01:00
Ludovic Marie e540cdbbaa Merge pull request #5256 from reservedbytes/fix/spear-no-knockback
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: spear is in #minecraft:no_knockback, so LivingEntity.hurtServer skips dealDefaultKnockback for a stab; Damage::no_knockback now lists Spear; unit tests. CI failures were main's Audio build break (#5251).
2026-10-31 18:20:52 +01:00
Ludovic Marie fb1a9bbecf Merge pull request #5035 from haruki55go/patch-1
🤖 Agent: change request addressed (ja wired in, tests pass); approved as code owner
2026-10-31 18:20:48 +01:00
Ludovic Marie 8d40bd70c6 Merge pull request #5066 from Sylviromi/content-creative-tabs
🤖 Agent: merged main (both sides added tests at the end of mod_content_tests.rs: kept both); cargo check of the lib and tests passes; review points fixed by the author (02a7c1553 cherry-picked); marked ready
2026-10-31 18:20:43 +01:00
Ludovic Marie bddb77819d Merge pull request #5244 from arismoko/pickup-invalidation
🤖 Agent: re-read: the new head only merges main into the reviewed pickup fix (guest pickup keeps item ids, re-finds the item after the native hook).
2026-10-31 18:20:39 +01:00
Ludovic MarieandClaude Opus 5.5 dbc8cfc208 Turkish: wire tr into the launcher and the game's own strings
Launcher: Language::Turkish (code tr, Türkçe, picked from a tr-* system
locale), tr.json brought up to main's strings (25 native-mods strings
translated, 5 the launcher no longer asks for dropped, one {version}
placeholder fixed). Game: tr_tr.json joins the bundled Horizon strings.
Built on #5035's Japanese wiring so the two don't conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:36 +01:00
Ludovic Marie 9e48cf7cc8 Merge pull request #5167 from levzzz5154/mods/native-entity-join
🤖 Agent: finished by me (94027c278): merged main, API number as a fragment, the canceled-join crash fixed (just_spawned_item), and a same-level End gateway trip no longer joins again. native_ tests pass.
2026-10-31 18:20:35 +01:00
Ludovic Marie 6d38e45e5b Merge pull request #5061 from Sylviromi/native-mod-review-followups
🤖 Agent: the review follow-ups from #3996–#4289 (screen only for the block's own menu, entity menus clear the block target); review points answered; marked ready
2026-10-31 18:20:28 +01:00
Ludovic Marie 62bf449a12 Merge pull request #5103 from k3yh/fix/region-header-rewrite
🤖 Agent: merged main into the branch (no conflicts left); requeued
2026-10-31 18:20:21 +01:00
Ludovic Marie 9d3722fb1c Merge pull request #5110 from k3yh/fix/lz4-java-decoder
🤖 Agent: requeued on rdbtCVS's merge of main (mergeable)
2026-10-31 18:20:17 +01:00
Ludovic MarieandClaude Opus 5.5 adda4f12c8 Merge main into #5241 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:15 +01:00
Ludovic Marie 6930dfd845 Merge pull request #5181 from Joansitoh/fix/4889-flat-world-item-icons
🤖 Agent: read the diff: Superflat layer and preset icons draw the item's GUI model (as CreateFlatWorldScreen's renderItem), clipped exactly to the list; other screens unchanged. Approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 18:20:12 +01:00
Ludovic Marie c1c609a058 Merge pull request #3502 from socksprox/feat/launcher-hide-partner-everywhere
🤖 Agent: one partner_hidden setting hides the sidebar card and the Servers banner; the 3-minute seen gate now counts on every page. rdbtCVS approved; launcher CI green.
2026-10-31 18:20:07 +01:00
Ludovic Marie 3c62df0a8b Merge pull request #3192 from kacpherek/fix/launcher-windows-update-no-relocated-copy
🤖 Agent: rename-aside Windows self-update with rollback replaces self_replace's self-deleting copy that Defender flags; start-up clean-up removes only this exe's .old/.update leftovers. Read in full; rdbtCVS approved; CI's launcher job builds Windows. A real Windows update after release is the remaining check.
2026-10-31 18:20:02 +01:00
Ludovic MarieandClaude Opus 5.5 2cefa54830 Merge main into feat/native-visual-hooks: native_visuals and native_emissive move to API 46
Main's #? took API 45 for native_block_entity_update_tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:18:07 +01:00
Ludovic MarieandClaude Opus 5.5 baf451aa0b /ride: force the ride as RideCommand.mount does, and fire started_riding
startRiding(vehicle, true, true) skips canAddPassenger, so a boat under
water still takes the rider; Horizon's boats seat at most max_passengers,
so a full one still refuses. Horizon's minecarts carry no mobs yet, said
in a comment. A player put in a boat or minecart now fires
minecraft:started_riding (sendEventAndTriggers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:18:05 +01:00
Naza 454cecac23 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:17:17 -03:00
Naza 787c70882b Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:17:16 -03:00
NazaandClaude Opus 5.5 2f570b013a Tests: the odd-length hex helper for this branch's keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:17:16 -03:00
Naza c00106cc04 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:16:51 -03:00
Naza 95a121e96e Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:16:50 -03:00
NazaandClaude Opus 5.5 ea371be23b Tests: one hex helper beside #5149's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:16:50 -03:00
Ludovic MarieandClaude Opus 5.5 b136900fd6 Merge main into #5233 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:16:37 +01:00
Naza d604ac139f Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:15:56 -03:00
Naza 88e226f7ec Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:15:54 -03:00
Ludovic Marie b28aaa7217 Merge branch 'a3-pr-4379' into a3-pr-4398
# Conflicts:
#	crates/horizon-shader-api/tests/schema.rs
2026-10-31 18:15:52 +01:00
NazaandClaude Opus 5.5 8b73bac6cb Merge main: this branch's tests beside #5149's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:15:52 -03:00
Ludovic MarieandClaude Opus 5.5 855206f914 Merge origin/main; official count follows SRV, game moves old entries too
#5235 keeps the address in horizon_telemetry::server_status::OFFICIAL_ADDRESS:
it is now play.playhorizon.dev, with OFFICIAL_PREVIOUS beside it; the
launcher's defaults and the game use both. The launcher's official player
count pings the address from links.json through its SRV record
(horizon_protocol::server_redirect + server_status::players_at): port 25565
on that host is another server. The telemetry server, which looks no SRV
up, defaults to the SRV target (OFFICIAL_STATUS_ADDRESS). The game moves
servers.dat entries for an earlier address once, like the launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:15:22 +01:00
Ludovic Marie f32c8836ad Merge main into #4584 2026-10-31 18:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 641919b93d Board carts only like vanilla: rideable, moving, no rider
pushAndPickupEntities only runs for a rideable cart moving at
horizontalDistanceSqr >= 0.01; an activator rail ejects mob passengers
too; a player can't board a cart a mob already rides (isVehicle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:14:08 +01:00
NazaandClaude Opus 5.5 6317f1bfaf Merge main; log what secure chat can't sign or randomise
A failed signature and failed OS randomness are logged instead of hidden, and the
ChatListener.flushQueue ledger row points at flush_chat_queue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:13:01 -03:00
Ludovic Marie 94283c8441 Merge branch 'a3-pr-4359' into a3-pr-4379
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
2026-10-31 18:12:50 +01:00
Ludovic MarieandClaude Opus 5.5 07a7721fd6 Merge main into #5262 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:12:40 +01:00
Ludovic Marie 8c53719432 Merge commit 'refs/pull/5035/head' of https://github.com/horizon-rust/horizon into sweepc-5067 2026-10-31 18:12:06 +01:00
rdbt d6e8bd67aa Merge pull request #5143 from arbelonson-source/fix/locator-bar-reconfigure
Clear waypoints when the server reconfigures the client
2026-10-31 17:11:35 +00:00
Ludovic Marie fb20c464f1 Merge branch 'a3-pr-4349' into a3-pr-4359
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_material.rs
#	crates/horizon-shader-api/src/stages.rs
2026-10-31 18:10:33 +01:00
Ludovic MarieandClaude Opus 5.5 d0f81cc039 Merge main into #5220; native_block_entity_sync takes its API number from a fragment
#4935 landed as API 45, and API numbers now come from
tools/native-hook-fragments (stamp_native_hooks.py after the merge): the
hand-written API 44 and its docs paragraph become
native-block-entity-sync.json. lan_bot keeps both new tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:10:25 +01:00
Ludovic MarieandClaude Opus 5.5 43a8eddf0a Launcher: official server address from links.json, moved lists follow it
The official server moves to play.playhorizon.dev (SRV to port 24454).
launcher/links.json now carries `official_server` and
`official_server_previous`, read through the existing Links mechanism with
the same built-in defaults, so a later move is a links.json edit, no release.

server_list::add_official offers the address from links, and when the
address differs from the one the marker file recorded, rewrites entries for
a previous address to the new one (player's name, picture and fields kept),
once. No duplicate when the new address is already listed; a player who
removed the server is not offered it again. The Home list re-runs it when
links.json arrives. The game's own built-in copy and docs/smp.md follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:09:26 +01:00
Ludovic Marie 30615b890d Merge pull request #3214 from I114rk/feature/boot-loading-screen
🤖 Agent: boot loading screen (GDI paint before the first wgpu frame, Windows); review points fixed (elythepreciouscat's commits), main merged, clippy clean.
2026-10-31 18:09:00 +01:00
Ludovic Marie 4a475325b4 Merge pull request #5032 from Sticky-Bug/feat/flower-pot-plant
🤖 Agent: requeued on the author's merge of main (mergeable)
2026-10-31 18:08:54 +01:00
Ludovic Marie 90030ddcdb Merge remote-tracking branch 'origin/main' into sweepc-5063 2026-10-31 18:08:30 +01:00
Ludovic MarieandClaude Opus 5.5 ed68b03b4e Merge origin/main into fix/conversion-save (main's build fix #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:08:03 +01:00
Ludovic MarieandClaude Opus 5.5 16305ab9c3 Merge origin/main into mods/native-entity-leave; API number as a fragment, gateway fix
- The hook asks for its API number with tools/native-hook-fragments/entity_leave_level.json
  (#5161); NATIVE_HOOKS_API, the histories and native-hooks.json are main's again.
- An End gateway keeps its traveller in the level (teleportSameDimension), so an item
  going through one no longer reports leaving it (arismoko's and rdbtCVS's point).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 ac676e7319 Merge origin/main into fix/trackers-blocks (main's build fix #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:07:59 +01:00
Ludovic Marie 39fa71cfe8 Merge pull request #5068 from doodlyyousuf/fix/4988-ui-label-centering
🤖 Agent: requeued: the new review is an approval
2026-10-31 18:07:43 +01:00
rdbt d932f5d96d Merge pull request #5122 from k3yh/fix/nbt-network-nan
Keep a NaN's bits in network NBT, as vanilla does (#5064, point 4)
2026-10-31 17:07:04 +00:00
Ludovic Marie ed829ea5f3 Merge pull request #4690 from calclb/fix/4529-player-death-message
🤖 Agent: requeued after main's Audio build break (failed jobs rerun); change unchanged.
2026-10-31 18:06:29 +01:00
Ludovic Marie bf0c334ddc Merge pull request #3580 from GIGABAIT93/feat/launch-api-prepare-inputs
🤖 Agent: horizon-prepare names the game files one by one (--version-json, --client-jar, --assets-dir…), still checked against research-lock.json pins; merged #3574's fix and main; horizon-prepare tests and clippy pass.
2026-10-31 18:06:24 +01:00
Ludovic Marie 8b4dc0dacc Merge pull request #3574 from GIGABAIT93/feat/launch-api-prepare-complete
🤖 Agent: horizon-prepare --complete and --progress json, documented in docs/api/launch.md; --check now refuses --progress/--complete (rdbtCVS's point). Merged main; horizon-prepare tests and clippy pass.
2026-10-31 18:06:19 +01:00
Ludovic Marie 9fd6279f36 Merge pull request #5149 from xFedeT/fix/chat-validation-error
🤖 Agent: requeued at the author's rebase on main (same change, one line less): vanilla's handlePlayerChat checks (global index, unknown sender, SignedMessageValidator) with chat.validation_error.
2026-10-31 18:06:16 +01:00
Ludovic Marie b1d9565677 Merge pull request #5269 from horizon-rust/sweep-c/finish-5107
🤖 Agent: follow-up to merged #5107: the LAN fallback needs a signed-in account (Mojang's rules); net test passes
2026-10-31 18:06:12 +01:00
Ludovic Marie 0749fa97e6 Merge pull request #4976 from Detimes/feature-4963-velocity-forwarding
🤖 Agent: read the diff: Velocity modern forwarding only with forwarding.secret and online-mode false (the proxy does the Microsoft login); HMAC-SHA256 checked in constant time (RFC 4231 vectors), version, lengths and trailing bytes checked, empty secret stops the start, in-memory joins never forwarded. Wire format from black-box recordings of Velocity 4.3.1. verify green; merging once parity passes.
2026-10-31 18:06:08 +01:00
Ludovic MarieandClaude Opus 5.5 8f38acad12 Merge main into #5253 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:06:06 +01:00
Ludovic Marie ef1658625c Merge pull request #5155 from xFedeT/fix/sulfur-cube-hit-angles
🤖 Agent: read the diff against vanilla's SulfurCube.knockback helpers (applyHorizontalHitAngleScale, applyVerticalHitAnglePowerTransfer, the position rotation) and Projectile.calculateHorizontalHurtKnockbackDirection; unit test matches the 26.3 helpers to 1e-9, author ran mob_ai arrow damage_source living_hurt server_knockback mob_attributes. CI green, parity runs on the head merged into main.
2026-10-31 18:06:03 +01:00
Ludovic Marie e05a09220f Merge pull request #5123 from mulareal/fix-attack-queued-moves-4524
🤖 Agent: read the diff: an attack or interaction now holds back a move queued behind it until the next tick, as PlayerAction and UseItemOn already do, so the game sees the player as vanilla's in-order packet handling does (falling crit). lan_bot test added; CI green, parity runs on the head merged into main.
2026-10-31 18:05:59 +01:00
Ludovic Marie d6665585e5 Merge branch 'a3-pr-4345' into a3-pr-4349
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-10-31 18:05:58 +01:00
Sylviromi 9971d320d3 Merge remote-tracking branch 'origin/main' into menu-button-v2 2026-10-31 20:05:43 +03:00
Ludovic MarieandClaude Opus 5.5 72645234c5 Merge #4335's updated branch (main with #4186) into shader/s2-frame-motion
#4186's precipitation/temperature/downfall/wetness come first in Frame (they are on main);
this branch's frame-motion fields follow at 416..512. Reference regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:05:36 +01:00
Ludovic MarieandClaude Opus 5.5 03e50c439d Merge main into #5267 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:05:04 +01:00
rdbtCVSandClaude Opus 5.5 fc0645c6be Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:49 +00:00
rdbtCVSandClaude Opus 5.5 352c6336ad Merge origin/main into fix/guest-blast-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:40 +00:00
rdbtCVSandClaude Opus 5.5 7fc284b46a Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:34 +00:00
Ludovic Marie 0ce359e5a4 Merge main into #4176 2026-10-31 18:04:26 +01:00
Ludovic MarieandClaude Opus 5.5 fa4017bdf6 Merge main into #5208 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:03:13 +01:00
Ludovic Marie 8125648039 Merge remote-tracking branch 'origin/main' into sweepc-5067 2026-10-31 18:02:36 +01:00
levzzz 1aedc168c7 Merge remote-tracking branch 'hr/main' into qw-5131
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 22:02:14 +05:00
MariuslozoandClaude Opus 5.5 03ec7ec551 Windows: run lan_e2e and agent_control_e2e without symlink privilege or vscreen
Three things kept tools/lan_e2e.py (and agent_control_e2e.py) from running
on Windows:

- The prepared resources were linked into the scratch home with os.symlink,
  which needs Developer Mode or an administrator (WinError 1314). The new
  lab.link_dir falls back to a directory junction there, which needs
  neither; shutil.rmtree removes a junction without touching its target.
- agent_control_e2e.game_env ran `vscreen up`, which doesn't exist on
  Windows. Windows has no virtual screen, so it takes plain lab.game_env,
  as macOS does.
- `horizon-ctl launch` never returned when its output was captured:
  Windows hands a child every inheritable handle of its parent, so the game
  kept the caller's stdout pipe open until it quit. horizon-ctl now clears
  the inherit flag on its own standard handles before it starts the game
  (horizon_threads::keep_stdio_from_children). Linux and macOS are
  unchanged.

Fixes #5274

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:02:03 +01:00
Ludovic MarieandClaude Opus 5.5 433ba6ecc3 Merge main into #5243 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:44 +01:00
Ludovic MarieandClaude Opus 5.5 b323704770 Merge origin/main into headless/loop-handle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:32 +01:00
Ludovic MarieandClaude Opus 5.5 9c32365c4d Merge main into #4011: use main's RiddenBoat for served players
Main's 45c36d872 added RiddenBoat for the client's own fluid box. The
served player's tick (lan_actions.rs) now fills the same type from the
ridden boat, so the server-side air supply follows it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:14 +01:00
MSedgeMC baaf6b5d4e Draw no falling block that sits inside its own state 2026-10-31 22:30:48 +05:30
Ludovic MarieandClaude Opus 5.5 3f52f4988f Merge origin/main into content-creative-tabs
Both sides added tests at the end of mod_content_tests.rs: keep main's
and append this branch's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:38 +01:00
Ludovic MarieandClaude Opus 5.5 94027c278e Merge origin/main into mods/native-entity-join; API number as a fragment, review fixes
- The hook asks for its API number with tools/native-hook-fragments/entity_join_level.json
  (#5161); NATIVE_HOOKS_API, the histories and native-hooks.json are main's again.
- A mod canceling an item's join no longer crashes a throw: spawn_item's callers
  touch the new item only if it joined (just_spawned_item), not whatever item is
  last (throw_from_eyes unwrapped it; allays, shearing and /give's overflow
  edited an unrelated item).
- An End gateway keeps its traveller in the same level (teleportSameDimension), so
  the item doesn't join again on arrival (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:35 +01:00
mularealandAntigravity 190e96938b gfx: upload atlas patches on skipped frames and refresh skins after pack reload
Fixes #5097

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 18:00:31 +01:00
Ludovic MarieandClaude Opus 5.5 2426dbb368 Merge main into #5252 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:22 +01:00
mularealandAntigravity b4980bc779 Sync item entities immediately after velocity jumps and slime bounces (#5080)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 18:00:02 +01:00
mularealandAntigravity 12cdd1c6fe Beds: sample sleeper light at eye height, zero sleeping pitch, and stand beside bed on wake (#5121)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 17:59:20 +01:00
levzzz b3c3bc2843 Merge remote-tracking branch 'hr/main' into qw-5188 2026-10-31 21:59:11 +05:00
levzzz 4c7b07a6ea Merge remote-tracking branch 'hr/main' into qw-5130 2026-10-31 21:59:10 +05:00
Ludovic MarieandClaude Opus 5.5 acf4da15a4 Spans: time natural spawning and the mobs' ticks inside tick_mobs
tick_mobs is half of a crowded server's tick, and its two big parts
(NaturalSpawner and the entity ticks) move independently with the players'
spread. Two nested spans let the slow-tick log and horizon-report tell them
apart; with spans off they cost one relaxed load each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:59:02 +01:00
Ludovic MarieandClaude Opus 5.5 0784050d6f Merge main into #5254 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:58:50 +01:00
Ludovic Marie a58349cd55 Merge remote-tracking branch 'origin/main' into feat/native-sidebar 2026-10-31 17:58:17 +01:00
Ludovic MarieandClaude Opus 5.5 5a44e816c6 Merge origin/main into fix/region-header-rewrite
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:40 +01:00
rdbt 571ec1b76e Merge pull request #5060 from MSedgeMC/fix/impulse-liquid-reset
End a wind charge's fall context when the player lands in water
2026-10-31 16:57:24 +00:00
Ludovic MarieandClaude Opus 5.5 1b9ed487ae Merge main into #5256 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:23 +01:00
Ludovic MarieandClaude Opus 5.5 498aea7864 Boot atlas: set main's new HudAtlas::text_shader field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:16 +01:00
rdbtCVSandClaude Opus 5.5 7fddcb2ec5 Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:55 +00:00
rdbtCVSandClaude Opus 5.5 5e8c60d4d3 Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:52 +00:00
rdbtCVSandClaude Opus 5.5 a7ce858e0e Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:45 +00:00
reservedbytes e3308bf498 feat(mods): native mods give their mob effects behaviour 2026-10-31 15:56:34 -01:00
SylviromiandClaude Opus 5.5 dfd7903066 Native mods: menu_button_v2, a mod menu button with the clicking player's cursor stack
Vanilla's AbstractContainerMenu.clickMenuButton(Player, int) reaches the player's carried stack;
native_menu_button(pos, entity, id) does not, so a mod's widget outside the slots (a tank taking a
bucket) cannot take or give what the player carries. native_menu_button_v2 fires right after it with
`carried: &mut Option<ItemStack>`; a change is written back and sent to the player (SetCursorItem).
Single player's player is a guest of its own host, so one path covers it and LAN guests.
native_menu_button is unchanged (frozen). The API number comes from the fragment when stamped.

Closes #5246

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:56:18 +03:00
Ludovic Marie b8f8c2515d Merge pull request #5202 from socksprox/fix/native-build-nonascii-path
🤖 Agent: read the diff: native-mod builds run under an ASCII path (resolved data dir, 8.3 short name, or a ProgramData folder named by hash) so MinGW-w64's ANSI tools work for a non-ASCII Windows user name (#5189); tests for the prefix stripping and ASCII check. Approved as code owner; CI green.
2026-10-31 17:56:14 +01:00
Ludovic Marie 0b93c4e79e Merge pull request #4935 from ErikKalikas/mod-block-entity-sync
🤖 Agent: native mods' block entities reach clients in the chunk and BlockEntityData (empty update tags dropped as BlockEntityInfo.create does); arismoko's replaced-block case fixed and checked in game; main merged and the hook API renumbered to 43. Merging once CI and parity pass.
2026-10-31 17:56:09 +01:00
Ludovic MarieandClaude Opus 5.5 0ade40a885 Merge main into #5264 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:56:05 +01:00
Ludovic Marie 258a11a817 Merge pull request #4787 from WooCaash/fix/4510-falling-block-ghost
🤖 Agent: merged main into the branch: main's dedicated event records (chat, join, leave) moved into the shared lan_process_events, and main's flush order (#4765) kept with the packet gate reset at the end of tick_lan. Merging once CI and parity pass.
2026-10-31 17:56:04 +01:00
Ludovic Marie 546c1c71fc Merge pull request #4539 from kacpherek/fix/trackers-batch
🤖 Agent: merged main into the branch (item_tooltip.rs: kept both the tropical fish bucket lines and main's map_id_lines). 15 mob fixes, rdbtCVS's points fixed; merging once CI and parity pass on this head.
2026-10-31 17:56:00 +01:00
Ludovic Marie a430b573f2 Merge remote-tracking branch 'origin/main' into a3-pr-4413 2026-10-31 17:55:55 +01:00
Ludovic Marie 07d7887bef Merge pull request #5101 from kacpherek/fix/trackers-rendering
🤖 Agent: seven render fixes checked against vanilla (EndermanRenderer.getRenderOffset, CopperGolemModel.translateToHand, Conduit/EnchantTable crumbling, DolphinCarryingItemLayer, CompositeBlockModel special models, Phantom size save/sync, getBlockLightLevel overrides). Parity rerun once for world_spawn#3/pearl_wind_charge, which it doesn't touch. Thanks!
2026-10-31 17:55:55 +01:00
Ludovic Marie a8763ea259 Merge remote-tracking branch 'origin/main' into a3-pr-4335 2026-10-31 17:55:53 +01:00
Ludovic Marie 29826a7558 Merge pull request #4434 from horizon-rust/fix/mod-registry-kind-and-retry
🤖 Agent: mod registry: a mod may turn native once; a retry takes up its own form branch, proven by an HMAC submission tag under its own derived key (elythepreciouscat's fix for rdbtCVS's point). Registry fmt/clippy/tests pass.
2026-10-31 17:55:51 +01:00
Ludovic Marie e48e87a422 Merge pull request #5258 from horizon-rust/sweep-c/finish-5062
🤖 Agent: #5062 by MSedgeMC (TamableAnimal.getTeam, verified) plus an O(n²) fix in mob_team; cargo check clean. Thanks MSedgeMC!
2026-10-31 17:55:47 +01:00
Ludovic Marie 6efd5166c2 Merge pull request #5257 from horizon-rust/agent/finish-4923
🤖 Agent: #4923 finished: calibration ends off the Options screen and its fullscreen is not saved as the option (rdbtCVS's review). HDR off by default; merging once CI and parity pass.
2026-10-31 17:55:43 +01:00
Ludovic Marie d7709584f0 Merge pull request #4933 from socksprox/fix/4912-macos-keychain-items
🤖 Agent: one macOS keychain item per account (the 1 KB split was a Windows Credential Manager limit); rdbtCVS's point fixed in f7f274772: a refused delete writes the whole old layout back, tested halfway and at once. verify green.
2026-10-31 17:55:37 +01:00
Ludovic Marie 73274bb060 Merge remote-tracking branch 'origin/main' into a3-pr-4389
# Conflicts:
#	crates/horizon-client/src/game/debug_keys.rs
#	crates/horizon-client/src/mod_content_tests.rs
#	crates/horizon-client/src/vanillagen.rs
#	tools/lan_e2e.py
2026-10-31 17:55:34 +01:00
Ludovic Marie 4cac6f30db Merge pull request #4868 from socksprox/fix/fake-player-hitbox
🤖 Agent: scaled hitboxes (LivingEntity.getDimensions), isPickable = !isRemoved, interaction-range attributes in GameRenderer.pick; guest reach per acting player. rdbtCVS's points fixed (CR dismissed). verify green.
2026-10-31 17:55:32 +01:00
Ludovic Marie a19985b75f Merge pull request #3827 from abit-foggy/perf/rd32-tick-stutter
🤖 Agent: incremental tick chunk membership and fresh-chunk tracking; rdbtCVS approved after three rounds (remove/re-add replay, key order, foreign revisions). verify green; parity runs on the merged head.
2026-10-31 17:55:27 +01:00
Ludovic Marie 3eb3e44ba6 Merge pull request #3499 from horizon-rust/codex/release-launcher-warning
🤖 Agent: game release notes repeat the launcher warning above the disclaimer; launcher notes unchanged; tests updated; CI green.
2026-10-31 17:55:23 +01:00
Ludovic MarieandClaude Opus 5.5 068791f1cc Merge origin/main into #4484 (the book click after main's native screen press)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:55:19 +01:00
Ludovic Marie 1363bd83b6 Merge pull request #5120 from Sylviromi/native-recipes-on-servers
🤖 Agent: read the diff: the three recipe helpers now return nothing only on a client with no player, so a server's Game answers them. Tested in game by arismoko (before/after), approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 17:55:19 +01:00
Ludovic Marie f21ce0241f Merge pull request #5235 from horizon-rust/feat/official-smp-stats
🤖 Agent: CI green on 8c913dff6 (clippy, launcher, tests, python, verify), merges cleanly on main after #5217. Telemetry server checked in its own workspace (fmt, clippy -D warnings, tests); the status ping was checked against the real official server. Official SMP count in the launcher and its history on the telemetry server.
2026-10-31 17:55:16 +01:00
Ludovic Marie 1fed25618f Merge pull request #5239 from horizon-rust/chore/discord-invite
🤖 Agent: Ludovic's decision (2026-10-31). A one-line or link-only change; merges once CI is green.
2026-10-31 17:55:10 +01:00
Ludovic Marie 670454eeb8 Merge main into #3986 2026-10-31 17:55:09 +01:00
rdbtCVSandClaude Opus 5.5 38ca649113 Merge origin/main into fix/frame-map-updates
Keep main's framed tuple and check for a filled map inside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:54:28 +00:00
rdbt 236219d649 Merge pull request #3832 from horizon-rust/fix/update-zip-security
Update zip to 9.1.3
2026-10-31 16:54:25 +00:00
Ludovic Marie cf83188a6d Merge remote-tracking branch 'origin/main' into ely3214 2026-10-31 17:53:47 +01:00
rdbtCVSandClaude Opus 5.5 d4d5c08c38 Restore #5251's compile fixes that the merge commit dropped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:53:26 +00:00
Ludovic MarieandClaude Opus 5.5 d07eeca212 Launcher: offer Japanese, and bring pt-br up to date
Wire ja.json into Language (code ja, name 日本語), translate the native
mods strings ja.json and pt-br.json lacked and drop the five strings the
launcher no longer asks for, so every language has every string again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:53:23 +01:00
Ludovic Marie 51827488ed Merge remote-tracking branch 'origin/main' into a3-pr-4440 2026-10-31 17:52:54 +01:00
rdbtCVSandClaude Opus 5.5 94df2b6183 Merge origin/main: keep locked_message beside the vehicle menus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:52:52 +00:00
Sticky-Bug 06d1db6295 Merge remote-tracking branch 'origin/main' into feat/flower-pot-plant 2026-10-31 22:22:44 +05:30
Ludovic MarieandClaude Opus 5.5 c9fdb5f790 Merge origin/main into #4958 (port map: main's stew rows, this PR's variant rows)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:52:35 +01:00
Ludovic Marie fa23bd1019 Merge remote-tracking branch 'origin/main' into pr5035 2026-10-31 17:52:28 +01:00
Ludovic Marie 2c613da8c5 Merge main into #3985 2026-10-31 17:51:36 +01:00
Ludovic Marie 5595b0225f Merge branch 'pr3574' into pr3580 2026-10-31 17:51:32 +01:00
arismoko d8882f9cb4 Merge remote-tracking branch 'upstream/main' into hook-fragments-workflow 2026-10-31 11:51:31 -05:00
Ludovic Marie 7fcec44100 Merge remote-tracking branch 'origin/main' into pr3574 2026-10-31 17:51:31 +01:00
arismoko 208bb011e8 Merge remote-tracking branch 'upstream/main' into pickup-invalidation 2026-10-31 11:51:30 -05:00
rdbtCVSandClaude Opus 5.5 2138e38349 Merge origin/main: keep both the wither skull and firework damage kinds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:51:25 +00:00
Ludovic Marie b305a5a5a0 Merge branch 'pr3574' into pr3580 2026-10-31 17:51:24 +01:00
arismoko 8d7ccfd5f4 Merge remote-tracking branch 'upstream/main' into native-stop-flush 2026-10-31 11:50:44 -05:00
Ludovic Marie 9816939333 Merge pull request #5251 from levzzz5154/fix/audio-share-device-fields
Main builds again: splitscreen audio shares and the stew particles
2026-10-31 17:50:33 +01:00
Ludovic Marie 9c9ab69366 Merge remote-tracking branch 'origin/main' into pr5107 2026-10-31 17:50:23 +01:00
rdbtCVSandClaude Opus 5.5 0e3d146470 Merge main: lz4-java's decompressor inside the block-at-a-time decode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:47:50 +00:00
Ludovic MarieandClaude Opus 5.5 87e3e1d155 LAN login: never join without an account
A failed session join goes on for a LAN entry only when an account is
signed in, as vanilla's client always has one (Mojang's rules: no play
without an account that owns the game).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:47:00 +01:00
levzzz b3e0f1d476 Merge main; the fluid hooks as an API fragment 2026-10-31 21:46:57 +05:00
rdbtCVSandClaude Opus 5.5 7174827f0f Merge the branch's own main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:46:29 +00:00
rdbtCVSandClaude Opus 5.5 a9387ea367 Merge main: keep both region tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:46:01 +00:00
Alexander KrylovandHermes Agent 2b2c2893ef Launcher: the accent a drag leaves off the strip, and the CLI's hue
Review fixes on top of the hue strip:

- A drag released outside the strip now writes settings.json too. on_mouse_up
  only fires while the element is hovered, so the accent stayed in memory and
  was lost on restart; on_mouse_up_out covers the release off it, and
  hue_dragging keeps a click elsewhere on the page from saving.
- The contrast test sweeps every half degree from 0 to 359.5 on both bases
  instead of two hand-picked hues, so the guarantee is tested where it is
  claimed.
- `settings accent <value>` names the degrees a value may be, not only the
  seven names.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-31 19:45:59 +03:00
rdbtCVSandClaude Opus 5.5 d54c7b2629 Merge origin/main into feat/ui-slider-keyboard-controls
Resolve title.rs (Outcome::Connect gained `lan`), handle main's new
graphics_ui::Action::Calibrate in the wheel and key paths, and let
Escape close Video Settings on the first press.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:45:57 +00:00
rdbtCVSandClaude Opus 5.5 5963751fe9 Merge origin/main: keep both the services-key and own-profile enums in remote.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:52 +00:00
rdbtCVSandClaude Opus 5.5 02e7c4b24a Merge origin/main: rocket damage now goes through Damage::fireworks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:49 +00:00
Ludovic Marie a56109fc75 Merge remote-tracking branch 'origin/main' into sweepB/4787
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 17:44:06 +01:00
Ludovic Marie 08b6bee394 Merge remote-tracking branch 'origin/main' into pr5035 2026-10-31 17:43:36 +01:00
iseaprojectsandDeepSeek V4.1 Flash 01770ad0d7 Merge origin/main into i18n/es-es-complete
main added three strings to every language (`finding_friend`,
`opening_world`, `cancelling_world`); es_es keeps its 198 translations
and gains those three, for 201/201 keys in en_us order.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 12:42:28 -04:00
rdbtCVSandClaude Opus 5.5 9dba2508fa Merge main: the pot answers through useItemOn's branch, and the generic arm fires item_used_on_block
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:42:09 +00:00
rdbtCVSandClaude Opus 5.5 acc42f9953 Read dialog_open before the player borrow in the motion tick (E0502)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:41:28 +00:00
Ludovic Marie df22152c88 Merge branch 'pr4376' into pr4377 2026-10-31 17:41:01 +01:00
Ludovic Marie 67f328dfc5 Merge remote-tracking branch 'origin/main' into sweepB/4638
# Conflicts:
#	crates/horizon-client/src/title.rs
2026-10-31 17:40:30 +01:00
rdbtCVSandClaude Opus 5.5 ac13cb84f5 Merge main: dialog content ids, sync_dialog_ids, packed_biomes takes the resources
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:39:26 +00:00
levzzz 9b1f9dbf83 Merge main; the chunk and player hooks as an API fragment 2026-10-31 21:39:15 +05:00
Ludovic MarieandClaude Opus 5.5 4441194650 Merge main into secure-chat-v2/2-chat-keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:38:33 +01:00
Ludovic Marie c90b0cd869 Merge remote-tracking branch 'origin/main' into sweepB/4539
# Conflicts:
#	crates/horizon-client/src/item_tooltip.rs
2026-10-31 17:38:22 +01:00
some-du6eandClaude Opus 5.5 daed0b5e2d F3 debug entries: draw the lines and renderers Horizon never had
Ports the 26.3 DebugEntry* classes the debug options screen listed but
the overlay never drew: memory, targeted block/fluid/entity state and
tags, chunk render/source stats, entity and particle stats, player
speed, light levels, heightmaps, biome, local difficulty, day count,
spawn counts, sound mood and cache, post effects and GPU utilization,
in DebugScreenEntryList's order (by identifier) with vanilla's text and
priority/line/group placement. The integrated server reports the lines
that read the server level.

Renderers: water levels, heightmap, collision boxes, block and sky light
levels, solid faces. The list's scrollbar can be dragged, and
debug-profile.json gets vanilla's three datafixes by DataVersion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:37:23 -06:00
rdbtCVSandClaude Opus 5.5 32cb2a76be Merge origin/main (brings #4955's block_update/random_tick oracle fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:36:31 +00:00
Ludovic Marie caefd3bb28 Revert "Fill an empty flower pot with a plant on right-click"
This reverts commit 8d75d8953e.
2026-10-31 17:35:38 +01:00
Ludovic Marie 9afcbb3609 Revert "Drop flight when leaving spectator near the ground"
This reverts commit 09e52b9de3.
2026-10-31 17:35:38 +01:00
TobiasLogicandClaude Opus 5.5 af63e5f439 Shader packs: x / 0.0, sqrt of a negative literal, and texelFetch2D translate
`x / 0.0` and `sqrt(-1.0)` are GL's infinity and NaN at run time (Mellow's vec4(-1.0 / 0.0),
Bloop's sqrt of negative constants). naga's frontend folds them into constants and refuses the
result, so the program was skipped. They now divide by `hz_zero`, a private float the frontend
can't fold: `x / hz_zero`, and `hz_zero / hz_zero` for the square root.

texelFetch1D/2D/3D (GL_EXT_gpu_shader4's names, Bliss) become texelFetch, as texture2D and
the other old names already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:35:19 +07:00
rdbtCVSandClaude Opus 5.5 98e1976db1 Merge origin/main; Escape on the profile list counts as Cancel
Conflicts in options_ui.rs (main's HDR calibration page beside the profile
page) and lib.rs's re-export list: kept both. Escape on the list now sets
choice = Some(None), as the Cancel button does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:29 +00:00
rdbtCVSandClaude Opus 5.5 dc2c9e7b46 Merge origin/main into fix: entity_nbt conflict (conversion times beside the block-state helpers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:21 +00:00
Ludovic MarieandClaude Opus 5.5 9c63ab14e9 HDR calibration: end it off the Options screen; don't save its fullscreen
The test picture and borderless window stayed when the screen left Options
without a Close event (a server opening the death screen), and
observe_fullscreen saved the calibration's own fullscreen as the player's
Fullscreen option. Review points from #4923.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:23 +01:00
Ludovic Marie 995c7aa817 Merge remote-tracking branch 'origin/main' into fix4434 2026-10-31 17:32:30 +01:00
ElyTheCatandCodex ed9c111323 Separate submission tags from session cookies
Co-Authored-By: Codex <[email protected]>
2026-10-31 17:32:30 +01:00
levzzzandClaude Opus 5.5 be7490c9ba Main builds again: the spawn egg test passes the block registry argument
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:32:24 +05:00
rdbtCVSandClaude Opus 5.5 28cbd6c861 Merge main; renumber mod block entity update tags to API 45
Main took API 43 (native_level_random) and 44 (server starting and stopping).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:32:06 +00:00
rdbt 384197a498 Merge pull request #5214 from horizon-rust/fix/unique-random-uuids
Unique random UUIDs: mobs generated in a pack no longer share one
2026-10-31 16:31:48 +00:00
rdbt b625ba5360 Merge pull request #5210 from iseaprojects/fix/5201-windows-version
Tools: read cmd /C ver in the console's code page on Windows
2026-10-31 16:31:25 +00:00
rdbt ffc8183484 Merge pull request #5209 from iseaprojects/fix/5132-burst-setback
Anticheat prediction: a burst keeps the setback point before its lie
2026-10-31 16:31:10 +00:00
levzzz 5a23ab721e Merge remote-tracking branch 'hr/main' into fix/audio-share-device-fields 2026-10-31 21:31:07 +05:00
rdbt b15c3fad35 Merge pull request #5196 from horizon-rust/server-admin/mcp
horizon-server mcp: an MCP server for AI agents to run and moderate a server
2026-10-31 16:30:40 +00:00
rdbt 2a8a736b4a Merge pull request #5193 from vivescene/audio/macos-device-fingerprint
Audio: list output devices only when the Core Audio devices change (macOS)
2026-10-31 16:30:25 +00:00
rdbt 0181d47bcb Merge pull request #5191 from horizon-rust/perf/path-shape-changes-once
Mobs: compare each block write's collision shapes once per tick, not once per pathing mob
2026-10-31 16:30:11 +00:00
rdbt 35a69e1eb6 Merge pull request #5182 from 1038april/fix/eat-food-that-cannot-be-placed
Food that can't be placed on the clicked block is eaten on this client too
2026-10-31 16:29:49 +00:00
rdbt 6d3f22638d Merge pull request #5180 from Akirabane/fix/service-test-windows
horizon-server: the launchd test no longer assumes `/` in paths
2026-10-31 16:29:28 +00:00
rdbt fd7c46c225 Merge pull request #5177 from xFedeT/fix/port-map-3513
Port map: seven rows of #3513 are ported already
2026-10-31 16:29:13 +00:00
rdbt 73b547d762 Merge pull request #5176 from xFedeT/fix/creeper-lingering-cloud
A creeper with effects leaves a lingering cloud when it explodes
2026-10-31 16:28:58 +00:00
Ludovic MarieandClaude Opus 5.5 ad930b9bb1 Merge main into native-visual-hooks; renumber to hook API 45, test module last in shader_packs.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:28:54 +01:00
rdbt bd71e987ef Merge pull request #5175 from reservedbytes/fix/native-set-property-no-change
fix(mods): property setters return false when nothing changed
2026-10-31 16:28:44 +00:00
levzzz b8e9257861 Merge remote-tracking branch 'hr/main' into qw-5130 2026-10-31 21:28:40 +05:00
rdbt bba758386d Merge pull request #5173 from reservedbytes/fix/primed-sulfur-cube-knockback
fix(mobs): a primed sulfur cube is still knocked about
2026-10-31 16:28:30 +00:00
Ludovic MarieandClaude Opus 5.5 54f11f1744 Pet teams: look a mob up only when its own name has no team
mob_team scanned every mob for each call, and mob_teams (every tick) and the
name tags and outlines (every frame) call it once per mob: O(n^2) whenever a
scoreboard team exists. The own team is checked first, the tick builds the
tamed owners in one pass, and callers holding the mob pass it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:28:25 +01:00
rdbtCVSandClaude Opus 5.5 d7883994eb Predicates: a server's player matches input.sneak and its game mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:27:27 +00:00
rdbt 125a880ee5 Merge pull request #5126 from Akirabane/fix/4904-recipe-result-string
Recipe results: a bare item id names the item, as vanilla reads it
2026-10-31 16:27:21 +00:00
rdbt 0c41429b4b Merge pull request #5117 from oskal675-design/fix/mace-pack-models
Resolve enchantment-dependent component item models
2026-10-31 16:26:56 +00:00
rdbt e4f07bb4c6 Merge pull request #5116 from MSedgeMC/fix/display-shadow
Draw a display's shadow, interpolated as vanilla draws it
2026-10-31 16:26:42 +00:00
rdbt 6daec26c3d Merge pull request #5114 from MSedgeMC/fix/projectile-power-packet
Read the projectile power the server sends with a projectile's motion
2026-10-31 16:26:28 +00:00
rdbt ef0c899c29 Merge pull request #5111 from oskal675-design/fix/remote-offhand-model
Render each remote hand with its own item stack
2026-10-31 16:26:05 +00:00
rdbt d13980b43a Merge pull request #5107 from oskal675-design/fix/lan-login-authentication
Preserve vanilla LAN authentication fallback
2026-10-31 16:25:43 +00:00
rdbt ce44d05eea Merge pull request #5106 from masa-masa-0314/fix/ui-localization
Localization: the merchant screen follows the language, Horizon's strings in Chinese
2026-10-31 16:25:29 +00:00
rdbt 1574e9b150 Merge pull request #5104 from apeirs/fix/recipe-book-space-replace
Recipe book: Space and Enter place the last clicked recipe again
2026-10-31 16:25:14 +00:00
rdbt 910ba97315 Merge pull request #5096 from oskal675-design/fix/srv-login-handshake
Fix Velocity forced-host routing after SRV redirects
2026-10-31 16:24:47 +00:00
Ludovic MarieandClaude Opus 5.5 a405d45b3c ExperienceOrb.unstuckIfPossible: search a cube of the orb's size around its centre
AABB.ofSize(center, size, size, size), not the box grown by its size on
every side, which let a buried orb jump three times as far as vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:35 +01:00
rdbt ccbc71879c Merge pull request #5087 from MSedgeMC/fix/mob-pick-up-item
Let a mob pick up and equip what it finds, as vanilla's looting pass does
2026-10-31 16:24:19 +00:00
rdbtCVSandClaude Opus 5.5 f0518ca0e1 Merge origin/main: --attack takes several targets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:23:56 +00:00
rdbt adc65f2983 Merge pull request #5082 from MSedgeMC/fix/gamemode-ground-flight
Land a spectator who becomes creative while standing on the floor
2026-10-31 16:23:50 +00:00
rdbt cc332d5e7d Merge pull request #5081 from MSedgeMC/fix/egg-entity-data
Let a spawn egg carry entity data, as vanilla's does
2026-10-31 16:23:36 +00:00
rdbt 8a2d942360 Merge pull request #5079 from reservedbytes/fix/entities-only-template
fix(worldgen): a template with entities and no blocks places them
2026-10-31 16:23:21 +00:00
rdbt 46ed0adc78 Merge pull request #5078 from MSedgeMC/fix/spectator-chunks-rule
Let the spectators_generate_chunks rule decide who loads terrain
2026-10-31 16:23:08 +00:00
levzzzandClaude Opus 5.5 f80c0360d3 Main builds again: a splitscreen share has the device tracker's fields, the stew particles reach served players
#3727's Audio::share predates #4527's device tracking fields, and #4515's stew
particles call lan_send_particles with #4761's old arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:23:05 +05:00
rdbt cda954ab2a Merge pull request #5077 from k3yh/fix/region-chunk-trailing-bytes
Load a region chunk whatever follows its root (#5064, point 6)
2026-10-31 16:22:55 +00:00
rdbt 2c2435d64d Merge pull request #5071 from reservedbytes/fix/player-creeper-blast-type
fix(combat): a player hurt by a creeper's blast takes a player_explosion
2026-10-31 16:22:41 +00:00
rdbtCVSandClaude Opus 5.5 cc6a70672e Chat ack on reconfiguration only when there is an offset, as sendChatAcknowledgement does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:22:40 +00:00
rdbt 0d80a814b8 Merge pull request #5062 from MSedgeMC/fix/pet-owner-team
Give a tamed pet its owner's scoreboard team
2026-10-31 16:22:14 +00:00
Ludovic MarieandClaude Opus 5.5 85b378f227 End gateway migration: a side file it can't read no longer stops the End from opening
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:22:02 +01:00
arismoko a5cca57316 Flush native mod messages during server shutdown 2026-10-31 11:21:50 -05:00
Ludovic MarieandClaude Opus 5.5 f3a8781d9b Merge origin/main into fix/gateway-exit-storage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:44 +01:00
rdbt c64445140f Merge pull request #5050 from MSedgeMC/fix/nausea-blend
Start a restored nausea at full strength, as vanilla does
2026-10-31 16:21:40 +00:00
rdbt 048f0b280e Merge pull request #5044 from mulareal/fix-armor-stand-water-push-4849
entities: push armor stands with flowing water and lava
2026-10-31 16:21:26 +00:00
rdbt a1d4937336 Merge pull request #5040 from apeirs/column-bulk-section-read
Generated columns: read a whole section in one pass
2026-10-31 16:21:11 +00:00
rdbtCVSandClaude Opus 5.5 f3dd9dec81 horizon-prepare: --check refuses --progress and --complete
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:20:59 +00:00
rdbt 993b05456b Merge pull request #5039 from apeirs/fix/tnt-minecart-spares-rails
TNT minecart: a primed cart's blast spares rails and the blocks under them
2026-10-31 16:20:57 +00:00
ElyTheCatandCodex e74a62b51e Match vanilla secure chat command handling
Keep command signing disabled until the server command tree arrives, count chat length in UTF-16 units, and acknowledge delayed chat during configuration transitions.

Co-Authored-By: Codex <[email protected]>
2026-10-31 16:20:52 +00:00
rdbt c9feaa1ecd Merge pull request #5037 from TiggerTakkyTTT/fix-particles-tick-freeze
fix(client): stop particles during tick freeze
2026-10-31 16:20:44 +00:00
rdbt d2da889891 Merge pull request #5036 from ludovicthenot/docs/player-tick-notes
Native mods: say when the player tick hook runs and when it doesn't
2026-10-31 16:20:30 +00:00
reservedbytes 89551109f9 fix(combat): a spear stab deals no default knockback 2026-10-31 15:20:16 -01:00
rdbt 4740c146cf Merge pull request #5034 from navinor/fix/chunk-io-test-race
Keep the chunk_io tests off the queue the save tests also fill
2026-10-31 16:20:16 +00:00
reservedbytes 16fd39cbe4 fix(mobs): a burning arrow lights a sulfur cube's fuse 2026-10-31 15:20:15 -01:00
reservedbytes 3351ca5d40 fix(mobs): a sulfur cube with a block shrugs off its whole immune tag 2026-10-31 15:20:15 -01:00
rdbt 428ad90224 Merge pull request #5022 from apeirs/fix/elytra-vanilla-trig
Elytra: glide with Mth's sine table and float drag, as vanilla does
2026-10-31 16:19:54 +00:00
rdbt 9c97099864 Merge pull request #5017 from Username59138/fix/melee-attack-damage-attribute
Melee damage reads the attack_damage attribute
2026-10-31 16:19:41 +00:00
rdbt 0139c8596a Merge pull request #5011 from doodlyyousuf/fix/4986-halloween-pumpkin-head
Give zombies and skeletons pumpkin heads on Halloween
2026-10-31 16:19:27 +00:00
rdbt ec151d869e Merge pull request #5007 from repositorykreml1n/fix/boat-hand-jitter-4583
The hands stop shaking while a ridden boat turns
2026-10-31 16:19:05 +00:00
rdbt 3836642a67 Merge pull request #5006 from MSedgeMC/fix/golem-creeper-refusal
Keep an iron golem from fighting the creepers it refuses
2026-10-31 16:18:50 +00:00
rdbtCVSandClaude Opus 5.5 9b74114b8a Clear the projectile attacker after a refused hit; name the host by its own name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:18:38 +00:00
rdbt 5092ba0f5a Merge pull request #5005 from MSedgeMC/fix/universal-anger
Let the universal_anger rule reach a wolf's target goal
2026-10-31 16:18:36 +00:00
rdbt 66ab78011a Merge pull request #4997 from reservedbytes/fix/firework-damage-type
fix(combat): firework rockets hurt as fireworks
2026-10-31 16:18:23 +00:00
rdbt bb63d3f310 Merge pull request #4991 from elythepreciouscat/perf/clamp-vertical-occlusion-search
Renderer: clamp vertical occlusion search grid height to non-air sections
2026-10-31 16:18:09 +00:00
rdbt 45b71aa863 Merge pull request #4980 from doodlyyousuf/fix/4887-offhand-container-swap
Swap F in a chest with the off hand
2026-10-31 16:17:56 +00:00
rdbt 005a8c3792 Merge pull request #4969 from MSedgeMC/fix/pufferfish-enderman-save-data
Save a pufferfish's puff state and an enderman's carried block
2026-10-31 16:17:42 +00:00
rdbt 80a52907b0 Merge pull request #4959 from MSedgeMC/fix/allay-no-ai-dance
Stop a NoAI allay dancing, fetching and delivering
2026-10-31 16:17:29 +00:00
rdbt 6d353618d3 Merge pull request #4936 from elythepreciouscat/fix/sprint-knockback-reset
Combat Parity: reset sprint and slow attacker on sprint-knockback hit
2026-10-31 16:17:08 +00:00
rdbt 4031e5063b Merge pull request #4930 from k3yh/fix/item-custom-model-data
Send custom_model_data and item_model to the client (#4916)
2026-10-31 16:16:55 +00:00
rdbtCVSandClaude Opus 5.5 59f37f92fb Merge origin/main into shader/s1-material-behind
MaterialInput gets main's tags, then screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:16:53 +00:00
Ludovic MarieandClaude Opus 5.5 95cdad8b3a Native sidebar: line holders the sidebar shows (not '#', which vanilla hides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:16:46 +01:00
levzzz d4c3a8bd4c Merge pull request #5140 from levzzz5154/mods/native-server-starting
Native mods: hook points for the server starting and stopping (API next)
2026-10-31 21:16:43 +05:00
rdbt 60134bd895 Merge pull request #4923 from senjinthedragon/hdr-calibration
HDR: calibration, full screen, one test picture at a time
2026-10-31 16:16:31 +00:00
ErikKalikasandClaude Opus 5.5 16f7efa54d Merge mod-block-entity-sync (upstream/main); renumber native_block_entity_sync to API 44
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:16:22 +02:00
rdbt 871c4691a3 Merge pull request #4911 from sirrayi/fix/guest-passenger-swim
Player swimming needs !isPassenger(), in both of Entity.updateSwimming's branches
2026-10-31 16:16:10 +00:00
arismoko fd60c3dc7a Merge remote-tracking branch 'upstream/main' into hook-fragments-workflow 2026-10-31 11:15:59 -05:00
rdbt fa7dbecb43 Merge pull request #4834 from Dufru/loc/add-pt-br-translation
i18n(launcher): add Brazilian Portuguese (pt-BR) translation
2026-10-31 16:15:57 +00:00
levzzz 1992ad03ba Merge main; the block items helpers as an API fragment 2026-10-31 21:15:53 +05:00
Ashura006andCursor Agent fcfcc67c01 Show server resource packs in the Resource Packs list
Vanilla's DownloadedPackSource puts active server packs in the selection
as required, fixed at the top (pack.source.server). Horizon already
applied them but never listed them; they also stay out of options.txt.

Fixes #5218

Co-Authored-By: Cursor Agent <[email protected]>
2026-10-31 19:15:46 +03:00
rdbt ad49dcc284 Merge pull request #4765 from mulareal/fix-hitsounds-4330
Fix missing hit and hurt sounds on server thread and multiplayer (#4330)
2026-10-31 16:15:43 +00:00
rdbt 6e446359bd Merge pull request #4737 from WooCaash/fix/3852-cape
Show the signed-in account's active cape in single player
2026-10-31 16:15:30 +00:00
rdbt 7027d1437b Merge pull request #4667 from posok124521/fix/entity-chest-open
Fix opening chest boats, rafts and container minecarts
2026-10-31 16:15:16 +00:00
rdbt 4b04fb40e8 Merge pull request #4631 from AurelienBelloir/parity/display-entity-transforms
Decompose matrix display transformations like MatrixUtil
2026-10-31 16:15:03 +00:00
levzzz 5017aa5ab0 Merge pull request #5026 from Qwerner88880/fix-native-followups
Native mods: on_equip for /item's main hand, recipes in subfolders, no sends while reconfiguring
2026-10-31 21:14:47 +05:00
rdbt a543fffdc1 Merge pull request #4555 from AurelienBelloir/parity/data-pack-dialogs
Send the world's data pack dialogs in the dialog registry
2026-10-31 16:14:34 +00:00
rdbt 7c808bba52 Merge pull request #4543 from AurelienBelloir/parity/default-and-any-block-use
Fire the default_block_use and any_block_use advancement triggers
2026-10-31 16:14:20 +00:00
rdbt e0b41b8481 Merge pull request #4515 from MSedgeMC/fix/mooshroom-stew
Let a brown mooshroom take a flower's stew effects
2026-10-31 16:14:06 +00:00
rdbt db3e803087 Merge pull request #4505 from kacpherek/fix/seam-batch
Server path parity: armour swap, boat paddling, guest scoreboards, respawn message, fence leads, boat air bar, xp orbs
2026-10-31 16:13:52 +00:00
rdbt d401b63cdc Merge pull request #4462 from elythepreciouscat/perf/worldgen-density-scratch-and-position-seed
Worldgen: optimize terrain fill, aquifer cell caching, and 48-bit positional seeding
2026-10-31 16:13:38 +00:00
rdbt 076582df26 Merge pull request #4380 from xFedeT/fix/explorer-map-references
Exploration maps skip the structures a map already pointed at
2026-10-31 16:13:24 +00:00
rdbt 562feb2581 Merge pull request #4371 from iLostName/core-text-shader
resource packs: a pack's core/text shader draws the GUI's text, so server HUDs show
2026-10-31 16:13:11 +00:00
rdbt fce73cb0b4 Merge pull request #4341 from MSedgeMC/fix/4301-respawn-immunity
Make the client-loaded immunity term real
2026-10-31 16:12:58 +00:00
rdbt b0bede7fc6 Merge pull request #4186 from Vaspyyy/weather-glass-shader-inputs
Shader API: sprite tags and the weather at the camera for the materia…
2026-10-31 16:12:44 +00:00
rdbtCVSandClaude Opus 5.5 44c54ba8b8 rustfmt creatures.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:12:16 +00:00
rdbt 6a7519dde9 Merge pull request #3938 from DUzzL/fix/issue-3915-atlauncher-import
Launcher: copy ATLauncher instances into Java instances
2026-10-31 16:12:09 +00:00
rdbtCVSandClaude Opus 5.5 a9015a5525 Renderer: a software-only first try asks again with non-conformant adapters
Review thread: wgpu ranks a CPU adapter last, so the retry can find hasvk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:58 +00:00
rdbt b8fc236a5d Merge pull request #3727 from martipops/split/02-audio
Splitscreen: share one audio output between local-player sound engines
2026-10-31 16:11:48 +00:00
Ludovic Marie d1207de12b Merge remote-tracking branch 'origin/main' into feat/native-sidebar
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 17:11:44 +01:00
rdbt 8f76982036 Merge pull request #3468 from AHHhHHHHH-H/pr/world-wide-lod-slider
LOD Distance: a slider up to the whole world, with heightmap far tiles
2026-10-31 16:11:35 +00:00
rdbtCVSandClaude Opus 5.5 08c9aa50f2 Merge origin/main into the accent hue branch
launcher/src/main.rs: keep both new Launcher fields (hue_area, language_menu).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:32 +00:00
Ludovic Marie 138bf9d166 Merge pull request #5217 from horizon-rust/feat/live-stats-exact
🤖 Agent: CI green on eccfe3d9b (clippy, launcher, tests, python, verify). Telemetry server checked in its own workspace (fmt, clippy -D warnings, tests). Exact live count, the live count's history and /v1/live/history; #5235 is stacked on it.
2026-10-31 17:11:31 +01:00
ElyTheCatandCodex f6fb532782 Fix remaining keyboard focus review findings
Co-Authored-By: Codex <[email protected]>
2026-10-31 19:40:55 +03:30
Ludovic MarieandClaude Opus 5.5 f35210d2da Trusted reviewers: add Qwerner88880
Ludovic (2026-10-31) asked to add x3 кто (Discord vladislav_hagen,
GitHub Qwerner88880, the native-mod hooks author).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:35 +01:00
apeirsandCursor 917b185ba1 Commands: a reused UUID still resolves, and on owner stays living
A kill leaves the old id in the UUID map, so the first match could be an entity the level no longer holds. on owner follows getLivingEntity, which does not return a marker or a display.

Co-authored-by: Cursor <[email protected]>
2026-10-31 17:10:31 +01:00
rdbt 6e2102e2cb Merge pull request #5224 from DmytroChup/fix/5213-port-pack-windows-tests
fix(port-pack): gate unix symlink tests with #[cfg(unix)]
2026-10-31 16:09:07 +00:00
Ludovic MarieandClaude Opus 5.5 df7ef012e4 Discord: the new official invite discord.gg/playhorizon
Ludovic (2026-10-31): replace the old invite everywhere. The launcher
reads launcher/links.json from main, so installed launchers pick it up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:09:05 +01:00
rdbt 01049b3eab Merge pull request #5222 from some-du6e/feat/debug-options-screen
Debug options screen (F3+F6): choose what the F3 overlay shows
2026-10-31 16:09:00 +00:00
rdbt 3faaef6693 Merge pull request #5161 from arismoko/hook-fragments
Batch native hook API numbers from fragments
2026-10-31 16:08:30 +00:00
ErikKalikasandClaude Opus 5.5 f755be19d8 Merge upstream/main; renumber mod block entity update tags to API 43
The detached encoder for saved chunks past the view distance (lan::far)
keeps the mods' update tag hook, which may then run off the server thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:08:27 +02:00
rdbt 22ba7eac80 Merge pull request #5102 from hauserdew-ui/fix/alsa-device-polling
Audio: list output devices only when the ALSA cards or Core Audio devices change
2026-10-31 16:08:09 +00:00
rdbt c820aaba1a Merge pull request #5033 from navinor/fix/sprint-blindness
Blindness stops a sprint and keeps one from starting
2026-10-31 16:07:43 +00:00
rdbt f115979414 Merge pull request #3945 from kacpherek/fix/split-maps-villager-trades
Map banner and frame markers
2026-10-31 16:07:06 +00:00
MSedgeMC 486464c829 Drop a saved anger at a player a golem cannot attack
NeutralMob.readPersistentAngerSaveData hands the saved anger back through
Mob.setTarget, and setTarget runs asValidTarget, which turns a target the
mob cannot attack into no target at all. IronGolem.canAttack refuses every
player when a player built the golem. Horizon's world load wrote the target
directly, so a player-built golem reloaded from a save kept its builder as
its target, and its melee goal never re-checks canAttack.

The golem rule becomes Mob::built_by_player, used by both the restore and
spares_players, with tests pinning the targets a built golem does and does
not take and the crack sound it already plays.
2026-10-31 21:36:54 +05:30
reservedbytes 8baff760da fix(mods): items-only content mods join the content identity check 2026-10-31 15:04:49 -01:00
MSedgeMC 9cef07df2f Drop the orb scan's unused upper-corner binding 2026-10-31 21:34:30 +05:30
MSedgeMC 79a182d6af Let the stem name auto-deref, as clippy asks 2026-10-31 21:34:26 +05:30
levzzz 46a0cf3eea Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 21:02:38 +05:00
levzzz 5062f22592 Merge pull request #5146 from levzzz5154/mods/native-level-random
Native mods: the server level's random (API next)
2026-10-31 21:01:52 +05:00
ElyTheCatandCodex 8bac1883f2 Keep the remote dig target after a break
Preserve the client's destroyBlockPos after a completed prediction so a replacement at the same position can resume the retained target, matching the isDestroying split.

Co-Authored-By: Codex <[email protected]>
2026-10-31 21:31:50 +05:30
MSedgeMC 80443df31e Fold the destroyPos guard onto one line, as rustfmt wants
CI's stable rustfmt put the `if !one_hit && let Some(play) = ...` back on a
single line; the local check accepted it split.
2026-10-31 21:31:50 +05:30
MSedgeMC 73d6388a0e Write destroyPos only for a dig that takes more than one hit
handleBlockBreakAction writes destroyPos in the branch that keeps digging,
after the creative destroy and the instant-mine check, so a creative or
one-hit START leaves no block behind for a later STOP to break.

Reported in review: destroy_pos was set for every accepted START.
2026-10-31 21:31:49 +05:30
MSedgeMC 5035b06b97 Keep a dig's progress across a missed pick, as vanilla does
MultiPlayerGameMode.stopDestroyBlock opens with `if (!isDestroying) return`,
so it zeroes the progress once, on the stop that ends a running break; every
later missed pick is a no-op, the dig identity survives, and the client keeps
accumulating until the progress passes 1.0 and the STOP it sends is measured
from the first START.

Clearing the accumulated progress on every miss restarted the dig each jump,
so the client never reached 1.0 and the server's retained clock was never
read. The LAN regression now breaks the stone in 100 ticks with the dig
accumulating to 27 ticks across falls.
2026-10-31 21:31:48 +05:30
MSedgeMC 12cbedcd1b Add the dig_clock LAN regression and fix its probe
The verifier asked for the server-thread regression this change needs: a
survival guest under a block it can only reach near the jump apex, holding
attack and jumping, with the block's dig clock measured from the first
START.

It currently fails: the block survives 308 ticks of held attack. The
client aborts on every fall and the STOP that would read the retained
clock is only sent when the client's own progress completes, which a
five-tick in-reach window cannot reach. Reporting rather than papering
over it.

tests/test_remote_mining.py compiles an extracted copy of
stop_remote_mining against a stub, so the stub needs the new flag and its
assertion must stop expecting a stop to clear the target, which vanilla's
stopDestroyBlock does not do.
2026-10-31 21:31:47 +05:30
MSedgeMC 5b8b65a1cc Split the running break from the target, and test the STOP decision
A click on the block a stopped break still names has to start the dig
again, so the server re-anchors its clock, while a held button carries the
same target on and keeps it. That is vanilla's "isDestroying &&
sameDestroyTarget" guard: the target test stays pos plus item, and the
running flag is what the start guard adds.

The STOP decision moves into a small pure function over the retained
destroy_pos, the elapsed ticks and the block's per-tick progress, so the
0.7 break, the delayed arming from the same anchor and the cases that must
not inherit a clock are covered without a Game.
2026-10-31 21:31:46 +05:30
MSedgeMC 84dc805438 Let a resumed dig run on the START that named the block
Vanilla keeps ServerPlayerGameMode.destroyPos and destroyProgressStart after
an abort or a STOP that arrives out of reach: only a START writes them, the
abort clears isDestroyingBlock alone, and tick does not check distance. On
the client, stopDestroyBlock keeps destroyBlockPos and destroyingItem, so
sameDestroyTarget still matches after a fall, no second START is sent, and
the server's clock keeps running. That is the "break it a bit each jump"
behaviour the report asks for.

Horizon had the formula but not the state: the ABORT and too-far STOP
branches cleared the dig, guest_stop_digging required an active dig, and
the client's own target died on every stop, so each landing re-sent START
and re-anchored dig_start.

The server now retains destroy_pos (written only by an accepted START,
cleared by a respawn) and measures a STOP from the START that named the
block. The client splits isDestroying out of the target, so the target
survives a stop while a running break is still the thing that suppresses a
new START.
2026-10-31 21:31:45 +05:30
Ludovic MarieandClaude Opus 5.5 8c913dff6f Official SMP count: in the launcher and in the telemetry server's history
- horizon_telemetry::server_status: the server-list status ping (handshake, status
  request, JSON answer) and OFFICIAL_ADDRESS, the one place the official server's
  address is kept (the launcher's and the game's server lists use it).
- Launcher: "· M on the official SMP" beside "N playing now", pinged once a minute on its
  own thread with a 3-second timeout, hidden when it doesn't answer; it opens the Servers
  page.
- Telemetry server: pings the official server every minute (HORIZON_TELEMETRY_OFFICIAL_SERVER)
  and keeps its player count as the "official" series, like the live count;
  GET /v1/live/history takes series=players|official and answers the latest minute too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:01:32 +01:00
rdbtCVSandClaude Opus 5.5 58b2c98d35 frame_gen: size the optical flow field as OpticalFlow sizes its vectors (div_ceil)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:01:17 +00:00
TobiasLogicandClaude Opus 5.5 7042bc4d65 Shader packs: #if takes float values, and a condition it can't read is a warning
GL drivers evaluate a float in #if as a number (I Like Vanilla's #define AMBIENT_CEL_AMOUNT 0.0
tested with #if AMBIENT_CEL_AMOUNT != 0), and Iris loads such packs; Horizon refused the line and
skipped the program. The evaluator now computes in f64: float literals count, a fraction is true,
and / and % stay integer division unless a float literal appears.

A condition that still can't be evaluated already reads as false, but it was a diagnostic, so the
whole program was skipped (Pastel). It is now a warning in the loader's notes, and the program
loads with that branch off, as Iris runs those packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:01:04 +07:00
rdbtCVSandClaude Opus 5.5 d5ca93d09e horizon-prepare: list --version-json and --assets-dir in the asset subcommands' usage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:59:15 +00:00
Ludovic MarieandClaude Opus 5.5 79dc2964db Native mods: a sidebar and Tab list lines for one player, tick times, AFK players out of the sleep count (API 43)
For a public server's SMP mod: native_sidebar shows one player a sidebar of
up to 15 lines (a text, and one where the score would be) that only their
client holds, native_tab_list sets the lines over and under their Tab
list, native_tick_stats reads /tick query's rate and average tick time,
and native_sleep_ignored leaves a player out of players_sleeping_percentage's
count until they are in bed. Server players now carry yaw and pitch, for
telling who is away from the keyboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:49 +01:00
MSedgeMC 5a4e55917e Title a player head after its profile
PlayerHeadItem.getName answers block.minecraft.player_head.named with the
profile's name, so a head given for someone reads their name rather than
"Player Head". Horizon showed the plain item name in the tooltip and in
command feedback.
2026-10-31 21:25:04 +05:30
MSedgeMC 1f354fcf65 Pick the seed a stem grew from
A stem has no block item, so vanilla's pick-block returns the seed it grew
from. Horizon fell back to the block's own item, found air and picked
nothing, so middle-clicking a grown pumpkin or melon stem did nothing.
2026-10-31 21:24:57 +05:30
MSedgeMC 92639ca113 Let shears crop a growing plant's head
ShearsItem.useOn on a growing-plant head below its top age plays
block.growing_plant.crop, sets the head to age 25, fires a block change,
wears the shears and succeeds. Horizon had no such arm, so shears on kelp
or a vine did nothing.

The arm sits with the pumpkin carve and the beehive shear and reuses their
helpers.
2026-10-31 21:24:35 +05:30
Jose Gil 31c1212bf7 fix(launcher): localize proxy, game arguments, release headers, and sponsor section 2026-10-31 11:53:09 -04:00
levzzz 6e09a5d817 Merge fix: close native_level_random 2026-10-31 20:51:25 +05:00
arismoko bd26b09bdd Keep item pickups stable across native hooks 2026-10-31 10:48:22 -05:00
iseaprojectsandDeepSeek V4.1 Flash 5e04d2051f i18n: complete the Spanish (es_es) strings
`es_es.json` had 13 of the 198 keys `en_us.json` ships, so the other 185
fell back to English on every Spanish screen. Translate them, keeping the
13 existing strings and every `%s`/`%.2f` placeholder.

The file also serves `es_ar`, `es_cl`, `es_ec`, `es_mx`, `es_uy` and
`es_ve`, which `lang.rs` aliases to it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:46:47 -04:00
Nathaniel Ramos fe617e8370 Heal a stale Shift or Control from the modifier state too
The same lost key release that latched sneak also leaves the physical
modifier down, and a screen reads the physical state: every inventory
click becomes a shift-click, every throw a whole-stack drop.
2026-10-31 23:45:57 +08:00
levzzz 2283c9cc25 Docs: one blank line before the bundle section 2026-10-31 20:45:27 +05:00
levzzz 5c7cf4f4c1 Merge remote-tracking branch 'hr/main' into mods/native-level-random
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:45:27 +05:00
levzzz 85be6c20bb Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:45:23 +05:00
Dmytro ChuprynaandAntigravity 31046b9b61 fix(port-pack): gate unix symlink tests with #[cfg(unix)]
Tests a_home_behind_a_link_takes_its_packs and a_link_below_the_base_is_refused imported std::os::unix without #[cfg(unix)], breaking cargo test on Windows.

Fixes #5213

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 16:45:21 +01:00
levzzz d91cab68de Merge pull request #5151 from levzzz5154/mods/native-item-entities
Native mods: read and change item entities near a block (API next)
2026-10-31 20:44:37 +05:00
some-du6eandClaude Opus 5.5 e47213a981 Debug options screen (F3+F6): choose what the F3 overlay shows
Ports DebugOptionsScreen, DebugScreenEntries and DebugScreenEntryList
from 26.3: every entry can be off, in the overlay or always shown, the
default and performance profiles, debug-profile.json beside options.txt.
The overlay, F3+B, F3+G and the 3D crosshair follow the entries, and the
screen's GUI Scale button edits debugGuiScale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:44:24 -06:00
ElyTheCatandCodex 2f9b57f78e Keep sprint input held during knockback check
Use a raw mouse click instead of the deferred attack agent task, which releases movement keys before reporting. Prove the click landed from the victim's server hurt log while measuring the first post-hit speed sample.

Co-Authored-By: Codex <[email protected]>
2026-10-31 19:13:42 +03:30
ErikKalikasandClaude Opus 5.5 0ea18b9878 Native mods: sync a mod block entity to clients (sendBlockUpdated)
native_block_entity_sync(pos) puts the position in the level's block log,
as Level.sendBlockUpdated marks ChunkHolder.blockChanged: the hub sends the
block and the block entity's update tag to the players who see its chunk,
once per position a tick. NATIVE_HOOKS_API 24. A lan_bot test checks the
chunk's tag, one BlockEntityData for two syncs, and none without a tag.

Refs #4615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:41:58 +02:00
Ludovic MarieandClaude Opus 5.5 eccfe3d9b7 Live count: exact numbers, a history of it, and a link to playhorizon.dev/stats
- GET /v1/live serves exact playing, lan, versions and platforms (live::round is gone);
  rare versions are still summed under "other".
- The telemetry server writes the live count down once a minute (totals only: playing,
  lan, per platform) into live_minute (7 days), live_hour (90 days) and live_day (kept).
- GET /v1/live/history?range=24h|7d|30d|all serves the series ([start, average, peak]),
  the range's peak, the all-time peak and the average per platform, cached a minute, on
  the live count's rate limit.
- The launcher's "N playing now" opens playhorizon.dev/stats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:40:33 +01:00
mularealandAntigravity 5c8b864105 Fix missing hit and hurt sounds on server thread and multiplayer (#4330)
- Dispatch HealthEvent::DamageBroadcast and MotionEvent::HurtSound for guests in leave_guest and guest_server_tick (along with record_combat and wear_armor in leave_guest) so every unblocked player hurt immediately sends ClientboundDamageEventPacket with its exact DamageTypeId and broadcasts the hurt sound to nearby players.
- Run tick_guests and the deferred guest attack/effect flushes before lan_flush_effects and lan_swings in tick_lan so captured sounds and mob damage events from guest turns are flushed on the same tick and in the right dimension.
- Attack live mobs directly via attack_mob in guest_attack after within_attack_range instead of turning the guest's view to the mob's center with look_at_entity and re-running raycast pick checks.
- Handle ClientboundSoundEntityPacket in remote.rs and prefer inline RegistryData effects in hurt_sound_for.

Fixes #4330

Co-Authored-By: Antigravity (Gemini) <[email protected]>
2026-10-31 16:39:59 +01:00
Teiwazik 2c952190f4 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 16:38:22 +01:00
Teiwazik 9bd31bf94d Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror 2026-10-31 16:38:19 +01:00
Teiwazik c127524e08 Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 16:38:17 +01:00
Teiwazik 00af468123 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 16:38:15 +01:00
Teiwazik 9452f0ac11 Merge branch 'shader/s1-material-behind' into shader/s2-frame-motion 2026-10-31 16:38:13 +01:00
Teiwazik 3f0b8f293a Merge remote-tracking branch 'origin/main' into shader/s1-material-behind 2026-10-31 16:38:11 +01:00
ElyTheCatandCodex 4705bc2aa8 Reuse the heightmap update loop
Keep the dynamic per-map bounds introduced for prefilled heightmaps, but restore the shared MAPS loop so the optimization does not duplicate the two heightmap paths.

Co-Authored-By: Codex <[email protected]>
2026-10-31 19:06:01 +03:30
Ludovic MarieandClaude Opus 5.5 fa0de01c99 Unique random UUIDs: mobs generated in a pack no longer share one
random_uuid() seeded each UUID from the clock alone, so calls in the same
clock tick (a microsecond on macOS) returned the same UUID: a pack of mobs
generated together shared one, and a vanilla client keeps one entity per
UUID, so it never moved the others. Seed once, then step one shared
counter through SplitMix64.

Fixes #5207.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:11 +01:00
levzzz 0d2a9d8402 Docs: API 41 stays bundles' data files 2026-10-31 20:30:54 +05:00
levzzz 9021ffb4f5 Merge remote-tracking branch 'hr/main' into mods/native-item-entities
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:30:30 +05:00
levzzz 93d78e0c53 Merge remote-tracking branch 'hr/main' into mods/native-level-random
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:30:19 +05:00
TobiasLogicandClaude Opus 5.5 70f8a8ab4e Shader packs: the sky's and clouds' light and normals, and blockEntityId, as Iris gives them
Measured with a probe pack in Minecraft 26.3 + Iris 1.11.7 against Horizon on the same world and
camera:

- gbuffers_skybasic, gbuffers_skytextured and gbuffers_clouds get full light, lightmap
  coordinates (240, 240); Horizon gave (0, 240).
- gbuffers_skybasic's normal is (0, 0, 1); Horizon gave (0, 1, 0).
- Clouds get their face's normal (down for the bottom, out for the sides); Horizon gave up for
  every face. The cloud mesh now carries it (w 0: the game's own clouds still shade by colour).
- blockEntityId is -1 outside block entities; Horizon gave 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:30:05 +07:00
iseaprojectsandDeepSeek V4.1 Flash a257a4221e tools: read cmd /C ver in the console's code page on Windows
`perf_bench.windows_version` asked subprocess for text, which decodes with
the locale encoding: `pr_ready` runs the tests with PYTHONUTF8=1, so the
reader thread decoded `cmd /C ver`'s OEM output as UTF-8, failed, and left
`stdout` None. `test_report_without_a_load_average` then raised, so every
Windows contributor's self-check showed `Python tests: fail`.

Decode the command's output as OEM, with replacement for stray bytes, and
keep the `platform.version()` fallback for an empty result. The regression
test runs the import in a child process with PYTHONUTF8=1 and checks it still
prints a Windows version; it fails on main with a CalledProcessError.

Fixes #5201

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:28:02 -04:00
Ludovic Marie 979aa64e51 Merge pull request #5190 from horizon-rust/server-admin/console
🤖 Agent: verified: rebased on main after #5166/#5186, tools/server_admin_e2e.py passed (45 checks: the console attaches, runs say/list, shows their lines, detaches), pr_ready green, CI green.
2026-10-31 16:26:00 +01:00
levzzz e46f743fa4 Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:25:08 +05:00
Ludovic Marie 1629fa2dfc Merge pull request #5125 from horizon-rust/feat/anticheat-on-by-default
🤖 Agent: proven on fdfa4fbfa (rebased on main 125875b84): pr_ready --quick green (1427 tests, clippy, fmt, 495 Python tests); CI green (parity 35/35 after a rerun of the known lunge_motion flake, untouched here); anticheat_e2e default set (combat with no server.toml, ground, off, prediction, timer) green; a real vanilla 26.3 client hits a cow with combat on and is never flagged; MSPT 60 bots: anticheat off 3.68/3.69, default 4.11/4.13, 0 flags.
2026-10-31 16:24:18 +01:00
ErikKalikasandClaude Opus 5.5 dd0c770b86 Drop empty chunk update tags; replace a mod block entity on another block
BlockEntityInfo.create sends no tag when getUpdateTag is empty, for every
block entity. The client replaces a mod block entity when the block changes
to another one with the same block entity id, as LevelChunk.setBlockState
removes the old block's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:23:53 +02:00
levzzz 85d1165657 Merge pull request #3964 from levzzz5154/launcher/native-mod-crates
Native mods: own crates, Horizon's lock as their allow list, data files, named hooks, post-build steps
2026-10-31 20:20:37 +05:00
Nathaniel Ramos 5f25673546 Heal a held sneak or sprint from the modifier state
A release does not always reach the mapping: a screen reads presses only, and
an unfocused window is given no release at all. The latch then keeps the player
sneaking or sprinting until the key is tapped again, which is what a short
alt-tab did on Wayland. The modifier state is what the keyboard itself asserts,
and winit reports it before any screen or gameplay handler runs, so a per-frame
check settles the question instead of waiting for a release that already got
away.

A mapping that toggles, or one a pad latched, is left alone: set_down(false,
needs_toggle) is a no-op for those, and a toggle the player asked for is meant
to outlive the key. A mapping rebound away from its modifier is skipped too,
since the modifier says nothing about it.
2026-10-31 23:20:20 +08:00
huiandZCode AI agent (GLM-5.3) 962288e85f Fluids: a cauldron never exceeds the ask, a shrunk tank table keeps its tanks, no overflow
A cauldron's drain now hands out only whole thirds that fit within the
amount asked, as IFluidHandler.drain's maxDrain: asking 1 or 500 of a full
one takes one third (334) or none, so a caller moving into the room it has
never receives more than it can hold. Its fill compares each level's whole
third against what is left of the amount instead of summing, so a huge
amount can't overflow. A save with more tanks than the block now declares
reads the tanks that still exist instead of all empty, which the next write
would have made permanent.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 16:19:00 +01:00
arismoko 47cf6820c4 Explain how to start stamp PR checks 2026-10-31 10:18:09 -05:00
simonandClaude Opus 5.5 3fde6d64dd Food that can't be placed on the clicked block is eaten on this client too
BlockItem.useOn falls back to Item.use for a consumable whose placement
failed. The server already did, but a server client ended the click
without starting the use, so sweet berries, glow berries, carrots and
potatoes were eaten with no animation, and a released click couldn't stop
it. In adventure mode ItemStack.useOn passes, so UseItem now goes out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:12:37 +10:00
socksproxandDevin 873ca4fdaf Native mods: build under a path MinGW-w64 can read (#5189)
A Windows user name like "Кириченко Павло" puts the launcher's data under
a non-ASCII path, and the build's MinGW-w64 tools — gcc, as, ld, dlltool —
open files through ANSI APIs, so linking failed with exit code 101 and a
player's own redirection of %APPDATA% changed nothing (dirs::data_dir
reads the known folder, not the variable).

Every path the build's tools see — the toolchain, its Cargo home, the
native/ build tree and now the tools' temp dir, where gcc, as and
cargo install put their scratch files — comes from backend::build_dir:
the data folder resolved (so a junction to a drive with an ASCII path
already works), and when that still isn't ASCII, the folder's short (8.3)
name, or a folder of ProgramData's named for it.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 16:11:10 +01:00
valuttaandClaude Opus 5.5 8961611cbe Audio: list output devices only when the Core Audio devices change
On macOS, cpal's output_devices() opens every device to see whether it
plays: about 90 ms with twenty devices, once a second on the render
thread. Naming the devices takes 2 or 3 ms, so card_fingerprint now
returns their names there, and the list is looked at only when they
change, as on Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:49 +02:00
Ludovic MarieandClaude Opus 5.5 640f459087 horizon-server mcp: an MCP server for AI agents to run and moderate a server
JSON-RPC 2.0 on stdin and stdout, one message per line, as horizon-ctl mcp.
Each tool is one control API call on the server running in the folder, with
the token read from .horizon/api.json: status, players, run_command, logs,
events, kick, ban, pardon, ban_ip, pardon_ip, op, deop, whitelist, backups,
restore, get_config, set_config, start, restart and stop. stop, restore and
delete_backup need confirm: true; tools that send someone away or can't be
undone carry destructiveHint. The admin e2e bans, pardons, reads events and
is refused an unconfirmed stop through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:08:14 +01:00
Ludovic Marie caf83890c8 Merge pull request #5198 from horizon-rust/fix/resourcepacks-linked-home
🤖 Agent: resource packs install for a home behind a link (Silverblue /home, macOS /var), asked for by the orchestrator. directory(base, path) accepts links at or above the owning folder and still refuses a link below it; the canonical path must stay inside the canonical folder. 2 new tests; #5094's test workaround reverted (the tests run under the linked /var again). pr_ready --quick green (port-pack 99, launcher 316); CI green. Builds on gravitacia's #4579 check, credited there.
2026-10-31 16:06:06 +01:00
iseaprojectsandDeepSeek V4.1 Flash 53171431d0 Anticheat prediction: a burst keeps the setback point before its lie
A catch-up plays every tick recorded since the last one, and `play` moves the
replay to the position each tick claimed. A tick farther than the tolerance
was counted, but a tick after it that agreed from the spot the lie claimed
still replaced `agreed`, so `guest_replay` read a setback point already at the
cheated position: a one-tick lie arriving in a burst with the honest tick
after it counted once and set nothing back.

A catch-up that counts a tick now keeps the agreed point where it was for the
rest of that catch-up. The regression test plays ticks 20 and 21 in one
catch-up, tick 20 claiming +3.0 and tick 21 honest from there, next to
`a_cheat_counts_from_where_it_started`.

Fixes #5132

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:04:13 -04:00
Ludovic MarieandClaude Opus 5.5 fd091af76a horizon-server console: attach to the running server
The recent log, then every new line as it happens (the control API's event
stream), a header with the TPS, MSPT, players and RAM refreshed every second on
a terminal (an ANSI scroll region; no TUI framework), and a command line whose
lines run as console commands. Ctrl-D detaches and leaves the server running;
when stdout isn't a terminal it prints plain lines. The admin e2e attaches
through a pipe and runs two commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:59:57 +01:00
Ludovic Marie d55e66d656 Merge pull request #5105 from horizon-rust/fix/lan-e2e-reopen-player
🤖 Agent: #5092 is merged, so this regression check (lan_e2e reopen_player) can land now: it fails on the old main and passes with #5092. CI is green.
2026-10-31 15:58:49 +01:00
Ludovic MarieandClaude Opus 5.5 a129533651 Natural spawning: fewer lookups per chunk and per attempt
- A position's placement (isSpawnPositionOk) is tested before its spawn list
  is looked up again (canSpawnMobAt: structures reaching it, the zoomed biome,
  a string-keyed list). Both only read the level, so their order cannot change
  the answer, and the few block reads of the placement turn most positions
  away. The placement is read from a table by species, not by an id built
  with format! after a linear search (from server-tick-2's
  perf/spawn-placement-first).
- LocalMobCapCalculator's players near a chunk are runs of one list, not a
  Vec per chunk, and the counts per player a Vec by player index, not a hash
  map: thousands of chunks ask every tick on a full server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:57:33 +01:00
reservedbytes a2a79d9cfc fix(mobs): a knocked sulfur cube syncs at once 2026-10-31 13:57:08 -01:00
Teiwazik a74b7d507b Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 15:55:11 +01:00
TeiwazikandClaude Opus 5.5 995dedc504 Shader API: say what the water mirror's sky is
The mirror draws the vanilla gradient, not a pack's own sky stage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 15:55:10 +01:00
Teiwazik a83a978c3f Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror 2026-10-31 15:54:37 +01:00
Teiwazik fa562a57bf Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 15:54:36 +01:00
ElyTheCatandCodex 0a7c5e6ced Reset native post history after frame gaps
Co-Authored-By: Codex <[email protected]>
2026-10-31 15:54:36 +01:00
TeiwazikandClaude Opus 5.5 e83286d60f Shader API: keep fluid faces out of the section mesh fixture's alpha
The fixture exports vanilla's colours, whose alpha is 255: the fluid face in
the high nibble is Horizon's own, as the emission in the low one is. A test
now checks the material stage's decode against the mesher's encoders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 15:54:17 +01:00
Ludovic Marie aa27eca326 Merge pull request #5186 from horizon-rust/server-admin/events
🤖 Agent: verified: pr_ready green (36 harnesses affected, all passed), the e2e's 39 event checks passed on the Mac mini; CI caught a race in the stream test (an event between the headers and the first cursor read was skipped), fixed in bef1eff27, CI green.
2026-10-31 15:52:34 +01:00
Ludovic Marie 7ccd9406af Merge pull request #5166 from horizon-rust/server-admin/cli
🤖 Agent: verified: rebased on main after #5165, pr_ready green (64 horizon-server tests), tools/server_admin_e2e.py passed with the CLI checks on the Mac mini, CI green.
2026-10-31 15:52:30 +01:00
Ludovic MarieandClaude Opus 5.5 3f7e92d6ab Resource packs: a home folder behind a link takes its packs
resourcepacks::directory refused a link anywhere up to /, so a launcher
home behind one (Fedora Silverblue's /home -> /var/home, macOS's /var ->
/private/var) installed no packs. It now takes the folder that owns the
packs (the work folder or the profile home): links at or above it are the
player's layout and are accepted, every step below it must be an ordinary
directory, and the canonical path must stay inside the canonical folder.
Builds on gravitacia's check (#4579), revised by levzzz5154 (b49925118).
#5094's test-only workaround (resolving the temp dir) is reverted: the
tests run under macOS's linked /var again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:51:11 +01:00
Federico Tagliani 97d4bbf06c Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:49:48 +01:00
Federico TaglianiandClaude Sonnet 5.5 722603631b Mob effects keep their ambient flag, and the creeper's cloud copies it
MobEffectInstance.CODEC saves ambient and the NBT loader dropped it. A mob keeps
ambient_effects now: loaded, saved, kept through conversion and expiry,
set by /effect give as an instance with ambient false, and the faint
swirls and DATA_EFFECT_AMBIENCE_ID follow it. add_effect_with applies
MobEffectInstance.update's rules for the flags of an effect already there, and
the cloud applies the creeper's effects with theirs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:49:47 +01:00
Nathaniel Ramos 44ba5682be Release held sneak and sprint when the window loses focus
A short alt-tab (under 500 ms, so no pause screen opens) leaves the player
sneaking until Shift is tapped again. The latch in `toggles` survives the focus
loss and #4139's `sync_toggle` puts ShiftLeft back into `keys`, while an
unfocused window never receives the key release that would clear it.

Release each mapping that does not toggle, so a held sneak, sprint, attack or
use cannot survive a focus change, and pass the option through for the ones
that do: `set_down(false)` is a no-op for a toggling mapping and for a pad
latch, so the latched state #4139 was protecting still crosses the alt-tab.
2026-10-31 22:49:33 +08:00
Federico Tagliani 6d6715fd11 Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:47:16 +01:00
Ludovic MarieandClaude Opus 5.5 c8146cdba6 Mobs: compare each block write's collision shapes once per tick, not once per pathing mob
ServerLevel.sendBlockUpdated compares the old and new collision shapes once
per write and only then asks each navigating mob's shouldRecomputePath. The
mob loop rebuilt both shapes for every pathing mob and every write; the shapes
use no entity context and the world is fixed during the loop, so the list of
changed positions is the same for every mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:47:05 +01:00
Ludovic MarieandClaude Opus 5.5 fdfa4fbfab Anticheat on by default: a combat check that acts, the other checks log only
Ludovic, 10-31: turn the anticheat on. Checks a vanilla client can't trip act by default; the
others run but only log until real players show no false positive. `[anticheat] enabled = false`,
or `enabled = false` in a check's table, turns them off.

New `combat` check, acting by default: vanilla's client attacks only from Minecraft.startAttack,
once a client tick, on the entity of the tick's hitResult, and swings (ServerboundPunchPacket) with
each attack. A client tick with more attacks than swings, or a second entity attacked in the same
tick, flags; from `setback` (2) on the attacks are dropped, `kick` 20. The swing counts before or
after the attack (older clients behind a version proxy swing first).

`timer`, `ground` and `prediction` default to setback, alert and kick 0 (log only); a log-only ground
check keeps the claim. The thresholds once trusted are in docs/anticheat.md.

horizon-bots swing after each attack as vanilla does; --no-swing and --attack A,B are the cheats for
tools/anticheat_e2e.py --check combat, which runs with no server.toml (the defaults).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:44:15 +01:00
reservedbytes c9a8724b61 fix(explosion): a guest hit by a blast remembers its source for fall_after_explosion 2026-10-31 13:40:53 -01:00
Ludovic MarieandClaude Opus 5.5 bef1eff277 horizon-server events: a stream starts when it's asked for, not when its headers are out
An event emitted between the reply's headers and the stream's first read of the
cursor was skipped (CI caught it in a_stream_sends_what_happens).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:37:22 +01:00
Federico Tagliani 138b02a242 Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 15:35:30 +01:00
Ludovic MarieandClaude Opus 5.5 928cc155fb horizon-server: players, kick, ban, pardon, ban-ip and pardon-ip commands
`players [--json]` shows who is online, the operators, the whitelist and the
bans (a stopped server's from its files); `kick`, `ban`, `pardon`, `ban-ip`
and `pardon-ip` moderate the running server through the control API. The
admin e2e runs them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:30:02 +01:00
valuttaandClaude Opus 5.5 30365e778a Audio: list output devices only when the Core Audio devices change
On macOS, cpal's output_devices() opens every device to see whether it
plays: about 90 ms with twenty devices, once a second on the render
thread. Naming the devices takes 2 or 3 ms, so card_fingerprint now
returns their names there, and the list is looked at only when they
change, as on Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:27:56 +01:00
levzzzandClaude Opus 5.5 da50e90690 Merge main into launcher/native-mod-crates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 +05:00
JoansitohandClaude Sonnet 5.5 eb62069295 Superflat screens: draw layer and preset icons from the item's GUI model
The layer list and the preset list drew the flat item/<name> sprite, so a block
showed as the first texture of its model. They now resolve the item and draw it
as GuiGraphics.renderItem does (isometric for blocks, buckets for water and lava),
clipped exactly to the list; the sprite stays as the fallback.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:21:04 +01:00
Federico TaglianiandClaude Sonnet 5.5 645ee4c546 A projectile knocks a mob the way it travelled
dealDefaultKnockback takes the direction from the projectile's motion
(Projectile.calculateHorizontalHurtKnockbackDirection, negated), not from its
position. The mob takes it from Mob::projectile_motion, set where a
projectile's hit is dealt (arrows, thrown items, the trident), for every mob
and for the sulfur cube's SulfurCube.knockback and LivingEntity.knockback
branches. Checked on six blows against the vanilla helpers, two from an arrow
with its motion.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:14:06 +01:00
TobiasLogicandClaude Opus 5.5 6d7323af8c Shader packs: face normals and mc_Entity for terrain on every GPU
A shaders/ pack's terrain program reads each quad's face normal and mc_Entity only in flat draws
(PackAtlas::terrain_inputs(flat)); otherwise it gets (0, 1, 0) and -1. Flat draws existed only
on Metal and Intel Gen11's Vulkan driver, so on NVIDIA and AMD every pack lit all terrain as if
it faced up and knew no block from block.properties. Flat draws now exist wherever the device
supports them; frames without a pack keep per-section draws outside Metal and Gen11
(flat::for_vanilla), so only a pack's terrain and shadow pass draw flat there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:13:00 +07:00
AkirabaneandClaude Sonnet 5.5 095c6c48cd horizon-server: the launchd test no longer assumes / in paths
service::tests::units_run_the_supervisor expected the agent's log path
as `/srv/R&amp;D/logs/supervisor.log`, but `launchd_plist` builds it with
`dir.join("logs/supervisor.log")`, which Windows prints with a `\` where
the join adds a separator. The code is right (the launchd agent only
exists on macOS); the test assumed the separator, so it failed on Windows,
where CI only builds and runs no tests.

The test builds the expected string from the same `Path::join`, with the
`&` escaped as `&amp;`, and keeps the literal macOS path under
`#[cfg(unix)]`.

Fixes #5168

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:10:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 5b49acb01b Port map: seven rows of #3513 are ported already
HoglinAi and Zoglin initFightActivity (MeleeAttack 40 and 15), AbstractCubeMob
push and dealDamage (hurt_touching_golems), EndCrystal.defineSynchedData
(both sides), Enderman.dropCustomDeathLoot (carried_block_drops) and
Strider.getDismountLocationForPassenger (strider_dismount_location). Each
read against the vanilla method.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:10:09 +01:00
Kacpherek 09e52b9de3 Drop flight when leaving spectator near the ground
Switching from spectator to creative always left the player flying. In
ServerPlayerGameMode.changeGameModeForPlayer a flying player who leaves
spectator mode stops flying when isInRangeOfGround holds: no collider
touches the player's box and Entity.getAvailableSpaceBelow(1.0) is under
one block.
2026-10-31 15:05:07 +01:00
Kacpherek 73dd47046c Let explosions destroy and push experience orbs
Orbs had no health and ignored blasts. Vanilla gives an orb 5 health
(ExperienceOrb.health) and ServerExplosion.hurtEntities calls
ExperienceOrb.hurtServer with the blast damage, which takes it off as an
int and discards the orb at 0 or below; pushFromExplosion then moves the
orbs that stay, from the eyes at 0.85 of the 0.5 high box. The health is
saved and loaded as "Health". Fire and lava damage are not ported yet.
2026-10-31 15:05:07 +01:00
Kacpherek 054124dd6f Lift entities when farmland or a path turns to dirt
Farmland that dried out or lost its support, and a path with a block over
it, became dirt and left whoever stood on it sunk a sixteenth into the
new block. FarmlandBlock.turnToBaseBlock and PathBlock.turnToDirt call
Block.pushEntitiesUp (Block.java): every entity touching the collision
the new state adds is teleported up so it rests on it. The level now
reports the swap and the game lifts the players, guests, mobs and items
the same way.
2026-10-31 15:05:07 +01:00
Kacpherek 0c312c3c96 Crack turtle eggs under walking mobs
Only the player broke an egg by stepping on it. TurtleEggBlock.stepOn
(TurtleEggBlock.java) runs for every entity on the ground that does not
step carefully: canDestroyEgg refuses turtles and bats and needs mob
griefing for other living entities, then the level random rolls 1 in 100
and decreaseEggs plays the break sound (0.7, 0.9 + random * 0.2) and
removes one egg or the block. A mob now rolls that in its block effects
and the game cracks the egg.
2026-10-31 15:05:07 +01:00
Kacpherek 12744874c8 Snuff a candle from a wind burst with the candle sound
A wind charge put a lit candle out with the fire-extinguish sound and no
block-change game event. AbstractCandleBlock.onExplosionHit calls
extinguish(null, ...) (AbstractCandleBlock.java): setBlock lit=false with
flags 11, the candle.extinguish sound at 1.0/1.0 and a block_change game
event; the smoke is a level.addParticle, which a server level ignores.
Use the world's extinguish_candle, which does exactly that.
2026-10-31 15:05:07 +01:00
Kacpherek c780e0890c Open a fence gate from a wind burst without turning it
A wind charge opened a gate by running the click path, which swings the
gate's facing towards the player when it faces them. Vanilla's
FenceGateBlock.onExplosionHit (FenceGateBlock.java) only toggles OPEN of
an unpowered gate with setBlockAndUpdate, plays the open or close sound
at pitch random * 0.1 + 0.9 and posts block_open or block_close with the
old state as context and no entity.
2026-10-31 15:05:07 +01:00
Kacpherek 0eda94e97a Break a pitcher crop under a walking ravager
A ravager only trampled wheat, carrots, potatoes, beetroots and
torchflowers; PitcherCropBlock.entityInside (PitcherCropBlock.java) does
the same for a pitcher crop: with mobGriefing a server level destroys the
block with drops for any Ravager inside it.
2026-10-31 15:05:07 +01:00
Kacpherek d50a163baf Keep an op's spawn egg data on a trial spawner
Using an op's spawn egg with entity data on a trial spawner only set the
mob id and dropped the data. TrialSpawnerBlockEntity.setEntityData runs
TrialSpawner.overrideEntityToSpawn(TypedEntityData), whose
FullConfig.overrideEntityData / TrialSpawnerConfig.withSpawning build each
config's spawn data from the egg's data (TypedEntityData.loadInto), then
the state goes to inactive.
2026-10-31 15:05:07 +01:00
Kacpherek 8d75d8953e Fill an empty flower pot with a plant on right-click
Horizon had only the empty-hand take-out of FlowerPotBlock, so no
potted plant could be made. FlowerPotBlock.useItemOn (FlowerPotBlock.java):
an empty pot clicked with a block item that has a potted variant
(getFullPot) becomes that pot, sends the block_change game event,
awards the pot_flower statistic and consumes one item; any other item
tries the empty hand.
2026-10-31 15:05:07 +01:00
Federico Tagliani 39e0ed339e Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:02:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 bbd87091bf Creeper cloud: the effects keep their visibility on those it affects
MobEffectInstance's copy keeps isVisible, so an effect the creeper had
with hideParticles stays hidden on the player or mob the cloud affects.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:02:18 +01:00
Kacpherek aaaf867e46 Send framed map updates only for a framed filled map
ServerEntity.sendChanges (ServerEntity.java) updates a frame's map only when the framed item is a MapItem; lan_map_updates took any framed stack with a map_id component, so /give stone[map_id=1] in a frame got map packets. That check followed a NeoForge-patched decompile, which replaces the test with if (true).
2026-10-31 15:01:47 +01:00
Federico Tagliani 23f6145aff Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 15:01:07 +01:00
levzzzandClaude Opus 5.5 7b0c59cc0a Native mods: entities leaving a level, for item entities and orbs
native_entity_leave_level(dimension, entity, kind) fires as vanilla's
onTrackingEnd: wherever an item entity or orb is removed (picked up by a
player or a guest, merged, despawned, burnt, blown up, killed, taken by a
hopper or a mob, gone through a portal) and when its chunk unloads,
including a level closing empty. Single removals go through
remove_dropped / remove_orb; the ticks and sweeps compare IDs before and
after. Not told at a server stop, as in vanilla. API 41.

Part of #5162 and #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:32 +05:00
Federico TaglianiandClaude Sonnet 5.5 f684653658 Sulfur cubes: the launch follows any causing entity, and the tags vanilla checks
SulfurCube.knockback reads source.getEntity(): the player, a mob, a
projectile's owner. Game::attacker_aim gives its eye, view direction and feet
for the blows the cube takes from them (melee, spear, mace, arrows, thrown
items, trident, a mob's attack), and the rotation by height uses the
entity's feet, not the projectile's position. As hurtServer does, a blow of
a type tagged no_knockback (explosions, falls...) does not knock the cube,
and one with no causing entity goes through LivingEntity.knockback.
Checked against vanilla's helper bodies on six blows, two from an arrow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:54:26 +01:00
SylviromiandClaude Opus 5.5 d76b3b7343 Native mods: a mod screen over a hopper or dispenser menu keeps its layout
machine_screen() answered Hopper or Dispenser by the menu's type even when
a mod drew its own screen over it, so the player inventory and the menu's
slots sat where HopperScreen puts them (inventory at y 51), not where the
mod's screen said. It now answers none while a mod screen shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:54:15 +03:00
PzmuV1517 0a19224cfe Shader packs: images a GPU can't make are left out, a resize rebinds every pass 2026-10-31 15:54:01 +02:00
PzmuV1517 7d4377297d Shader packs: SSBO and CUSTOM_IMAGES wait for their bindings, three sizes make a 3D image 2026-10-31 15:54:01 +02:00
PzmuV1517 a48662db3c Shader packs: a pass whose buffers differ in size is skipped, images bind in every program 2026-10-31 15:54:01 +02:00
PzmuV1517 e472d6f7e7 Shader packs: shaders.properties sees the pack's options, images wait for compute programs 2026-10-31 15:54:01 +02:00
PzmuV1517 b8f4137e2c Shader packs: 1D stand-ins, samplers check a custom texture's type, pack_textures is tested 2026-10-31 15:54:01 +02:00
PzmuV1517 026faad742 Shader packs: images clear before the shadow programs write them, buffers stay within the device's limits
Clearing image.* happened after the shadow and shadowcomp programs ran, wiping what they
stored; it now opens the frame. A bufferObject.* larger than the device binds is made at the
largest size it takes, with a warning, and its per-pixel size no longer overflows.
2026-10-31 15:54:01 +02:00
PzmuV1517 de054759fb Shader packs: the hand doesn't spin with nausea, and the buffer and image parsers are tested
gbufferModelView holds the nausea spin the world turns with; the hand's projection now takes
it out again, as vanilla's hand never spun. Tests cover bufferObject.*, image.*,
iris.features.* and buffer blocks with and without a binding.
2026-10-31 15:54:01 +02:00
PzmuV1517 83c30ba172 Shader packs: draw passes sample their copies too, and a sky program that writes nothing hides vanilla's
A composite or deferred program that samples colortexN and stores to colorimgN now samples
a copy taken before it runs, as compute programs did; it sampled an empty texture. Whether
vanilla's sky and sun are drawn depends on the pack having the program, not on it building a
pass.
2026-10-31 15:54:01 +02:00
PzmuV1517 8f6c9f7d0c Shader packs: work group sizes a compute program can't use are refused, buffer sizes bounded
workGroups needs three numbers and workGroupsRender two (or three, the last taken as 1);
another count fails the program with that message instead of running it 1x1. size.buffer.*
factors must be finite and positive, and every size resolves to at most 8192.
2026-10-31 15:54:01 +02:00
PzmuV1517 ec259a221a Shader packs: a custom texture over the device's limits is skipped with a note
Its size is checked against the device's largest texture of its dimension and its texels
against that size before anything is created, so a pack asking for too much loses that
texture instead of failing validation at upload.
2026-10-31 15:54:01 +02:00
PzmuV1517 eb54435bef Shader packs: real_packs prints a program's bindings with PRINT_PROGRAM 2026-10-31 15:54:01 +02:00
PzmuV1517 af942d8ae1 Shader packs: read storage buffers and custom images
- bufferObject.<n>: a storage buffer of n bytes (or bytes per pixel of the render size). Buffer blocks are bound in set 4 and lose their access qualifiers so that every stage binds them alike.
- image.<name>: 2D and 3D images of the pack's, with their format, size (or the render size scaled), sampler name and whether they clear before each frame.
- iris.features: SSBO and CUSTOM_IMAGES are supported.
- A 3D image (iimage3D) is declared as 3D.
- naga: atomicAdd and the other atomic functions on buffers and shared memory, imageAtomic*.

Nothing binds them yet.
2026-10-31 15:54:01 +02:00
PzmuV1517 e75b7ff982 Shader packs: run compute programs, buffer sizes and the pack's own sky
- Compute programs dispatch before their composite-style program, over workGroups or workGroupsRender times the target's size; their images bind the side colortexN reads, and a pass that samples and stores one buffer samples a copy taken first. The MC_GL_KHR_shader_subgroup_* macros are defined when the device has subgroups.
- size.buffer: a pass draws at the size of the buffers it writes, and those buffers clear apart from the blit.
- A gbuffers_skybasic or gbuffers_skytextured that writes nothing still hides vanilla's sky or sun and moon, and the world's background is colortex0's clear colour when the pack sets it: vanilla's fog colour was added to Photon's sky, and hid its stars.

Photon v1.4 at 2560x1440 on an M5: 8 to 50 FPS (GPU 125 to 21.5 ms), its sky light from the compute program, quarter-resolution clouds.
2026-10-31 15:54:01 +02:00
PzmuV1517 e944268c3e Shader packs: translate compute programs, read size.buffer, options in shaders.properties
- Compute programs (<name>.csh, then _a to _z) are translated and carried before their composite-style program: images (colorimgN in its own format), workGroups and workGroupsRender, shared memory, barriers and subgroups. Compute-only programs are noted as not supported yet. Nothing dispatches them yet.
- size.buffer.<buffer>: a size of its own, relative to the render size (floats) or in pixels (integers).
- shaders.properties and the program switches see the pack's options at their defaults (switches on, value options): Photon sizes its cloud buffers only under #if CLOUDS_TEMPORAL_UPSCALING == 4.
- naga: subgroup operations, memory barriers, and barrier() as a work group barrier.
2026-10-31 15:54:01 +02:00
PzmuV1517 e65496594f Shader packs: draw custom textures, and what Photon needed to render in game
- The custom textures texture.* names are uploaded and bound to the samplers they name: one texture per dimension of a unit (Photon's 3D scattering LUT under depthtex0), and the lightmap and block atlas by resource location. A sampler with nothing to read gets a stand-in of its own dimension instead of failing its pipeline.
- Outputs past the last buffer RENDERTARGETS names go nowhere, as in GL, instead of an attachment the pass lacks (a crash).
- A shader the driver's compiler refuses (an internal error) skips its program as a validation error does.

Photon v1.4 now loads all 27 overworld programs and draws its lighting, shadows, fog and volumetric clouds.
2026-10-31 15:54:01 +02:00
PzmuV1517 dd028c594f Shader packs: compute-only programs
A prepare, deferred, composite or shadowcomp program of compute stages only (.csh without .vsh and .fsh) runs its stages in order where the program would draw. shadowcomp programs also run the compute stages they carry.
2026-10-31 15:54:01 +02:00
PzmuV1517 3a0de65fa4 Shader packs: IS_IRIS and Iris feature flags, sized array types in global initializers
- IS_IRIS is defined: packs take Iris's extensions (compute programs, images, custom textures) only where it is, and Rethinking Voxels draws an error screen without it. iris.features.optional defines IRIS_FEATURE_<name> for the features Horizon has; a required one it lacks is noted.
- A global initialized with the size on its type (ivec3[6] offsets = ivec3[](...)) kept only the size as its type.
- naga: constant shifts drop the bits shifted out, as GLSL says (1 << 31).
2026-10-31 15:54:01 +02:00
PzmuV1517 19f8944958 Shader packs: bind storage buffers and custom images, and atomics on them
- Storage buffers are zeroed once and kept across frames, bound in set 4 to every program that declares the buffer block.
- The pack's images are made at their size, cleared before each frame when asked, and bound as images by name and as textures by their sampler name.
- Images an atomic function uses bind for atomics; the device asks for texture atomics, storage buffers in vertex shaders and texture clears where the GPU has them.

Rethinking Voxels r0.1-beta9 loads 10 programs and draws; its shadow pass (a geometry stage) and compute-only programs remain.
2026-10-31 15:54:01 +02:00
PzmuV1517 77a69f342f Shader packs: the view bob is in gbufferModelView, not gbufferProjection
Packs invert only gbufferProjection's perspective terms when they rebuild positions, so a bob inside it moved every rebuilt position, and the lighting with it, while walking. The bob and the nausea spin are now part of the model-view, where GL drew them; the product is the same, and the hand's projection no longer adds the bob a second time.
2026-10-31 15:54:01 +02:00
PzmuV1517 01664d49f5 Shader packs: matrix elements in custom uniforms are m.column.row
gbufferModelViewInverse.1.0 read row 1, column 0: the transpose. Packs turn a view direction to the world as inverse.0.0 * x + inverse.1.0 * y + ..., so Photon's sun direction (from sunPosition) turned with the camera: the sky lit as another time of day when looking around, swung with view bobbing, and the sun was missing.
2026-10-31 15:54:01 +02:00
PzmuV1517 3e1641b754 Shader packs: a directive ends a continued property value 2026-10-31 15:53:50 +02:00
PzmuV1517 6485363dc2 Shader packs: a pack that defines its own inverse() keeps it 2026-10-31 15:53:50 +02:00
PzmuV1517 6edd81d368 Shader packs: a continued property value stops where its branch does
shaders.properties is preprocessed before its lines join: a value continued with \ just before #else (Photon's moon_phase_brightness) swallowed the directive and the other branch, so the uniform was never defined. Directives now apply while lines join, and lines of branches they turn off are dropped.
2026-10-31 15:53:50 +02:00
PzmuV1517 17639892ec Shader packs: a raw texture's sizes are bounded and checked
Each dimension of a TEXTURE_* entry is at most 16384, and the bytes it needs are counted
without overflowing, so a pack asking for a huge volume gets a note instead of a wrapped
length check.
2026-10-31 15:53:50 +02:00
PzmuV1517 a59d378bf4 Shader packs: struct varyings, Vulkan type names as identifiers, naga's GLSL gaps
Photon translated 45 of its 88 composite-style programs and none of its overworld terrain, sky set-up or final pass; now all 88 translate and every dimension loads its full set.

- A struct passed between stages (flat out CloudsParameters p) keeps the pack's declaration as a private global and travels as its members packed into vectors, copied by our main.
- sampler, texture2D and Vulkan GLSL's other texture and sampler type names are free names in desktop GLSL (sampler2D sampler); they are renamed unless called.
- naga's GLSL frontend lacked textureGather, sampled implicit levels in vertex shaders, broke struct constructors with array members, vecN(bool) and vector % scalar: naga is patched with the fixes until a release has them.
- real_packs prints every loader note with SHOW_ERRORS.
2026-10-31 15:53:50 +02:00
PzmuV1517 6b0f0261e9 Shader packs: read custom textures (texture.*)
texture.<stage>.<name> and texture.noise name the files a pack binds to its samplers: PNG images, and raw 1D, 2D and 3D data with their size, format and type, each with its .mcmeta (blur, clamp). Post carries them, with a note for each that can't be read; nothing draws them yet.
2026-10-31 15:53:50 +02:00
PzmuV1517 914e5d0103 Vendor naga 30.0.1 unchanged
Shader packs need fixes to naga's GLSL frontend that no release has yet. They go on top of this
copy in the commits that need them, as wgpu-hal's fix did, instead of a git dependency.
2026-10-31 15:53:50 +02:00
PzmuV1517 d085cabe41 Shader packs: outputs narrower than vec4, inverse(), alphaTestRef and the cutout test
- A fragment output declared float, vec2 or vec3 (int and uint too) is written as the vec4 of its kind, alpha 1: wgpu's targets take four components.
- inverse() of a mat2, mat3 or mat4 is a function of ours (the adjugate over the determinant): naga writes it neither for Metal nor for WGSL.
- alphaTestRef is a constant of the program: its alpha test's reference.
- Solid and cutout terrain draw together, so terrain gets the cutout test (alpha > 0.1) unless alphaTest.* says otherwise.
- The naga pin moves to the fixes for pointers into vectors passed as out arguments and field selection on a matrix column.
2026-10-31 15:53:50 +02:00
PzmuV1517 c29967b1aa Shader packs: macro calls whose arguments span lines
A function-like macro called across lines (Photon's clamp01(\n ... \n)) was left unexpanded. Its lines now join the line the call starts on; the lines after it stay, empty, so line numbers still map back.
2026-10-31 15:53:49 +02:00
Ludovic MarieandClaude Opus 5.5 48ccd93267 horizon-server: an event stream in the control API
GET /api/v1/events streams server-sent events: every log line, the
supervisor's phase, and the game's typed join, leave, kick, chat and death
events with the player's name and UUID (?types= picks some; Last-Event-ID or
?since= resumes). Without Accept: text/event-stream it answers the events
after since as JSON. The dedicated server records the typed events where it
logs them and answers `server-events SINCE` on its control endpoint; the
supervisor fetches them every 250 ms. The admin e2e streams a bot's joins,
chat (a raw chat packet), kick, leave and death.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:49:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 21b8d1f527 A creeper with effects leaves a lingering cloud when it explodes
Creeper.spawnLingeringCloud: right after the blast an AreaEffectCloud of
radius 2.5, wait time 10 and 300 ticks stays where the creeper stood,
shrinking by radius / duration a tick and by 0.5 for each entity it
affects, with the creeper's active effects at 0.25 of their remaining
duration. The potion cloud takes its radius per tick and its effects as
data now, and keeps an infinite effect infinite (withScaledDuration).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:42:32 +01:00
arismoko f30e87e791 Scope native hook stamp workflow permissions 2026-10-31 08:40:36 -05:00
levzzzandClaude Opus 5.5 2af60d68a2 Native mods: entities joining a level, for item entities and orbs
native_entity_join_level(dimension, entity, kind, pos, loaded_from_disk,
canceled) fires as vanilla's PersistentEntitySectionManager.addEntity:
from spawn_item, add_orb, /summon and chunk loads. loaded_from_disk is
true only for an entity that comes back with its chunk; portal and
gateway arrivals and /summon go through the chunk-load queue, so they are
marked there as fresh adds. A cancel takes the entity back out, and a
/summon of it fails with commands.summon.failed.uuid. API 41.

Part of #5162 and #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:14 +05:00
arismoko ed4501ac13 Open native hook stamp pull requests after merges 2026-10-31 08:40:04 -05:00
Federico Tagliani 6b333b2754 Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 14:35:18 +01:00
ElyTheCatandCodex 89a68f75de Fix boot loading screen merge regressions
Co-Authored-By: Codex <[email protected]>
2026-10-31 17:03:11 +03:30
huiandZCode AI agent (GLM-5.3) c5f62fe73c Native mods: a block's fluids, read, filled and drained in millibuckets
native_block_entity_fluids declares a mod block's tanks, as its container
declares its slots: each tank's capacity, the tanks each face reaches, and
which a fill may reach and a drain may take. The tanks' contents live in
the entity's saved compound under `fluids`, so they survive restarts and
the block's own tick reads them there.

native_fluid_tanks reads them through a side, native_fluid_insert fills
(first into tanks holding the same fluid, then into empty ones) and answers
what did not fit, native_fluid_extract drains and answers what it took.
Both also work a cauldron: it holds thirds of a bucket, a lava one only
whole, and powder snow none, as it is no fluid. Water and lava are the
fluids; a tank holds one at a time.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 14:32:22 +01:00
Federico TaglianiandClaude Sonnet 5.5 3bb2c7d3e4 Sulfur cubes: a blow turns the launch by where the attacker aims
SulfurCube.knockback with a block now applies applyHorizontalHitAngleScale
(1.6) and applyVerticalHitAnglePowerTransfer (0.5) with the attacker's eye
and view, then the position rotation, in vanilla's order and float
arithmetic (Vec2.rotate through Mth's tables, Math.atan2). The player's
melee, spear and mace blows carry their aim. Checked against the bodies of
the vanilla helpers run on the real Vec2, Vec3 and Mth classes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:28:54 +01:00
arismoko baf72e46c1 Clarify fragment documentation 2026-10-31 08:23:58 -05:00
ElyTheCatandCodex c78d4495f0 Merge current main into boot loading screen
Co-Authored-By: Codex <[email protected]>
2026-10-31 16:53:50 +03:30
chi4a 5e0af16e10 Merge remote-tracking branch 'upstream/main' into fix/entity-chest-open 2026-10-31 15:21:12 +02:00
arismoko 17ca3981f0 Support helper-only API fragments 2026-10-31 08:21:12 -05:00
sirrayiandDeepSeek Harness c1a49d50e6 Server: the interact packet's entity keeps the crosshair pick's filters
Review found that the by-id path dropped what `mob_hit` applies: a mob in its
death animation, the mob the player rides and the camera entity are never
clicked by the pick, so naming one in a packet must not reach the feed, shear,
leash or name paths either. `named_mob_pickable` is that rule, shared by both
paths and unit-tested on its own, since the pick's copy cannot be tested
without a world.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:21:08 +00:00
sirrayiandDeepSeek Harness 70bf1afb05 Server: an Interact packet's entity is the entity it clicks
ServerGamePacketListenerImpl.handleInteract resolves packet.entityId(), checks
it and the range, and hands that same entity to Player.interactOn. Horizon aimed
the guest at the named entity and then picked one again from the view, so a mob
that moved between the eye and the target took the click, and a block in the way
stopped an interaction the packet had already validated (#4651).

GuestUse::Entity now carries the packet's id and location, and the interact_
helpers use it as it is; the local player still picks from the crosshair, the way
Minecraft.hitResult does.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:21:08 +00:00
levzzzandClaude Opus 5.5 fc9326b05e Native mods: read and change item entities near a block
native_item_entities(dimension, min, max) lists the item entities whose
box meets a box (Level.getEntitiesOfClass(ItemEntity.class, box), as a
hopper's getItemsAtAndAbove asks); native_set_item_entity(dimension, id,
stack) changes one's stack or removes it when empty (ItemEntity.setItem,
discard), as a hopper's addItem does. On the level in place. API 41.

Fixes #5150. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:19:18 +05:00
Federico Tagliani 0a6ecf0231 Merge remote-tracking branch 'origin/main' into fix/chat-validation-error 2026-10-31 14:15:42 +01:00
arismoko e7c2951d4c Batch native hook API numbers from fragments 2026-10-31 08:15:39 -05:00
sirrayiandDeepSeek Harness 40fa42e239 Mod screens on hopper menus get their own slots back
`slot_position` asks the hopper's and dispenser's stock layout before its
native-screen arm, so a mod screen on a `minecraft:hopper` or
`minecraft:generic_3x3` menu never reached the slots it declares, and its
`inventory_y` was ignored: the mod drew its screen while every slot stayed on
the stock grid (#5141, item from #4953).

Only that layout stands back, and only while a mod screen is shown; the rest of
those menus was already the mod's, since `draw_native_screen` owns the
background and `screen_height` the height. With no mod screen the stock grid
answers exactly as before.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:15:25 +00:00
levzzzandClaude Opus 5.5 ce12d07965 Native mods: the server level's random (native_level_random)
native_level_random(dimension) hands a mod the level's own random
(vanilla's Level.random, level.getRandom()), the stream Horizon's ports
of vanilla draw from, for the level in place and for a parked one. None
on a client of a server and for a level that isn't open. API 41.

Fixes #5145. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:12:47 +05:00
arbelonson-sourceandClaude Opus 5.5 da62023123 Clear waypoints when the server reconfigures the client
Vanilla's handleConfigurationFinished builds a new ClientPacketListener,
whose constructor makes an empty ClientWaypointManager. Horizon kept the
last server's waypoints, so behind a proxy the locator bar stayed up on
a server with the locator_bar game rule off.

Refs #4760

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:08:32 +02:00
Federico TaglianiandClaude Sonnet 5.5 7cec464d45 Chat from other players is validated as vanilla's client does
ClientPacketListener.handlePlayerChat checks the global chat index (a mismatch
disconnects), the sender's PlayerInfo, and the sender's SignedMessageValidator:
keyed by the chat session the services' profile-key keys vouch for, or the
fallback of enforcesSecureChat. A message that fails shows the red italic
chat.validation_error in its chat type, tagged chatError
(ChatListener.handleChatMessageError); one a validator takes without a
signature shows as unsigned. The services' keys are fetched once per
connection off the game thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:06:51 +01:00
levzzzandClaude Opus 5.5 cf57da8885 Native mods: hook points for the server starting and stopping
native_server_about_to_start(dedicated) fires before the worlds load (the
start of MinecraftServer.loadLevel; NeoForge's ServerAboutToStartEvent),
native_server_stopping(dedicated) when an orderly stop begins (the start of
MinecraftServer.stopServer; ServerStoppingEvent), with the players still
connected and not yet saved. Both on the single player server thread and on
a dedicated server. API 41.

Fixes #5138. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:04:04 +05:00
oskal675-designandCodex GPT-6.1 12af64aab1 Keep End gateway exits only in block entity NBT
Migrate legacy side files before loading End chunks and retain gateway source chunks while resolving new exits.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 18:03:26 +05:00
huiandZCode AI agent (GLM-5.3) 9cf6a6d154 Recipes load in id order, unreadable folders warn, the book reads subfolders
recipe_files sorts by the recipe's id, as vanilla lists resources in a map
keyed by it: crushing before crushing/iron, where a path's components put the
subfolder's file first. A subfolder or an entry that can't be listed is warned
and skipped, as PathPackResources keeps what it found; only the top folder's
read still fails. RecipeIndex::load walks the same files, so vanilla's
brewing/ recipes (279 of them, minecraft:brewing/<name>) reach the recipe
book's ids; a brewing recipe has no display of its own, like the special ones.

Review: reservedbytes in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 14:03:09 +01:00
sirrayiandDeepSeek Harness f5d8035626 Player swimming needs !isPassenger(), in both of Entity.updateSwimming's branches
Horizon keeps two ports of that method. crates/horizon-world/src/swimming.rs's
`update` carries `!is_passenger()` in both branches and is compared against the
real class by tools/test_swimming_parity.py, whose oracle varies the flag; the
client's copy in motion/fluids.rs::fluid_base_tick left the term out (#4427).

Only the term and the flag that feeds it: main already sits a riding guest up
through passenger_pose_tick, so this drops the duplicate of that.

The new test warms the fluid tick once first — fluid_base_tick samples
was_eye_in_water before it updates the fluids, so a fresh body could not report
is_under_water and the test panicked wherever .local/ existed.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:00:02 +00:00
BlzsandClaude Opus 5.5 3a88b6339f Options sliders: list the monitor's video modes once per monitor, not at every drag step
Settings apply at every pointer move of a dragged slider, and since the
Exclusive Fullscreen rows that listed the monitor's video modes each time:
about 28 ms on Windows, so the handle lagged and jumped. The list is now
kept until the window's monitor changes, in game and on the startup menu.
horizon-ctl options-set keeps it too (with_option rebuilt Settings without it).

Fixes #5133

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:59:46 +01:00
socksproxandDevin (SWE-2 High) d27432c461 Bound the painting and mob picks in attack() by the unfiltered block hit
The last filtered pick()s that compare a distance: a painting or mob
behind a wall in the band between block and entity interaction range
could still be broken or attacked through it. before_block() runs the
same comparison against pick_unfiltered(), as the other entity picks do.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:56:07 +01:00
socksproxandDevin (SWE-2 High) f7f2747724 Launcher: put split secrets back when the keychain refuses a delete
set_item wrote the merged secret over key before removing key.1, key.2,
...; a removal that then failed left the merged item beside leftover
pieces, and the next read joined them into a secret that doesn't parse.
The account looked signed out, and the fold retried on the corrupted
string until a delete went through and made it permanent. The same gap
was in set_pieces' leftover clean-up on Windows.

Both now snapshot the items first and write the old layout back when a
delete is refused, so a failed write leaves the store reading the
secret it answered before — pieces removed before the failure included.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:52:31 +01:00
socksproxandDevin (SWE-2 High) a4ecd4ebb6 Bound entity picks by the unfiltered block hit
`GameRenderer.pick` bounds the entity ray by the block hit before
`filterHitResult` drops it past block reach: a wall between the two
ranges still hides the entity. `pick_unfiltered` keeps that traversal
for `before_block` and the deflect and mob-interact occlusion checks;
`pick` keeps the filter only where a block target is consumed.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:47:06 +01:00
TobiasLogicandClaude Opus 5.5 f62ae6ea53 Shader packs: terrain colours without the game's face shading
With a shaders/ pack, Iris gives terrain, translucent terrain and fluids their colour without
vanilla's face shading: packs light faces themselves (oldLighting, off unless the pack turns it
on). Horizon baked the shading in, so packs shaded faces twice: a probe pack's colours per face,
Horizon over Iris, were 0.8 north and south, 0.6 east and west, 1.0 up. Sections now mesh
without it while a shaders/ pack runs (Mesher::shader_pack_lighting), and remesh when that
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:33 +07:00
mularealandAntigravity 0b179e13dc lan: keep moves queued behind an attack or interaction for the next tick
Fixes #4524.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 13:41:35 +01:00
SylviromiandClaude Opus 5.5 c1eb16913a Native mods: using an entity forgets the last clicked block
A click on a block that opened nothing left use_target set, so a menu an
entity opened next (a chest minecart's generic_9x3) could take the machine's
screen when the machine declared the same menu (arismoko on #5061). Using an
entity now clears it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:41:21 +03:00
AkirabaneandClaude Sonnet 5.5 57c54aac90 Recipe results: a bare item id names the item, as vanilla reads it
Vanilla's ItemStackTemplate.CODEC (and TransmuteResult.CODEC) is
Codec.withAlternative(<id, count, components>, Item.CODEC): a recipe's
"result" is either the stack's fields or just the item's id, so
"result": "mod:item" makes that item. Horizon read only result.id, so a
recipe written that way loaded and made minecraft:air.

horizon_world::loot::recipe_result_id reads both forms, and every place
that reads a recipe's result uses it: Template::parse (shaped, shapeless,
cooking and the special recipes), the banner duplicate and shield
decoration recipes, smithing and stonecutting, the recipe book, the
stonecutter and advancement packets, and the smelting loot modifier.

A result with no "id" (such as the "item" key of older versions) still
loads here as an empty stack; vanilla refuses the recipe. That is left
for its own change.

Tests: the helper, Recipes::load with a bare result, and a content mod
whose recipes make its own items either way (the full content root test).

Fixes #4904

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:39:32 +01:00
k3yhandClaude Opus 5.5 eb932b91a3 Keep a NaN's bits in network NBT, as vanilla does (#5064)
Vanilla writes network NBT (FriendlyByteBuf.writeNbt) through Netty's
ByteBuf, whose writeFloat and writeDouble keep a NaN's bits; only the
disk path (NbtIo.write through DataOutputStream) writes the canonical
NaN. Horizon canonicalized on both. The writer now canonicalizes only for
write_named, and the old test that asserted a canonical network NaN
checks both paths against vanilla 26.3's bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:38:32 -05:00
masa-masa-0314andGLM 5.3 9a1dc67c0d Localization: the server connection's phases follow the language
The connect flow drew its phase texts as literals on the net thread:
"Connecting to the server..." (also the Remote's initial status), "Joining
world..." and "Finding your friend's world..." stayed English in every
language while the middle phases ("Logging in...", "Encrypting...") were
already vanilla keys. The three now go through `connect.connecting`,
`connect.joining` and Horizon's new `horizon.multiplayer.finding_friend`
(all six bundles), and the local world-open progress line says
`horizon.loading.opening_world` / `horizon.loading.cancelling_world`
instead of hardcoded "Opening world" / "Cancelling world".

`NOT_SIGNED_IN` stays literal on purpose: `reconnect::allowed` matches
disconnect reasons with English keywords ("online mode", "microsoft
account"), so a translated message would slip past the never-retry list.
Matching on keys instead of texts is its own fix.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 20:36:51 +08:00
socksproxandDevin (SWE-2 High) 46329b3cb6 Keep the scaled seat of a scaled non-avatar vehicle
`remote_mob_attributes` puts `minecraft:scale` on any living entity, but
`awake_dims_in` grows only avatars' boxes: a scaled strider's `height()`
stays unscaled, so un-divide the default attachment only for them.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:31:39 +01:00
levzzz 8306da9cc3 Merge #4590 into launcher/native-mod-crates
Builds keep #4590's profiles, plan and timing and gain #3964's crate
check and post-build steps; post-build programs build with the chosen
profile. The strings #3855 retired stay out of the translations.
2026-10-31 17:31:02 +05:00
levzzz 9b6aa1342a Merge #3891 into launcher/native-fast-profile
#3855 (in #3891) moved Native mods from Settings to the Mods page: the
build's time, how it ended and the Release build switch move with it, to
the instance's Native build row. The install notice #3855 removed stays
removed; the build record keeps #3891's per-mod sha256.
2026-10-31 17:30:28 +05:00
Federico TaglianiandClaude Sonnet 5.5 b103e5821b F3 Density line: no in-frame fallback
The window's server thread publishes the generators; end.locator is only
set on the deprecated in-frame path, which is not extended.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:29:47 +01:00
oskal675-designandCodex GPT-6.1 c6c043e8b5 Resolve enchantment-dependent component item models
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 17:28:33 +05:00
MSedgeMC 43a17d0af9 Draw a display's shadow, interpolated as vanilla draws it
A display entity carries shadow_radius and shadow_strength, and vanilla
interpolates both across its interpolation window and casts a shadow when
the radius is above zero, through the same gates a mob's shadow uses.
Horizon parsed and blended neither: the two fields sat unread and the
shadow pass collected only mobs, dropped items and experience orbs.

The blend now carries them, the gate arithmetic lives in one function that
both casters call, and text, block and item displays join the pass.
2026-10-31 17:56:23 +05:30
MSedgeMC 67979afe8d Read the projectile power the server sends with a projectile's motion
ClientPacketListener.handleProjectilePowerPacket finds the entity by id and,
when it is a hurting projectile, takes the power it accelerates by. Horizon
decoded the packet and dropped it, so a deflected fireball - whose power
halves - kept the spawn default on the client between tracker updates. Our
own server already sends it bundled with the motion.
2026-10-31 17:53:57 +05:30
ElyTheCatandCodex c37ca8bbc3 Preserve guest interaction ranges
Co-Authored-By: Codex <[email protected]>
2026-10-31 13:23:57 +01:00
Federico TaglianiandClaude Sonnet 5.5 b2c52b5036 F3 Density line: the cache is a type with a test
DensityCache holds DebugEntryChunkGeneration's lastPos and result; the test pins
the sampling on a new block or dimension, the cleared line without a
generator, and the clear when another world opens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:22:28 +01:00
huiandClaude Opus 5.5 815de4119d Native mods: hook points for chunks and players coming and going
native_chunk_load, with whether the chunk is new, and native_chunk_unload,
including a level that closes for being empty. native_player_logged_in and
native_player_logged_out come in pairs however a player leaves: a disconnect, a
kick, the server stopping or failing. At a stop they're told before the save,
still listed, and a last message goes out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:19:58 +01:00
SylviromiandClaude Opus 5.5 3b77664bdb Native mods: recipe helpers work on servers, which have no player
native_recipes_of_type, native_recipe_ingredient and native_recipe_result
treated a Game with no player as outside a world. A server's Game (the
server thread, a dedicated server, a LAN host) never has one, so a machine
ticking there got no recipes, though their docs say they are the server's
table. Outside a world now means a client with no player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:18:28 +03:00
chi4aandDeepSeek Harness 45cad03deb Fix vehicle container close events and the tick close
Use the vehicle's own position for CONTAINER_OPEN and CONTAINER_CLOSE: vanilla
Entity.gameEvent calls level().gameEvent(source, event, this.position) and
AbstractChestBoat/MinecartChest.stopOpen use position(), not y + 0.5.

Close a vehicle menu through one helper: store the menu's slots back, remove the
shared placeholder, and fire CONTAINER_CLOSE for a chest boat or chest minecart
(no-op for a hopper minecart). The ServerPlayer.tick stillValid close
(out of reach or vehicle gone) now uses it too, so it no longer leaves the
placeholder behind or skips the event, matching closeContainer -> removed ->
stopOpen.

Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-31 14:12:38 +02:00
Federico TaglianiandClaude Sonnet 5.5 50b64cd002 F3 Density line: nothing drawn or kept without a generator
DebugEntryChunkGeneration adds its group only in the server-level branch. With no
generator (a remote server, a LAN world joined, the server thread not there
yet) the cached line of the last world was still drawn; it is cleared
there, and when another world opens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:10:10 +01:00
TobiasLogicandClaude Opus 5.5 22e5ef2029 Shader packs: custom uniforms' smooth starts from the first real value
smooth() starts from the first value it sees. The load-time dry run that finds unknown names
evaluates every custom uniform with the built-ins at zero, so each smooth() started there:
Photon's biome_temperature (smooth over 1000 s) read -0.97 on the plains where Iris reads 0.6 at
once, and took minutes to get there. Smoothing now restarts after the dry run, and on frames
before the camera's biome is known (its chunk not loaded yet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:03:07 +07:00
AurelienBelloirandClaude Opus 5.5 fe752c7ac5 horizon-server: the lists test waits for a fork-held lock descriptor to close
edits_the_lists_while_stopped failed now and then on Linux CI with "a server
is already running in /tmp/horizon-server-lists-<pid>". The folder's lock is
an flock taken with try_lock; while another test in the same binary spawns a
process (tar, id, the engine), the child holds a copy of the lock's
descriptor between its fork and exec, so the lock is briefly taken and
try_lock answers WouldBlock. one_supervisor_per_folder already retried for
that window (4513af899); the lists test did not, neither for its own lock nor
for the edits, which take the lock inside lists::run.

The retry moves into supervisor::retried, a test-only helper (50 tries, 20 ms
apart), used by one_supervisor_per_folder and by the lists test for its lock
and for the edits that must succeed. The edits that must fail while the test
holds the lock are unchanged, and so is lists::run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:02:58 +01:00
ElyTheCatandCodex 0c651136a5 Fix server-side recipe book placement
Use the active world recipe tables when handling remote recipe placement, and let the server own remote grid and ghost updates instead of predicting them on the client.

Co-Authored-By: Codex <[email protected]>
2026-10-31 15:32:41 +03:30
oskal675-designandCodex GPT-6.1 cf8ed9d8c3 Render each remote hand with its own item stack
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 17:01:21 +05:00
k3yhandClaude Opus 5.5 0cffa9fa03 Read LZ4 region chunks as lz4-java reads them (#5064)
LZ4BlockInputStream decompresses each block with lz4-java's
LZ4JavaSafeFastDecompressor and calls it corrupted unless the decompressor
reads exactly the block. That decompressor has its own end conditions: a
sequence whose literals leave less than 8 bytes of output, or of input
past them, must be the last and end the output, and an offset of 0 fills
the match with zeros. lz4_flex's decoder disagrees both ways, so Horizon
loaded some streams lz4-java refuses (with whatever they decoded to) and
refused some it loads.

decompress_lz4_java ports that decompressor, fed from a buffer kept as
LZ4BlockInputStream keeps compressedBuffer (it may read past the block,
up to the buffer's end), and decode_lz4 requires it to read the whole
block. lz4_flex still compresses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:57:33 -05:00
AurelienBelloirandClaude Opus 5.5 6dd82d9d30 Merge remote-tracking branch 'origin/main' into parity/data-pack-dialogs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:56:10 +01:00
Federico TaglianiandClaude Sonnet 5.5 6c8584a70d F3 Density line: sampled when the feet block changes, without the query worker
DebugEntryChunkGeneration.display keeps lastPos and result and recomputes
only when the feet BlockPos changes; F3 sampled every frame. The line reads
the pipeline's samplers only, so it now goes through the generator and no
longer takes the shared query worker (no try_lock, no frame without the line).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 12:48:13 +01:00
Federico Tagliani 27bd4f8313 Merge remote-tracking branch 'origin/main' into feat/f3-density-line 2026-10-31 12:46:01 +01:00
oskal675-designandCodex GPT-6.1 db171db98f Preserve LAN authentication fallback during server login
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 16:44:39 +05:00
huiClaude Opus 5.5ZCode AI agent (GLM-5.3)
7fc7fc4735 Native mods: read and take a block's items through a side, and move them
native_container_slots, native_extract_item and native_move_items follow the
rules hoppers use; a move puts a refused item back, so none is lost.
native_dimension names the level for a machine's tick, which isn't told it.

native_insert_item goes one item at a time and tells the container once. It
no longer overfills an empty slot, leaves a client's copy alone, and can't
reach a machine's slots during its own tick, where what went in was lost.

Hoppers change with it, as vanilla has them: a shulker box doesn't go into a
shulker box, a stack's max_stack_size component sets its limit, and comparators
read it. A comparator's fill ratio is clamped to full, where vanilla's
count-at-most-limit invariant keeps it: a stack a mod wrote over its own
limit can't push the signal past 15. A slot whose block entity went away
keeps the item, rather than losing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 12:43:04 +01:00
masa-masa-0314andGLM 5.3 5876fa5ff8 Localization: the merchant screen follows the language, and Horizon's own strings come in Chinese
Three fixes for text the language screen could not translate:

- The merchant screen drew its title, villager profession, trade level
  ("Novice".."Master"), the Trades caption, the inventory caption and the
  out-of-stock hint through `Enchantments::translate` — a pinned en_us map
  loaded once at startup — so they stayed English in every other language.
  They go through `lang::get_or` now, like the rest of the menus.
- The reconfiguring screen's button used the key `gui.disconnect`, which
  no language file has (vanilla's button is `CommonComponents.GUI_DISCONNECT`
  = `menu.disconnect`), so the raw key showed on screen. It is
  `menu.disconnect` now.
- Horizon's own strings had no zh_cn bundle: the settings, graphics, HDR,
  controller, extras, shader packs, mods and AI agent pages, and the
  multiplayer invite dialog, were English for a Chinese player. A complete
  zh_cn bundle ships now (the five other bundles already do; the language
  is picked up by `Language::load`'s source scan like the rest).

Checked the whole game for the first two classes with a key audit: every
key a `lang` call passes now resolves in vanilla's en_us or Horizon's
bundle, vanilla-namespace keys all have zh_cn values, and no production
text draw keeps a hardcoded English literal. `test_instance_block.invalid_test`
looks like a missing key but is vanilla's own (`TestInstanceBlockEntity`
translates it without the key existing in any language file), so it stays.
Horizon-only command feedback (/raid, /profiler, the agent's messages)
still speaks literal English by house style; localizing those would need
new keys across all six bundles and is left alone here.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 19:39:53 +08:00
Ludovic MarieandClaude Opus 5.5 42f51485f4 lan_e2e: reopen_player, the owner keeps position and items over Save and Quit
The integrated server's owner is saved in the world's own player file;
#4866 stopped writing it on the server thread and nothing caught it
(#5065, #5075). The check moves both players onto glass blocks with
items, saves and quits, reopens and compares (the guest's own file too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:39:30 +01:00
ElyTheCatandCodex d692215361 Fix LAN packet phase lifecycle
Co-Authored-By: Codex <[email protected]>
2026-10-31 15:08:46 +03:30
k3yhandClaude Opus 5.5 9f0d819f6b Rewrite the whole region header on a chunk write (#5064)
RegionFile.write and clear end in writeHeader, which writes both header
sectors from memory. The constructor has already zeroed the entries it
found invalid there (overlapping the header, empty, past the end of the
file), so a write leaves them zero on disk. Horizon wrote only the
touched slot's offset and timestamp, so stale invalid entries stayed in
the file for other tools to read.

write_header replaces write_header_entry and writes both sectors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:35:15 -05:00
apeirsandClaude Opus 5.5 184a48b55b Recipe book: Space and Enter place the last clicked recipe again
RecipeBookComponent.mouseClicked keeps the recipe a click placed as
lastRecipe (slot clicks keep it, unlike lastPlacedRecipe), and
RecipeBookComponent.keyPressed places it again on Enter, Space or numpad
Enter (isSelection), key repeats included, with the key's Shift. Holding
Space and shift-clicking the result crafts an inventory quickly. Horizon
had neither the field nor the key path. horizon-ctl's key command takes the
same path, as for the merchant's offers.

lan_e2e: recipe-reselect crafts wool three times from one book click (Shift
held Space, then Enter), on the host and on a guest.

Fixes #5099

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:32:06 +01:00
SylviromiandClaude Opus 5.5 ae1eb14b17 Creative tabs: look the title up as a lang key (the code for the previous commit)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:31:25 +03:00
SylviromiandClaude Opus 5.5 4c41466e2e Creative tabs: the title may be a lang key
A mod usually has its own itemGroup.<mod id> already (reservedbytes on
#5066): "title" is looked up as a lang key first, English text when it is
none. itemGroup.<mod>.<name> still overrides it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:29:45 +03:00
hauserdew-uiandBig Pickle da185c6128 Audio: list output devices only when the ALSA cards change
cpal 0.16 lists ALSA devices by opening a hardcoded set of names
("default", "pipewire", "pulse", "jack", "oss") for playback and
capture. Every missing plugin prints two ALSA errors, so the per-second
device check in Audio::should_change_device printed six "Unknown PCM"
lines and spent 5-9 ms (spiking to ~70 ms) enumerating a list that only
changes when the ALSA cards do.

Read /proc/asound/cards (a few microseconds) and enumerate only when it
changes; without a fingerprint, keep checking as before. Open the
default output first and enumerate only when it cannot open.

Co-Authored-By: Big Pickle (opencode) <[email protected]>
2026-10-31 13:28:05 +02:00
Kacpherek 80106eb50d Light a vex, allay and wither at 15 block light
Only Blaze and Magma Cube (and burning mobs) were lit fully, and with the sky
light forced to 15 as well. AllayRenderer, VexRenderer and WitherBossRenderer
override getBlockLightLevel to 15 like BlazeRenderer, and EntityRenderer's
default gives a burning entity 15; getPackedLightCoords keeps the sky light
the level's. These mobs were dark in caves.
2026-10-31 12:26:37 +01:00
Kacpherek 7c0c1f6be3 Read, save and send a phantom's size
A phantom's size was never read: /summon phantom {size:N} and a server's
phantoms stayed size 0, so they were never drawn larger (PhantomRenderer.scale
uses 1 + 0.15 * size). Phantom.setPhantomSize (Phantom.java) stores 0 to 64 in
ID_SIZE and updatePhantomSizeInfo sets the attack damage base to 6 + size and
refreshes the box, scaled by 1 + 0.15 * size in getDefaultDimensions. The size
is now loaded and saved as "size", sent to LAN guests as data 16 and read from
a server's.
2026-10-31 12:26:37 +01:00
Kacpherek 66e3585fa8 Draw the special model of a block display's chest, head or banner
A block display of a chest, banner, skull, shulker box, bell, conduit,
decorated pot, enchanting table or the end portal and gateway showed only the
empty block model. BuiltInBlockModels gives these blocks a special model, and
CompositeBlockModel.update adds it to the block model's quads. The display now
draws it through the block-entity renderer, as a chest minecart does its
chest.
2026-10-31 12:26:37 +01:00
Kacpherek 361c9f9fff Draw the item a dolphin carries at its snout
A dolphin holding an item showed nothing: its model has no arm bones for
ItemInHandLayer's hand lookup. DolphinCarryingItemLayer.submit
(DolphinCarryingItemLayer.java) draws the main hand's item in the GROUND
context at (0, 1, -1), then moves it with |pitch| / 60: y by -0.5 and z by
+0.5 for a negative pitch, y by +0.8 and z by +0.2 otherwise.
2026-10-31 12:26:37 +01:00
Kacpherek 0877a0b76a Show break cracks on a conduit and an enchanting table's book
Mining a conduit or an enchanting table drew no crack stage on their
block-entity models. ConduitRenderer.submit and EnchantTableRenderer.submit
call submitCrumblingOverlay after the shell (inactive) or cage (active) and
after the book; the wind and eye are not overlaid. render_breaking now
covers both.
2026-10-31 12:26:37 +01:00
Kacpherek 37d6e42013 Hold a copper golem's item as CopperGolemModel does
A copper golem's item sat at the humanoid hand offset. CopperGolemModel.translateToHand
(CopperGolemModel.java) turns the item 90 degrees about the arm and moves it
0.125 forward while the golem is idle, and otherwise scales it to 0.55 and
moves it (-0.125, 0.3125, -0.1875). Both run before ItemInHandLayer's own
offset.
2026-10-31 12:26:37 +01:00
Kacpherek 5896db7b9c Jitter an angry enderman sideways each frame
An enderman with a target (isCreepy) stood still. EndermanRenderer.getRenderOffset
(EndermanRenderer.java) adds nextGaussian() * 0.02 * scale to the render
position's x and z every frame. The offset is added to the entity's render
origin, seeded per mob and frame so the body and carried block agree.
2026-10-31 12:26:37 +01:00
Kacpherek 770dd407d5 Describe the grown frog's finalization without a vanilla citation
Word the finalize doc without claiming a port.
2026-10-31 12:26:28 +01:00
Kacpherek 13b200f8c8 Wear down the helmet of an undead mob burning in daylight
A zombie or skeleton in sunlight with a helmet was simply spared, so the
helmet never lost durability and never broke. Mob.burnUndead hurts the
item in sunProtectionSlot (head; body for a zombie horse or nautilus) by
random.nextInt(2) when it is damageable and only ignites the mob when the
slot is empty. A broken item leaves the slot and
LivingEntity.onEquippedItemBroken broadcasts the break event (49 head,
65 body) for the sound and crumbs.
2026-10-31 12:26:28 +01:00
Kacpherek ecdcb91c27 Keep a tadpole's name and flags when it grows into a frog
A grown tadpole was replaced by a fresh frog that kept only yaw, so a
name tag, silence and the like were lost. Tadpole.ageUp calls
Mob.convertTo with ConversionParams.single(this, false, false), which
copies those through ConversionType.convertCommon, then finalizes the
frog for the conversion and makes it persistent.
2026-10-31 12:26:28 +01:00
Kacpherek 47da9560bc Convert piglins and hoglins in place instead of respawning
A piglin, piglin brute or hoglin that zombified outside the Nether was
replaced by a fresh mob that kept only baby, yaw and persistence, so the
name tag, armour, held items, effects and flags were lost. Vanilla runs
Mob.convertTo with ConversionParams.single (piglins keep equipment and the
pick-up-loot flag, hoglins keep equipment only) and then adds nausea for
10 seconds (AbstractPiglin.finishConversion, Hoglin.finishConversion).

ConversionType.convertCommon calls setBaby(true) on the new mob and only
copies the exact age between two AgeableMobs, so a baby hoglin becomes a
non-ageable baby zoglin instead of keeping its growing age.
2026-10-31 12:26:28 +01:00
Kacpherek c225acc128 Run the dragon phases' client ticks on a client
EnderDragon.aiStep calls currentPhase.doClientTick() on a client and
Horizon never did. DragonLandingPhase spawns 8 dragon-breath puffs from the
head each tick, DragonSittingFlamingPhase 8 x 6 on every second of the first
10 ticks, DragonDeathPhase an explosion emitter every 10 ticks, and
DragonSittingAttackingPhase replays the growl on every tick instead of once.
Vanilla throws away the results of look.yRot, so the puffs keep one direction.
Run them from tick_remote_dragon and drop the one growl the server played.
2026-10-31 12:26:28 +01:00
Kacpherek a5280537ef Draw a breeze's block dust and jump trail on a client
Breeze.tick (Breeze.java) calls emitGroundParticles while it stands, shoots
or inhales, 20 of them while it slides, and emitJumpTrailParticles for the
first five ticks of a long jump. tick_breeze_effects drew them, but only
tick_creatures called it, so a client of a server (single player included)
saw none. Call it from the remote entity tick too.
2026-10-31 12:26:28 +01:00
Kacpherek d20172681d Show a witch's magic puffs on the client
The server drew Witch.aiStep's 7.5E-4 roll but never broadcast entity
event 15, and nothing handled it, so no witch ever puffed. Witch.handleEntityEvent
(Witch.java) spawns nextInt(35) + 10 witch particles around 0.5 above its
box, redrawing the count on every loop pass, with gaussian offsets of 0.13.
2026-10-31 12:26:27 +01:00
Kacpherek 15e9f8caeb Play the zoglin's angry grunt when it starts a fight
A zoglin only grunted angrily on its ambient timer. Zoglin.updateActivity
(Zoglin.java) plays entity.zoglin.angry as soon as the brain switches from
idle to the fight activity.
2026-10-31 12:26:27 +01:00
Kacpherek aee4f219e4 Kick up dust under a walking iron golem
Only players ever drew sprint dust. Vanilla IronGolem.canSpawnSprintParticle
(IronGolem.java) replaces the sprinting test: when its horizontal motion
exceeds 2.5000003E-7 and a one in five roll hits, Entity.spawnSprintParticle
streams the block under its feet back from it, scaled to its 1.4 width.
2026-10-31 12:26:27 +01:00
Kacpherek 3f0aa12580 Pass the rabbit's target position into its server step
The killer bunny hop needs only where its target stands, so server_ai_step takes that instead of the whole MobWorld, and the existing tests keep calling it without a world.
2026-10-31 12:26:27 +01:00
Kacpherek 575f48fe5f Make killer bunnies armoured, hostile and named
Variant 99 (from /summon or a save) only changed the texture. Vanilla
Rabbit.setVariant(EVIL) (Rabbit.java) sets armor to 8, adds the bite goal
(MeleeAttackGoal 1.4), the hurt-by and player/wolf target goals, a plus 5
attack modifier and the Killer Bunny name, and customServerAiStep makes it
face and hop at a target within 4 blocks.
2026-10-31 12:26:27 +01:00
Kacpherek 9e11a4d7c8 Show the pattern and colours on a tropical fish bucket tooltip
A bucket of tropical fish had no pattern or colour lines. Vanilla
TropicalFish.Pattern.addToTooltip (TropicalFish.java) names one of the 22
common variants, or shows the pattern name and then the base colour (with
the pattern colour when it differs), in italic gray, just before the
instrument line.
2026-10-31 12:26:27 +01:00
Kacpherek 562cbac5fa Hang a camel's lead at its body anchor
Every camel drew its lead at the default offset (eye height, 0.4 of the
width ahead). Vanilla Camel.getLeashOffset (Camel.java) uses the front
body anchor, which sinks while the camel sits, less 0.2 times the age
scale, and 0.56 of the width ahead.
2026-10-31 12:26:27 +01:00
Kacpherek d467786e5b Drop goat horns with their own instrument and upward push
A horn knocked off by a ramming goat was a plain goat horn (always the
ponder tune) thrown with the generic item motion. Vanilla Goat.createHorn
(Goat.java) picks a random instrument from #regular_goat_horns, or
#screaming_goat_horns for a screaming goat, with a random seeded from the
goat's UUID hash, and Goat.dropHorn throws it with deltaX/Z in -0.2..0.2
and deltaY in 0.3..0.7 drawn from the goat's random.
2026-10-31 12:26:27 +01:00
Kacpherek 1d69ba5efa Make tadpoles swim at half speed and follow frog food
Tadpoles swam around at speed 1.0 and ignored players holding food.
Vanilla TadpoleAi.initIdleActivity (TadpoleAi.java) strolls with
RandomStroll.swim(0.5F) and runs FollowTemptation at 1.25 for items in
the frog_food tag.
2026-10-31 12:26:27 +01:00
ElyTheCat 9be1573e70 Fix creative page releases and vanilla tab items
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 02a7c1553a70590c0d049a0dd0c4cfe22a6a64df)
2026-10-31 14:19:38 +03:00
oskal675-designandCodex GPT-6.1 90d02c5fff Use the SRV target address in login handshakes
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 16:16:36 +05:00
ElyTheCat f3831f7038 Consume remote menu click targets
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 4b17a2d6c9597dc328416f1771f325ed49bf8171)
2026-10-31 14:11:57 +03:00
T3Code 221f941c63 Keep native shader test module after implementation 2026-10-31 17:09:37 +06:00
k3yhandClaude Opus 5.5 83df5969b8 Skip a file root's name unread, as vanilla does (#5064)
NbtIo.readUnnamedTag skips the root's name with StringTag.skipString
and never decodes it, so a file whose root name isn't modified UTF-8
loads in vanilla. Horizon decoded it and refused the whole file; for
level.dat that meant the world opened with fresh defaults (game mode,
cheats, time) saved over the real ones.

read_named and read_named_streaming read the root name leniently: a
name that isn't modified UTF-8 is read as empty. Every other string,
compound keys included, is still checked, as DataInput.readUTF does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:05:31 -05:00
MSedgeMC 554451b083 Keep a locked container locked
LockCode.canUnlock lets a spectator or a player whose main hand matches the
lock's item predicate open a container; BaseContainerBlockEntity.createMenu
refuses anyone else with an action-bar message naming the container and one
chest-locked sound. Horizon kept the tag on disk and read it nowhere, so a
locked chest opened for anyone, /data get did not show the lock, and
/setblock could not make one.

The lock now lives where the loader already put it, one place reads and
writes it, and the open funnel refuses with vanilla's message and sound.
2026-10-31 16:32:57 +05:30
T3Code efc875c37e Merge current main after native shader toggle 2026-10-31 16:59:54 +06:00
T3Code a4bc24abdd Tie opt-in native shader packs to mod toggle 2026-10-31 16:59:47 +06:00
MSedgeMC 854c85b235 Let a mob pick up and equip what it finds, as vanilla's looting pass does
Mob.aiStep walks the items within one block of a mob with CanPickUpLoot and
mob_griefing on, and pickUpItem equips what beats what it holds: armour by
armour and toughness, a weapon by attack damage, an equal item by
enchantments, damage and name, with the preferred-weapon tags skeleton and
drowned mobs carry. The replaced piece is dropped unless the drop chance
saves it, and the mob becomes persistent.

Horizon rolled the flag at spawn and nothing read it: a zombie stood beside
a diamond sword and left it there. The comparison rules live in a lib
module so they can be tested without a game, and the pass reuses the panda
and piglin pickup plumbing rather than adding a third copy.
2026-10-31 16:27:20 +05:30
AurelienBelloirandClaude Opus 5.5 c5bf7a35cd Key the reloaded sync data by the pack dialogs it keeps
After a /reload that disabled a world's packs, the cached SyncData was
marked as read without packs while still holding the kept registries with
the packs' dialogs, so the next world opened without packs would have been
served them. The cache key now also counts kept pack entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:52:49 +01:00
DetimesandClaude Sonnet 5.5 5b25371912 horizon-server: Velocity modern forwarding behind a proxy (#4963)
A forwarding.secret file in the server folder turns it on for an offline
server: the login asks the proxy on velocity:player_info, checks the
HMAC-SHA256 signature and joins the player with the forwarded UUID, name,
properties and address. A login that doesn't answer, or is signed with
another secret, is refused; an empty secret file stops the start.

The format was worked out by recording a real Velocity 4.3.1 with a
stand-in backend (tools/velocity_forwarding_probe.py), see
docs/velocity-forwarding.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:52:37 +00:00
SenjinandClaude Opus 5.5 60c91c304f HDR: what shines is marked with improved transparency on too
Lightning, additive quads and translucent particles draw in the OIT stages
there, and only the OIT entities got a mark after them. The firework mark
also takes the same base as the draw it follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:51:42 +01:00
k3yhandClaude Opus 5.5 d516b77daa Write a string too long for modified UTF-8 empty on disk (#5064)
NbtIo.write goes through StringFallbackDataOutput: a writeUTF that would
exceed 65535 encoded bytes logs "Failed to write NBT String" and writes
an empty string, and the rest of the tree is written. Horizon's
write_named failed instead, and since a save batch's records are encoded
before any is written, one block entity with such a string (a long
command block command, a book) made the whole batch fail: every chunk
changed since the last save was lost, not only that one.

The disk writer (write_named) now writes such a string empty; the
network writer still refuses it, as vanilla's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:50:27 -05:00
AurelienBelloirandClaude Opus 5.5 456bbccd1d Keep the synchronized registries on /reload
Vanilla's MinecraftServer.reloadResources builds the new
ReloadableServerResources against the server's existing
LayeredRegistryAccess (TagLoader.loadTagsForExistingRegistries); the
synchronized registries RegistryDataLoader read at server start never
reload. Players keep the registries of their configuration phase, and
later joiners get the same ones.

Horizon's /reload read the SyncData again with the current packs, so a
dialog file added or removed shifted the dialog indices (vanilla's too,
as pack ids sort among them): /dialog show sent the wrong index, the
re-broadcast dialog tags pointed at wrong entries and new joiners got
another registry.

Now /reload under a hub reloads the SyncData (recipes, advancements,
command tree) but keeps the hub's synchronized registries and their
names, and reads the tags against them (SyncData::keep_registries,
lan::reload_sync_data); the commands' dialog ids are not touched. A
pack's new dialog shows after a restart, as in vanilla. Without a hub,
/reload forgets the cached data as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:48:17 +01:00
ElyTheCatandCodex 9b9b30a0f5 Reset pause focus when reopening the menu
Co-Authored-By: Codex <[email protected]>
2026-10-31 14:18:03 +03:30
MSedgeMC 2d519f8808 Land a spectator who becomes creative while standing on the floor
changeGameModeForPlayer clears flight on top of the mode abilities when the
new mode is not spectator and the player is in range of the ground:
no collider joins the body, and the block below the feet is within one
block. Horizon copied GameType.updatePlayerAbilities in both the local and
the guest funnel and stopped there, so a spectator switched to creative on
the floor kept hovering.

One probe, in the world crate where the collision scan lives, used by both
funnels.
2026-10-31 16:13:40 +05:30
MSedgeMC a6d66cd98e Let a spawn egg carry entity data, as vanilla's does
EntityType.updateCustomEntityTag loads the egg's entity_data component into
the mob it just spawned, after the components, guarded by the type matching
and by the op rule for the three op-only types. Horizon applied the egg's
components and not this one, so a NoAI or Silent egg spawned an ordinary
mob.

The loader is the one /data merge already uses, and the op-only set is now
shared with the tooltip warning that already listed it.
2026-10-31 16:10:52 +05:30
AurelienBelloirandClaude Opus 5.5 cf2c6dd7cb Order the dialog registry by file id and drop a pack world's sync data
ResourceManagerRegistryLoadTask.load registers the entries sorted by the
keys of FileToIdConverter.listMatchingResources, the file Identifiers
(dialog/<path>.json), path first, then namespace (Identifier.compareTo).
The ".json" counts: x-y comes before x since '-' sorts before '.'. Every
synchronized registry's entries, the game's own included, now sort by
"<path>.json", then namespace, instead of the bare id.

The cached SyncData remembers whether it was read with data packs. A world
without packs opened after one with packs read it again instead of keeping
the first world's dialogs, recipes and advancements; a world without packs
after another, or after the world-screen preload, still reuses it, and
SyncPreload.adopt replaces only what was read with packs.

The commands' dialog ids are set from the SyncData lan_host and /reload
send, not in finish_open_world: there it loaded SyncData on the server
thread before SyncPreload.adopt, wasting the preload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:38:08 +01:00
AurelienBelloirandClaude Opus 5.5 7553d7356c Keep a failed use on a block from firing any_block_use
DebugStickItem.useOn returns FAIL when handleInteraction does not
succeed (no game-master permission, a block without properties), and
handleUseItemOn fires ANY_BLOCK_USE only when the result consumes the
action. use_hand now sets use_failed on the stick's failure, and on a
click a native mod canceled, which uses nothing.

ChiseledBookShelfBlock.useWithoutItem returns CONSUME on an empty slot
without a book, so ServerPlayerGameMode.useItemOn fires DEFAULT_BLOCK_USE
there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:36:44 +01:00
T3Code 2f297750d7 Hide empty native settings pages and use full-width toggle 2026-10-31 16:36:28 +06:00
ElyTheCatandCodex 9dba54f8fa Keep clamped occlusion sky conservative
Co-Authored-By: Codex <[email protected]>
2026-10-31 14:06:17 +03:30
MSedgeMC fe0c9d5588 Let the spectators_generate_chunks rule decide who loads terrain
ChunkMap.skipPlayer is a spectator with the rule off, and a skipped player
holds no distance-manager ticket: the world does not load or generate
around them, though they still receive the chunks other players load.
Horizon declared and saved the rule and never read it, so a spectator was a
chunk source like any other player in all three places that matter.

One helper now answers it, and the ticket sources, the LAN streaming areas
and the dedicated server's centre all ask it.
2026-10-31 16:06:09 +05:30
AurelienBelloirandClaude Opus 5.5 6dba457703 Merge remote-tracking branch 'origin/main' into parity/default-and-any-block-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:34:18 +01:00
AurelienBelloirandClaude Opus 5.5 32a35d1225 Merge remote-tracking branch 'origin/main' into parity/data-pack-dialogs
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/lan/data.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:33:16 +01:00
k3yhandClaude Opus 5.5 a921e7b79e Load a region chunk whatever follows its root (#5064)
RegionFileStorage.read parses the root straight off the decompressing
stream (NbtIo.read on the chunk's DataInputStream) and closes it, so
bytes after the root and a damaged stream past it are never read.
Horizon decompressed the whole record first and then refused trailing
bytes, so such a chunk failed to load, read_record took it as missing,
and the game generated it again and saved over what had been built.

ChunkRecord::nbt now parses the root from whatever decompressed before
an error (Compression::decode_into keeps it; an LZ4 block is kept only
once checked, as lz4-java hands it out) and ignores what follows. A root
cut short still fails with the decompression error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:30:16 -05:00
SylviromiandClaude Opus 5.5 b43e2d1493 Native mods: ask the block's container for its menu when a screen opens
The window's world has no mod containers (the server tick installs them), so
mod_menu_at was always none there and no mod screen opened. Ask
native_block_entity_container for the clicked block instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:28:06 +03:00
mularealandAntigravity 6988570a37 gfx: cull back faces of entity shadows
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 11:27:44 +01:00
oskal675-designandCodex GPT-6.1 80e46f61b4 Add launcher shell completion generation
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 15:26:59 +05:00
masa-masa-0314andGLM 5.3 089d775600 Experiments: the trade_rebalance flag applies its built-in data pack
A world with the `trade_rebalance` feature flag now plays the flag's
built-in pack: the overridden villager trade tags and trades, the
librarian enchantment trades and the changed chest loot, as vanilla's
server does. `MinecraftServer.configurePackRepository` force-enables a
feature pack whose requested features the world enables
(`enableForcedFeaturePacks`) and drops a selected pack asking for
missing ones: `datapack::enabled_ids` now takes the world's
`enabled_features` and does both, so the pack's files reach the world's
content overlay (`/datapack list` shows it as enabled).

The content tables then merge the pack over the resource root:
`Trades::load` takes the packs' `villager_trade`/`trade_set` files and
their tags (`replace` clears, `TagLoader`), and `Enchantments::load_with`
takes the packs' enchantment tags, so the librarian trades'
`#minecraft:trades/<biome_common>` pools resolve. Along the way:

- `PackOverlay::tag_kind` stripped a leading `:` that is not part of
  the `tags/<ns>:<registry>/<path>` key shape, so it never matched and
  every pack tag overlay silently no-opped; loot tables' tag overlays
  were affected too.
- `/loot give ... loot <table>` rolled the resource root's table when
  the table had a random sequence (`self.res.items()`), not the world's
  own — the world's tables are used for the sequence branch now.
- The world's flags feed the worldgen overrides and the biome table
  (`packed_biomes`, `level_shapes`, the preload reads them from the
  world's level.dat), and the integrated open path passes them through.
- Fixes the stacked world-save commit leaving `integrated.rs` calling
  `packed_biomes` without its `resources` argument (the bin did not
  compile).

Vanilla reference: `MinecraftServer.configurePackRepository`,
`enableForcedFeaturePacks`, `TagLoader.load`,
`SimpleJsonResourceReloadListener`, the built-in
`data/minecraft/datapacks/trade_rebalance` pack.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 18:22:07 +08:00
SylviromiandClaude Opus 5.5 223fc6b6b6 Native mods: a mod screen only for the menu its block opens
The first try (key == target) never held for a mod block: `holds` knows only
vanilla block entities, so every mod screen stayed shut. The screen now opens
when the menu is the one the clicked block's container declares
(`mod_menu_at`), which a chest menu from a command over a stale target is not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:21:01 +03:00
MSedgeMC 22ea093e76 Fold the two expressions rustfmt wants on one line
CI's stable rustfmt collapses the free-position map and the collision-shape
map; the local check accepted them split.
2026-10-31 15:46:45 +05:30
DoodlyyousufandGrok 4.6 714e6e52bd Center widget labels with vanilla integer half-widths
AbstractWidget.renderScrollingString / TextAlignment.CENTER uses
x + w/2 - width/2. Flooring (w - width) / 2 sat odd-width labels one
pixel left of vanilla on even-width buttons.

Co-Authored-By: Grok 4.6 <[email protected]>
2026-10-31 10:12:56 +00:00
MSedgeMC 0d476d32e7 Keep a template's carried block when there is no registry
mob_to_nbt dropped an enderman's carriedBlockState whenever the mob carried
none, but a caller that passes no registry never read the key either, so a
structure template's compound lost a field it had. Reported in review: the
key is only removed when a registry could have written it.
2026-10-31 15:41:59 +05:30
MSedgeMC 830820b2cf Write the em dash back as an em dash
A comment in mobs/allay.rs was saved with a doubled encoding, so the dash
in the travelFlying note read as mojibake. Reported in review.
2026-10-31 15:41:53 +05:30
EymenWSMC 5659ae6842 Create tr_tr.json 2026-10-31 13:10:07 +03:00
EymenWSMC 5b8bef7b6b Create tr.json 2026-10-31 13:09:13 +03:00
T3Code 2fba1602ac Merge main and assign native visual hooks API 41 2026-10-31 16:08:28 +06:00
reservedbytes c48886001b fix(mobs): a primed sulfur cube is still knocked about 2026-10-31 09:08:15 -01:00
MSedgeMC ea306f29eb Give a tamed pet its owner's scoreboard team 2026-10-31 15:28:44 +05:30
MSedgeMC 7b9a18331d End a wind charge's fall context when the player lands in water 2026-10-31 15:28:38 +05:30
MSedgeMC 23b8c8de7a Lift an experience orb out of the block it spawned in 2026-10-31 15:28:32 +05:30
MSedgeMC d38e8d4ecb Save a mob halfway through converting 2026-10-31 15:28:25 +05:30
Neferr de01121cae C:/Program Files/Git/ride mounts boats and minecarts
RideCommand.mount took only a mob as the vehicle. A player or a mob now
mounts a boat with a free seat, and a player any minecart no other player
is in, through the same state as a click. /ride <player> dismount goes
through stop_riding, and Entity.getVehicle names a minecart as one.

Part of #4968.
2026-10-31 10:57:39 +01:00
reservedbytes ea26d1a145 feat(mods): native mods change or cancel damage 2026-10-31 08:53:28 -01:00
TeiwazikandClaude Opus 5.5 5994eb0aa4 Packs: don't hash a large zip's bytes on every overlay check
The overlay's key hashed each enabled zip in full, on the assumption that one
file reads in a moment: with a 9 GB pack that read 9 GB at every launch.

A zip over 250 MiB, vanilla's limit on a server pack, is now keyed by its
length, modified time and central directory. Smaller ones keep their bytes, so
a server pack's touched time still doesn't rebuild the overlay, and an edit
that a zip's empty checksums wouldn't show is still seen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 10:51:29 +01:00
SylviromiandClaude Opus 5.5 a82bb75544 Native mods: follow-ups from the reviews of the block entity and screen hooks
- A mod screen opens only when the clicked block holds the menu: use_target
  outlives its use, so a menu opened later (a command's, another mod's) got the
  last clicked machine's screen, and its clicks and tooltips (arismoko on #4106
  and #4289).
- native_screen_tooltip runs over an empty slot, as its doc says, so a mod can
  say what an input takes (arismoko on #4289).
- A mod block whose template has no block entity gets none, with a warning:
  breaking it never removed the block entity, which stayed in the save
  (reservedbytes and DUzzL on #3996).
- A GUI texture that doesn't decode is named in the log, not skipped silently
  (reservedbytes on #4106).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:50:03 +03:00
reservedbytes 135c4a8872 fix(mods): property setters return false when nothing changed 2026-10-31 08:49:12 -01:00
reservedbytes 1ec0d72ba5 fix(mobs): sulfur cubes follow the tnt_explodes rule 2026-10-31 08:48:54 -01:00
reservedbytes 44d5b972fa fix(dragon): a wither skull hits the ender dragon's parts and explodes 2026-10-31 08:45:10 -01:00
reservedbytes 67188b668c fix(worldgen): a template with entities and no blocks places them 2026-10-31 08:45:10 -01:00
reservedbytes 9b63c4c735 fix(combat): a player hurt by a creeper's blast takes a player_explosion 2026-10-31 08:45:07 -01:00
reservedbytes 8516e8d3f9 fix(combat): name a player's renamed held item in death messages 2026-10-31 08:45:02 -01:00
SylviromiandClaude Opus 5.5 a138c139ac Creative tabs: block items once in search, nine-sliced page buttons, lang titles
From reservedbytes' test on #4331: a block's item was added to search twice
(blocks' items are already among the plan's items); the page buttons stretched
the 200-wide button sprite into 20 px and lost their sides (now blit_sprite, as
vanilla's Button draws); turning the page while on search, saved hotbars or the
inventory keeps that tab; a mod tab's title comes from itemGroup.<mod>.<name>
in its lang files, the file's title being the English fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:43:07 +03:00
MSedgeMC ef638cf256 Start a restored nausea at full strength, as vanilla does
ClientPacketListener.handleUpdateMobEffect calls skipBlending when the packet
carries no blend flag, and BlendState.setImmediate then sets the factor from
hasEffect rather than fading up. That is the path the effect list takes on
joining, respawning and changing dimension, so vanilla shows the warp at
once. Horizon had this for darkness and not for nausea, whose warp ramped
from zero over a hundred and fifty ticks.

One branch beside the darkness one, in the same place, with the blend-out
guard now shared.
2026-10-31 15:10:49 +05:30
SylviromiandClaude Opus 5.5 74ff545e60 Content mods: creative inventory tabs, paged as NeoForge pages them
A content mod declares tabs in data/<id>/horizon/creative_tab/*.json: a
title, an icon and its items. They come after vanilla's, ten to a page
(the top row's first five columns, then the bottom row's), with < and >
buttons above the screen; search, saved hotbars and the inventory show on
every page. Its new items join the search tab under their English names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:39:48 +03:00
mularealandAntigravity 24831525c0 entities: push armor stands with flowing water and lava
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 10:24:21 +01:00
apeirsandClaude Opus 5.5 83189a8ae7 Generated columns: read a whole section in one pass
VanillaTerrain::column read every block of a generated section with
ProtoChunk::block (a section lookup, coordinates and a palette read per
block, ~98k calls a chunk) before repacking it. Light views for the ring
past the view distance are built on the server thread, so flying kept a
steady stream of them there. A whole generated section is now unpacked
word by word and mapped through its palette (TerrainChunk::section_states,
BlockSection::fill_states, PackedStorage::unpack_into); a partly generated
one still goes block by block. Same values, same packing.

Fixes #5038

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:19:19 +01:00
TeiwazikandClaude Opus 5.5 095342d9b7 Zip: read the directory on open, not the whole archive
Listing the packs opened every zip in resourcepacks/ twice, once for its
pack.mcmeta and once for its pack.png, and each open read the whole file
into memory on the render thread. With 22 GB of packs in the folder, enabled
or not, opening the options screen froze the game for five to seven minutes.

A Zip now keeps the file open and holds only the central directory on, with
the END record normalized as before, the way Java's ZipFile works. Entries
are read at their positions, so clones and threads don't share a cursor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 10:17:31 +01:00
apeirs 9cccb97de1 TNT minecart: a primed cart's blast spares rails and the blocks under them
MinecartTNT.getBlockExplosionResistance and shouldBlockExplode: while the
cart is primed (fuse > -1), a rail or a block with a rail above has no
resistance to its blast and is never blown up. Horizon broke both, so a
cart primed on an activator rail tore up its own track and the block
holding it. An unprimed cart (a wall hit, a fall, fire) blasts rails
like any explosion, as before.

Harness: command_exec_corpus `tnt minecart rails`.

Fixes #4992
2026-10-31 09:57:57 +01:00
Neferr e085a4edf5 Native mods: say when the player tick hook runs and when it doesn't
Players still tick under /tick freeze, and a dead player, or one killed
earlier in the same tick, isn't called. Notes from reservedbytes on #4665.
2026-10-31 09:56:29 +01:00
NazaandClaude Opus 5.5 ead550fcdd Port ledger and docs: the key pair's callers, Chat Delay in the harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:56:10 -03:00
Naza c4ec4be1f3 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 05:55:58 -03:00
NazaandClaude Opus 5.5 e3a2719f50 Merge main; messages flushed from the Chat Delay queue count as processed
flush_chat_queue now goes through accept_chat, as ChatListener.flushQueue runs each handler.
Without it a flushed signed message never reached the last-seen tracker, and vanilla's
server disconnected the client at its next message (checksum mismatch). The harness now
sets Chat Delay, holds a message back and flushes it. send_command parses over chat_tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:55:57 -03:00
Haruki bb76b9a73e Add Japanese translation (ja) 2026-10-31 17:46:36 +09:00
NazaandClaude Opus 5.5 516327ba3d Port ledger: the key pair rows say what this branch calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:44:28 -03:00
Aaron Wang 3d9e3dbe6e Keep the chunk_io tests off the queue the save tests also fill
chunk_io's PENDING is process-wide, and save tests that fail a chunk load
or write report into it while the chunk_io tests take and compare it, so
those tests failed now and then (#4854). The newest-chunk rules move into
Pending::load_failed and save_failed, which the tests call on a local
value; warn_on_low_disk_space returns what it found. Only the
low-disk-space packet test still reads PENDING, and it checks a flag
nothing else in the lib clears.

Fixes #4854

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 04:42:37 -04:00
Aaron Wang 05a059bde4 Blindness stops a sprint and keeps one from starting
LocalPlayer.isSprintingPossible is false while Player.isMobilityRestricted
(blindness). sprint::Conditions already honoured mobility_restricted, but
nothing set it. PlayerMotion now carries the flag into the sprint phase,
and sync_player_body sets it from the effects every tick, on the server
thread's client as well.

agent_control_e2e's consume scenario now holds the sprint key while the
server gives and clears blindness.

Fixes #4831

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 04:30:51 -04:00
apeirs 3a10d0c3dd Commands: a UUID target finds any entity, not only mobs
EntitySelector.findEntities with a UUID takes ServerLevel.getEntity,
whatever the entity is. target_with_uuid kept only mobs, so /tp, /kill
and /data get entity with a display's, armor stand's or item's UUID
found nothing; the scoreboard's holder lookup kept mobs and items. Both
now go through level_entity.

Fixes #4966
2026-10-31 09:24:40 +01:00
masa-masa-0314andGLM 5.3 bb3835926c Data packs: /datapack follows the world's enabled features
`DataPackCommand` gates feature packs on the world's flags: a pack whose
requested features are not enabled is not available, cannot be enabled
("required flags are not enabled in this world"), and a feature pack can
never be disabled — the flag stays on for the world's life. Horizon
refused every enable and allowed every disable regardless. The command
now reads the world's `enabled_features`: with the flag on, a feature
pack enables and its data loads; without it, it stays hidden and the
refusal names the missing flag.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 088731f316 World save: every player saves to their own file, as PlayerList.saveAll
Vanilla's server save runs `PlayerList.saveAll`: every connected player
is saved to `playerdata/<their own uuid>.dat` — there is no world-owned
player file. The integrated server's save now does the same for its
placed guests (`playerdata::save`), on every save and when a player
leaves, and the owner joins through `PlayerDataStorage.load` on their
own uuid first — the world's own player state is only the fallback for a
first join. Previously the owner's state was stored into the level and
then dropped (`write_without_player`), so position and inventory were
lost on every reload, and the stale single-player file shadowed the
newest-file fallback on load.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 36ab1a21cf World save: the integrated server writes the world's own player
The integrated server's owner plays as the world's own player
(`Owner`): on leave their state is stored into the host's `Level`
(`playerdata::store`), but the server saves with `write_without_player`
(it runs as `dedicated`) and the window's own save is skipped while it
is connected, so the state never reached a file and position and
inventory were lost on every reload. The integrated role now writes the
world's own player file (`players/data/<singleplayer_uuid>.dat`) with
the level, as single player does (`PlayerDataStorage.save`); a real
dedicated server keeps saving its players on their own.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 a4a0b5539b Minecarts: the new behavior turns and climbs
Review of the port against the decompile found three deviations that
stalled carts at corners and broke climbing:

- `stepAlongTrack` ends with `setDeltaMovement(movement)` — the movement
  turns onto the exit's direction at the same speed. Without it the
  velocity never turned, so a corner's exit selection kept the old
  direction and the cart stopped.
- `TrackIteration`'s slope, halt and boost speeds apply once per tick
  and carry their flags across the iteration; the port re-applied them
  on every rail.
- the player-input push does not replace the natural slowdown and the
  speed cap (vanilla applies both), and a stuck step
  (did-not-move-but-would-have) zeroes the movement.

Also `isDecending` reads the step's movement (the exit's direction),
not the stale velocity, for the hill's Y projection.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 9464d531e3 Minecarts: minecart_improvements picks the smoother physics
`AbstractMinecart` chooses its behavior by the world's features: with
`minecart_improvements` the new behavior runs — the slide on slopes
grows with the speed (`max(0.0078125, h * 0.02)`) so a descending cart
keeps its pace, the empty cart slows half as fast (0.975 over 0.96),
the speed cap is a vector normalization instead of a per-axis clamp,
a powered rail boosts by `+0.06` along the movement or pushes a resting
cart 0.2 away from the signal's side, an iteration carries leftover
distance across rails, a hill projects the Y onto the slope, and a V of
opposing slopes rests a slow cart (`restAtVShape`). Without the flag
the old physics runs as before.

Stacked on #4975 (← #4957 ← #4948 ← #4619 ← #4577), whose
`enabled_features` the gate reads.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 5acff3836c Create World: the world's enabled features persist in level.dat
Vanilla's level.dat carries `WorldDataConfiguration`'s `enabled_features`
flat in the Data compound (`FeatureFlags.REGISTRY`'s codec: a list of the
whole set's namespaced ids, `minecraft:vanilla` included, the tag omitted
when the flags are vanilla's default) — Horizon wrote nothing, so a world
made with experiments told vanilla nothing and forgot its flags itself.

`Level.enabled_features` is written with the selection (the flags of the
picked feature packs over vanilla) and read back from level.dat, so a
vanilla-made world keeps its flags too. The behaviors behind the three
flags are still separate work; this is the data they will gate on.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
masa-masa-0314andGLM 5.3 072cc61277 Create World: the preparing screen and the experimental-features warning
Vanilla reads the world generation data behind a loading screen before
the Create New World screen opens, again after its Data Packs or
Experiments screen applied a selection, and warns before applying a
selection that asks for experimental features. Horizon showed none of
that.

- `createWorld.preparing` (`GenericMessageScreen`: the menu background
  and the centred message on a black box with a 1-pixel border and 12
  pixels of padding, no progress): Create New World reads
  `vanillagen::shared_data` on a worker while the title shows it, and
  opens the create screen when the read is done; the Data Packs screen's
  Done and the Experiments screen's Done re-run the read the same way
  (`applyNewPackConfig`).
- `ConfirmExperimentalFeaturesScreen`: when the Data Packs screen's Done
  picks a feature pack, the warning comes up first — the title, the red
  message wrapped to 310 and centred, a 100-wide Details button, Proceed
  and Back in the footer, the whole block centred, Escape declining. Its
  `DetailsScreen` lists the picked packs bold with the flags they ask
  for (`selectWorld.experimental.details.entry`). Proceed applies the
  selection, Back reopens the Data Packs screen on the selection it was
  opened with (`tryApplyNewDataPacks`'s `onAbort`).

Stacked on #4619, whose Experiments screen and feature-pack plumbing the
confirm screen builds on.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
rdbtCVSandClaude Opus 5.5 228b7d3491 Fix packed_biomes call after merging main; restore world_worldgen_overrides's doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:16:16 +08:00
masa-masa-0314andGLM 5.3 6975e9c9e1 Experiments: the More tab's button opens, and a world keeps its selection
Review fixes on #4619:

- The More tab's `options.press` guard caught button 30 before the
  Experiments arm could, so the button did nothing; the guard lets 30
  through now.
- Create World's `open_world` overwrote the `datapacks.json`
  `datapack::install` had just written, losing the unselected world
  packs' `disabled` entries, so a pack the player left off came on. The
  write now only happens when `install` did not run (only Experiments
  used), and it creates the world folder and logs failures instead of
  dropping the error.
- `level.dat`'s `DataPacks.Disabled` also lists the world packs the Data
  Packs screen held back, as vanilla's `DataPackConfig` does.
- The completion registries' pack list gains the feature packs, so a
  pack enabled through Experiments completes its worldgen ids.
- The feature packs on the Data Packs screen compared against the
  resource pack format and drew red; they compare against the data pack
  format now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
10fa933647 Create World: the Experiments screen, the feature packs and dropped packs
Vanilla's More tab offers Game Rules, Experiments and Data Packs; the
Experiments button was drawn disabled ("Experiments have no screen yet").
It now opens vanilla's ExperimentsScreen (`title/experiments.rs`): every
feature pack the resources root ships under `data/minecraft/datapacks`
(`ServerPacksSource`, `PackSource.FEATURE`), each with a 44-wide on/off
switch at the right of its row, its description grey underneath, the red
warning wrapped to 310 and centred, Done and Cancel in the footer —
vanilla's `HeaderAndFooterLayout` + `SwitchGrid` geometry. Switching a
pack adds or removes its id in the world's data pack selection, which the
Data Packs screen and the world's DataPacks then see; Done keeps the
other packs and rejoins the picked feature packs last, reversed
(`onDone`), Cancel and Escape leave the selection as it was.

The feature packs also join the world's repository, so a picked pack
reaches the game:

- `datapack::feature_packs` scans the resources root's feature packs and
  `screen_packs` offers them on the Data Packs screen (available, off),
  as `PackSelectionScreen` does;
- a world remembers its selection in `datapacks.json` (the pack stack's
  own file) and `level.dat`'s `DataPacks` (`DataPackConfig`);
- `pack_stack`, `packed_biomes` and `world_worldgen_overrides` resolve
  the ids against the resources root, and a feature pack stays off
  unless the world enabled it (vanilla starts `PackSource.FEATURE`
  unselected), so existing worlds behave exactly as before;
- `/datapack` shows an enabled feature pack with the feature source and
  can disable it again, while `enable` still refuses one
  (`commands.datapack.enable.failed.no_flags`) — the three flags'
  behaviors are not implemented yet, but the packs' data files
  (trade_rebalance's loot tables and tags) reach the content tables
  through the pack stack.

Dropping a pack on the window (`PackSelectionScreen.onFilesDrop`): a
zip, a pack folder, or a folder holding exactly one pack (`PackDetector`)
is copied into the create screen's pack folder, where the Data Packs
screen's existing watcher lists it within a second; anything else is
refused and logged.

Stacked on #4577, whose unified pack list the resolution goes through.

Verified headless: a world with `trade_rebalance` in `datapacks.json`
lists it enabled ("[trade_rebalance (feature)]"), disables it with
`/datapack disable`, and the other feature packs stay available.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
Co-Authored-By: ZCode Agent <[email protected]>
2026-10-31 16:16:15 +08:00
VaspyyyandClaude Opus 5.5 9194f37386 Shader API: rebuild Horizon's own pack after a resource reload
The reload replaces the atlas and the renderer, so the pack's sprite tags
named stale layers and the pack stopped drawing until its files changed.
Suggested by rdbtCVS's review on #4186.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-31 08:15:30 +00:00
masa-masa-0314andGLM 5.3 ccb19c6cfe Biomes: the table survives world switches, and tags nest
Review fixes on #4577:

- The window's Game on the integrated-server path kept `world_biomes`
  unset: the chunks it draws carry the packs' biome ids past the built-in
  list, but nothing named them. `open_integrated` builds the world's
  table now, as `finish_open_world` does for the load path.
- The integrated path's own encoders read `res.biomes()` past the world's
  table: `lan_host`'s chunk encoder and `/execute if biome`'s naming go
  through `world_biomes!` now, so a packed biome keeps its name and its
  place on the wire (a guest still needs the table sent over the
  connection; a remote server join clears the last world's table so no
  stale names linger).
- A tag in a sub-directory of its registry never matched: `content_id`
  attached the namespace to the file's last segment
  (`tags/worldgen/biome/has_structure/minecraft:village_plains`), while
  the lookups build `tags/{registry}/{ns}:{path}`. The store key is the
  tag id now (`tags/worldgen/biome/test:sub/mytag`), `PackOverlay`
  gained `tag_kind` for the registry walks, and the readers match.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 7eabfedc8d Data packs: one pack list builds the biomes, the worldgen and the commands
`WorldgenOverrides` and `Biomes::with_packs` both take the world's packs,
but the client built the biome table and the worldgen from different
lists: `content_overlay` reads `datapacks.json` (empty until a world
writes it) while the world's worldgen uses `datapack::discover` with
`enabled_ids`. A pack's biomes therefore never reached the table
`WorldgenOverrides::finish` tests against, so it dropped every new biome
("cannot add biomes yet") and the generator then failed on a material
rule naming one ("unknown material biome").

`world_loading::packed_biomes` builds the table from the same list the
worldgen is built from, and the completion registries read the same
overridden ids instead of `DataStore`'s files, so `/locate biome` accepts
and completes a packed biome.

Verified with Terralith 2.6.5 as the world's only pack: the world opens,
`/locate biome terralith:alpine_highlands` finds it, and `/locate biome
#c:is_lush` reports `terralith:cave/underground_jungle`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 6ea12f710f Data packs: the world's chunks and the game read one biome table
`Biomes::with_packs` (the built-in table with a world's pack files over
it, as `RegistryDataLoader` layers registries) had no callers, so a
packed biome had no client data and `Biomes::id` resolved it to plains —
every packed biome rendered, spawned and saved as the fallback.

The table is built when a world opens, from the same pack overlay the
content tables use, and every biome lookup goes through it
(`world_biomes!`, a field-level borrow for the loops that also take
`&mut self`). `world_loading::make_streaming` builds the same table for
the generator, so the ids the terrain names and the ids the client draws
agree.

A world with more than 128 biomes needs the palette's 8 bits, which the
earlier commit gives it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 ce83c14926 Commands: a world's packs complete and match their biomes and tags
`command-registries.json` lists the game's own ids, so `/locate biome`
refused a packed biome before searching and offered no packed biome or
tag in its completions. The packs' `worldgen/biome`, `structure`,
`structure_set`, `template_pool` and `processor_list` ids now join those
registries, and so do their worldgen tag ids.

The tag lookup behind `#tag` (`/execute if biome`, `/locate biome #tag`)
read only `data/<ns>/tags/worldgen/biome` from the resource root; it now
reads the world's packs too, in resource-stack order, with a `replace`
file dropping what the lower sources put in (`TagLoader.load`). That also
makes a pack's structure tags work.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 a5d6d8af2a Biomes: the palette holds a world's biomes too
`Strategy::biomes(7)` tops out at 128 biome ids; vanilla ships 67 and a
pack like Terralith adds a hundred more (`Biomes::with_packs`), which the
global palette's 8 bits hold.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
Sticky-BugandAntigravity 06f4fe173c feat: place flowers and plants in flower pots (#4844)
Right-clicking an empty flower pot with a flower, sapling, mushroom, or other potted plant item now places it in the pot matching vanilla FlowerPotBlock.useItemOn behavior:
- Construct potted_by_content map on Behaviours caching all 39 potted blocks
- Support empty pot plant insertion returning InteractionResult::Success
- Consume 1 held plant item, increment pot_flower stat, and award item_used_on_block
- Clicking a potted pot with a plant returns InteractionResult::Consume
- Add unit test coverage for potting flowers, saplings, and mushrooms
- Update port map entries in game2-013.tsv

Fixes #4844

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 13:36:41 +05:30
huiandClaude Opus 5.5 d4b98b96b0 Native mods: on_equip for /item's main hand, recipes in subfolders, no sends while reconfiguring
/item weapon.mainhand now goes through onEquipItem, as vanilla's setItemSlot
does; hotbar.N stays the inventory's. A content mod's recipes in a subfolder
of its recipe folder load, as a data pack's do. native_net_send_to_server
refuses while the server reconfigures the player. The hook's doc says what
scatters at death isn't item_dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:59:26 +01:00
Teiwazik c7ec497a49 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:52:52 +01:00
TeiwazikandClaude Opus 5.5 dbd6d4b88f Water mirror: format the plane scan's key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:52:52 +01:00
Teiwazik e44bda259f Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:52:12 +01:00
TeiwazikandClaude Opus 5.5 a121b4e0f3 Water mirror: format the plane scan's key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:52:11 +01:00
apeirs 5943233ee5 Elytra: glide with Mth's sine table and float drag, as vanilla does
updateFallFlyingMovement takes the look direction from getLookAngle
(calculateViewVector: Mth.sin/Mth.cos, 0.017453292f), the pull-up's
-sin(lean) from Mth.sin, the lift from Math.cos of the widened lean, and
drags by multiply(0.99F, 0.98F, 0.99F). Horizon used exact f32 sin/cos,
f32::to_radians and 0.99/0.98, so a long glide landed up to ~0.11
blocks from vanilla's spot.

Fixes #4618
2026-10-31 08:51:11 +01:00
TiggerTakkyTTT 49abcffb11 fix(client): stop particles during tick freeze 2026-10-31 07:50:33 +00:00
Dufru 34f24116bf Fix Brazilian Portuguese locale and add missing translations 2026-10-31 04:39:57 -03:00
TeiwazikandClaude Opus 5.5 7a34d500ab Shader API: [reflection] option's doc says what the code does
The mirror is drawn while the option is not 0, so a negative number turns it on too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:34:11 +01:00
Teiwazik d948ff386e Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:33:51 +01:00
TeiwazikandClaude Opus 5.5 16f513b823 Water mirror: mirror the sky's rays on the right side, scan for the plane less
The sky reads inverse_view * ray, so its mirror is F * inverse_view; the column operation used for view_proj * F left a pitched camera's sky wrong: looking down 40 degrees mirrored a sky looking down. A test compares the rays.

A scan for the water plane reads up to 60 000 blocks on the game thread; it now runs when the camera moves 8 blocks or every 5 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:33:50 +01:00
Teiwazik 287aac04ba Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-10-31 08:33:31 +01:00
TeiwazikandClaude Opus 5.5 360f52d328 Shader API: flowing water keeps its slope in MaterialInput.normal
A normal within 0.99 of an axis (about 8 degrees) snapped to it, which flattened flowing water's gentlest slope (about 6 degrees). The snap now reaches about 3.6 degrees, still enough for distant block faces, and the doc says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:33:10 +01:00
TeiwazikandClaude Opus 5.5 07cd25f819 Shader API: a gap between frames forgets the frame before
NativeFrame::write skips title frames, so the first world frame after a title screen, a pause or a world load reprojected from a stale camera with a delta_time of seconds. Past half a second the frame counts as the first. The easing is a pure function, tested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:32:50 +01:00
Teiwazik 9e867eacf6 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 08:32:50 +01:00
Teiwazik c20250bf75 Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 08:32:50 +01:00
Teiwazik a67502d44d Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:31:23 +01:00
Teiwazik 5b79796812 Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client-render/src/terrain.wgsl
2026-10-31 08:31:22 +01:00
Teiwazik f8184ab1fb Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 08:30:29 +01:00
Teiwazik ff2bf4e670 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 08:30:27 +01:00
Kacpherek d4326f77c8 Tick experience orbs on the client between server updates
A server's experience orb was only eased towards each position update and
did not move on its own in between, so orbs jumped every update (#4488).

Entity keeps a NO_OP interpolation handler for an orb, so the update puts it
there at once, and ExperienceOrb.tick runs on a client too: gravity,
buoyancy, collisions, friction and followNearbyPlayer towards the nearest
living player within 8 blocks (ExperienceOrb.java). The orb now takes the
update's position, ground flag and motion, and runs the existing
ExperienceOrb::tick each tick. Merging stays on the server.
2026-10-31 08:28:56 +01:00
Kacpherek 45c36d8721 Keep a boat passenger's eyes dry in single player
The local player skipped the fluid update while riding, so its eye flags
froze at whatever they were on boarding and the air bar stayed up inside a
floating boat.

Entity.getFluidInteractionBox asks the vehicle:
AbstractBoat.modifyPassengerFluidInteractionBox gives no box when the boat
is not underwater and reaches the passenger's head, and otherwise only the
part above the boat's top. The rider's tick now samples fluids in that box
(Entity.baseTick) through the existing interaction update.
2026-10-31 08:28:03 +01:00
Kacpherek f671f9a758 Tie a lead to a fence for players of a server
A client of a server (single player included) clicks the fence block, never
the knot, so the guest's use only reached the block and item turns. The
port of LeadItem.useOn and LeashFenceKnotEntity.interact, Game::interact_knot,
ran only from the host's own interact_entity, so no lead could be tied to a
fence, taken back off a knot or snipped with shears on the server thread.

Vanilla: LeadItem.useOn binds the player's leashed mobs to the clicked fence
(LeadItem.bindPlayerMobs), and LeashFenceKnotEntity.interact handles a click
on an existing knot (LeashFenceKnotEntity.java). A guest's block use now
offers the click to interact_knot first, as the host's frame does.
2026-10-31 08:27:54 +01:00
Kacpherek 12a0e29610 Tell a LAN guest when its respawn block is missing
A guest respawning at a bed or anchor that was gone had the
"block.minecraft.spawn.not_valid" message put into the host's chat, and
never saw it itself. PlayerList.respawn sends the player
ClientboundGameEventPacket.NO_RESPAWN_BLOCK_AVAILABLE, and
ClientPacketListener.handleGameEvent turns it into that system message.

Send the event from both guest respawn paths when the point cannot be
resolved, and show the message when the remote client receives it.
2026-10-31 08:27:39 +01:00
Kacpherek fbf38dfc89 Update the health, food, armor and xp objectives of LAN guests
Game::tick_scoreboard recorded these criteria only for the in-frame
player, so a guest's (and a single-player owner's, who is a placed guest)
health, food, armor, experience and level scores never moved: a
`health` objective shown below the name or in the tab list stayed at 0.

ServerPlayer.doTick calls updateScoreForCriteria for every player when
the value differs from lastRecorded*. Move that part into
record_player_scores, used by tick_scoreboard and by each guest's tick
with a per-guest record in GuestPlay (reset with a new body).
2026-10-31 08:27:29 +01:00
Kacpherek 932cecc24e Use nothing on a server while the hands paddle a boat
Minecraft.startUseItem returns when LocalPlayer.isHandsBusy (rowing with
the movement keys held). The in-frame path checks it in use_with_hand;
remote_use did not, so on a server a right click while rowing still ate,
placed or threw. remote_use now returns the same way.
2026-10-31 08:27:18 +01:00
Kacpherek a72ce25b23 Predict the armour swap of a right click on a server client
Item.use runs Equippable.swapWithEquipmentSlot on the client too
(MultiPlayerGameMode.useItem), so the worn piece and the hand change at
once and the server's slot packets only confirm. remote_use_item sent the
packet and waited, so on a slow server the swap showed a ping late.

It now calls equip_from_hand like the in-frame path. A client spawns no
dropped item (the server does) and takes its attribute modifiers from the
server's update packet, so equip_from_hand skips both when remote.
2026-10-31 08:27:18 +01:00
My Name b8b6196026 Melee damage reads the attack_damage attribute 2026-10-31 10:14:17 +03:00
levzzzandClaude Opus 5.5 d6452eb44b Merge hr/main: hook points API 41 for data files; horizon-mixins 4342a0b
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:06:12 +05:00
rdbtCVSandClaude Opus 5.5 7c94ba52d0 Merge origin/main; the native visual hooks are API 40
Main took API 39 (#4677). Both sides added Game helpers at the same
place; keep both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:00:51 +00:00
rdbtCVSandClaude Opus 5.5 ff6c8b6a48 Merge origin/main; the native visual hooks are API 39
Main took API 37 (#4799) and 38 (#4725) while this was being checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:55:57 +00:00
rdbtCVSandClaude Opus 5.5 ff96502356 Merge origin/main; renumber the native visual hooks to API 37
Main reached NATIVE_HOOKS_API 36. native_visuals and native_emissive
(API 8 and 9 on this branch) now share API 37, in the module doc and
docs/native-mods.md. The emissive layer moves to the terrain shader's
@location(9), after main's fade, local and visibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:55:29 +00:00
Muhammad Asif 8dadd9e02a Give zombies and skeletons pumpkin heads on Halloween
Zombie.finalizeSpawn and AbstractSkeleton.finalizeSpawn put a carved pumpkin (or jack o'lantern) on an empty head 25% of the time on 31 October. Structure spawn already did this; natural, spawner, summon and raid paths never ran it.
2026-10-31 06:55:05 +00:00
abit-foggy 6ec32c5ec7 perf(client): index fresh chunks in ChunkMap instead of scanning all columns 2026-10-30 20:51:24 -10:00
abit-foggy a368ded7f5 perf(client): restrict tick_blocks candidate lookup to random-ticking bounds 2026-10-30 20:50:32 -10:00
abit-foggy 4dca2732a3 perf(client): avoid O(N) SipHash scan in TickChunkMembership::synchronize 2026-10-30 20:50:32 -10:00
repositorykreml1nandClaude Opus 5.5 aae2414706 Build the test's camera with struct update syntax
clippy::field_reassign_with_default denies the assignment after default().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:38:15 +03:00
ElyTheCatandCodex c3246b78e4 Make sprint knockback LAN coverage tick-tight
Batch movement, teleport and attack commands so the check proves that a hit occurred instead of overshooting the target between control calls. Assert the first post-hit sample is below sprint speed.

Co-Authored-By: Codex <[email protected]>
2026-10-31 10:05:43 +03:30
rdbtCVSandClaude Opus 5.5 368eea9f74 Pack upgrade: refuse entries by their stored name
zip 9's enclosed_name turns ../x into x instead of refusing it, so the
escape test main added passed the entry through. Check the stored name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:28:40 +00:00
MSedgeMC 95a4e8effa Keep an iron golem from fighting the creepers it refuses
IronGolem.canAttack answers no for a creeper as well as for a player, and
that answer is asked by the retaliation goal, by target continuation and by
the nearest-target search. Horizon mirrored only the player half, so a
creeper that hit a golem became its target and the golem swung back.

The rule now covers any target and is asked by the retaliation goal and by
target continuation, the two places that had no answer.
2026-10-31 11:58:02 +05:30
repositorykreml1nandClaude Opus 5.5 cbe755778c The hands stop shaking while a ridden boat turns
The hand pass draws with the camera's view, whose rotation spreads a ridden
boat's turn over the frames of a tick (LocalPlayer.getViewYRot). hand_geometry
posed the hands with the end-of-tick yaw instead, so they swung by that turn
20 times a second. HandSway::to_world takes the camera now.

Fixes #4583.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:26:49 +03:00
MSedgeMC 1e14c90711 Let the universal_anger rule reach a wolf's target goal
NeutralMob.isAngryAtAllPlayers is the gamerule universal_anger plus a mob
that is still angry with no anger target left, and isAngryAt asks it before
the named target. Horizon registered the rule and stored it, but the wolf's
player target goal only checked the named target, so the rule did nothing.

The wolf's predicate now asks the rule, and the world it reads carries the
gamerule the same way mob_griefing and mob_drops do.
2026-10-31 11:56:28 +05:30
ElyTheCatandCodex 98a1726379 Restore vanilla remote knockback ordering
Use the entity kind for the RemotePlayer client-side attack path so profile bookkeeping cannot suppress the vanilla attacker slowdown. Restore the client-side mob warning and leave sprint synchronization to the normal end-of-tick movement send.

Co-Authored-By: Codex <[email protected]>
2026-10-31 09:48:54 +03:30
Muhammad Asif eafecb1e48 Release held keys when a server dialog opens
A DialogScreen is a screen in vanilla, so KeyMapping.releaseAll runs.
Horizon kept Screen::Playing, dropped key and right-button releases, and
left W and use held after the dialog closed.
2026-10-31 06:17:54 +00:00
rdbtCVSandClaude Opus 5.5 0510ebdfbf rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:12:57 +00:00
rdbtCVS eeede72cf8 Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 06:12:20 +00:00
rdbtCVSandClaude Opus 5.5 0faccb5be8 Pack overlay test: break the zip where zip 9 still refuses to open it
zip 2 refused an archive whose entry claims method 99 without an AES
field; zip 9 opens it and only the entry fails, which mirror now skips.
A name that runs past the archive still fails the open, and the build.
Also rustfmt for upgrade/mod.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:12:19 +00:00
rdbtCVSandClaude Opus 5.5 d114abe94b Launcher pt-BR: the strings added since the table was written, rustfmt
Fills in the 135 strings the table lacked (Mods browser, native mods from
GitHub, AI agents, data collection), drops the unused "Sign in", names pt-br
in the language setting's hint, and formats parse().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:11:40 +00:00
rdbtCVS da79a2ba26 Merge remote-tracking branch 'origin/main' into pr-3986
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 06:07:00 +00:00
rdbtCVS f88dcf6379 Merge remote-tracking branch 'origin/main' into pr-3468 2026-10-31 06:04:29 +00:00
rdbtCVSandClaude Opus 5.5 976de52032 Merge main; give the world-wide plan test real assertions
world_wide_plans_stay_small_and_cover_every_level only printed node counts.
It now checks that the roots reach MAX_LEVEL, that each plan stays under
16 384 nodes (10 655 at most today, at a corner of the world), that reduced
has no more nodes than full, and that every level from the finest one to
the roots has tiles. The mesh test's corner check bound as (A && B) || C;
it now needs the far corner. Main's mesher materials take a position: the
heightmap's fluid test passes the tile origin, as fill_buried_gaps does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:04:23 +00:00
rdbtCVS 5ff1007a29 Merge branch 'fix/issue-4001-boat-drowning' of https://github.com/DUzzL/horizon into pr-4011 2026-10-31 05:57:02 +00:00
ElyTheCatandAntigravity ede6a087d0 Renderer: clamp vertical occlusion search grid height to non-air sections
- Dynamically clamp the occlusion graph search box height to the highest non-air section across columns in the search radius, skipping empty air section exploration.
- Fall back to column reachability for open air queries above the clamped search box.
- Add unit test vertical_clamping_bounds_height_and_reaches_open_sky.

Fixes #4985

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 09:24:48 +03:30
rdbtCVS f1cb24b213 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 05:47:06 +00:00
rdbtCVSandClaude Opus 5.5 18a3169447 Format the respawn immunity test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:39:55 +00:00
MSedgeMC e35e3e3ca0 Keep the touch pickup a NoAI allay still has
Mob.aiStep's looting loop runs for every server mob, AI or not, and an
allay's pickup gates (canPickUpLoot, the held item, mob griefing, the
inventory room) never ask for AI. So a NoAI allay still takes a matching
drop that lands in its getPickupReach, which Allay overrides to (1, 1, 1)
— it only never walks to one (the sensor is the brain's) and never throws
one (the delivery goal is too).

The blanket skip becomes that split: the reach is one helper, the search
uses it when AI is off, and delivery stays behind the AI gate.
2026-10-31 11:09:47 +05:30
Muhammad Asif b70cb22e9d Swap F in a chest with the off hand
AbstractContainerMenu SWAP button 40 is the off hand even when the
open menu has no off-hand slot. Horizon only mapped 40 on the
inventory menu, so F in a chest predicted a move the server ignored
and left a ghost item.

Fixes #4887
2026-10-31 05:35:25 +00:00
rdbtCVSandClaude Opus 5.5 fae347ca67 Merge main into fix/mod-registry-kind-and-retry
Keeps both sides: main's owner updates, the "listed by its repository"
refusal and the label route next to the native turn and the retry. A mod
turning native is never an owner update (its kind changes, which the
owner-update workflow doesn't review), and the test checks for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:29:28 +00:00
rdbtCVS 28b8be4410 Merge remote-tracking branch 'origin/main' into pr-4834 2026-10-31 05:28:33 +00:00
reservedbytes b56f94b2b7 fix(render): terrain samples block textures like vanilla 2026-10-31 04:16:34 -01:00
rdbtCVSandClaude Opus 5.5 0240b305c9 Launcher on Windows: remove an interrupted update's copy too
An update that stopped between copying the new binary and moving it into
place left <name>.update-<pid> in the install folder, and the start-up
clean-up only matched .old- files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:16:05 +00:00
rdbtCVS 17e6f6e8fa Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 05:15:35 +00:00
rdbtCVSandClaude Opus 5.5 a0f42c81e0 One rule for zip entry names that can't be decoded
A reader that returns a Result fails with an error; the best-effort port-pack scans, which already skip entries they can't read, skip it. Also ports the readers main added since (resourcepacks.rs, upgrade/mod.rs) to the 9.x API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:15:33 +00:00
SolenopsisbotandClaude Opus 5.5 a4d481311f Game: its event handlers take a loop handle, not winit's loop
A headless client (#4951) has no display, so it cannot run winit's event loop. The game's
methods used winit's ActiveEventLoop only to stop the loop and to say when to wake next:
they now take `&dyn EventLoopCtl` (game/event_loop.rs), which winit's loop implements.

window_event and about_to_wait keep their bodies, now the inherent handle_window_event and
idle, and the ApplicationHandler methods forward to them. The control socket's and the pad
menu's synthetic input call handle_window_event. Nothing changes for a window's game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:14:36 +11:00
MSedgeMC 026d3fa47a Save a pufferfish's puff state and an enderman's carried block
Pufferfish.addAdditionalSaveData writes PuffState and reads it back clamped
to two; Enderman writes carriedBlockState with the block state codec, only
while it carries one, and an air state reads back as no block. Horizon
saved neither, so a puffed fish came back small and an enderman forgot its
block across a reload.

The block state codec moves from the save path into entity_nbt so both
sides share it, and the registry it needs is threaded to the two save
functions.
2026-10-31 10:41:41 +05:30
rdbtCVS 2f8def5b52 Merge remote-tracking branch 'origin/main' into pr-3986
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 05:10:45 +00:00
reservedbytes d8a9baec80 feat(mods): mod mob effects show their icons 2026-10-31 04:07:43 -01:00
rdbtCVS 1a1aae69ec Merge remote-tracking branch 'origin/main' into pr-3192 2026-10-31 05:05:20 +00:00
Dufru 99efd0ccb4 i18n(launcher): add pt-br translation and wire language support 2026-10-31 02:04:38 -03:00
rdbtCVS e41d76620e Merge remote-tracking branch 'origin/main' into pr-4631
# Conflicts:
#	crates/horizon-client/src/text_display.rs
2026-10-31 04:59:39 +00:00
rdbtCVS de6436fd88 Merge remote-tracking branch 'origin/main' into pr-4341
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-client/tests/lan_bot.rs
2026-10-31 04:59:11 +00:00
rdbtCVSandClaude Opus 5.5 476cfa77ae Fix cartography test after merging main: Decoration has name and key now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:58:56 +00:00
rdbtCVSandClaude Opus 5.5 c3557288e6 Merge main; keep only the survival drop from the eyes
#4231 (on main) already returns the replaced piece in creative and
discards it there when the inventory is full. What this branch still
adds: survival throws the piece that no longer fits like Player.drop
(stack, false) instead of spawning it motionless at the eyes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:58:36 +00:00
rdbtCVS a3cbfd71d0 Merge remote-tracking branch 'origin/main' into pr-3985 2026-10-31 04:55:48 +00:00
rdbtCVSandClaude Opus 5.5 d929bb58a8 Merge main; move the software-adapter skip into open_backend
Main split open_device into open_with_retry and open_backend (#4064). The
CPU-adapter check now sits right after request_adapter and fails the
backend, so the non-conformant retry doesn't pick the same adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:55:42 +00:00
rdbtCVS 852f627737 Merge remote-tracking branch 'origin/main' into pr-4011
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 04:54:41 +00:00
rdbtCVS ad3f5ed3d7 Merge remote-tracking branch 'origin/main' into pr-4111 2026-10-31 04:54:09 +00:00
rdbtCVS b804e8a290 Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 04:53:40 +00:00
MSedgeMC 584f41373e Report a mooshroom's colour as its variant component
MushroomCow.get answers with minecraft:mooshroom/variant, red or brown, and
applyImplicitComponents reads it back, so a mob spawned from an egg keeps
its colour. finalize's species_variant_component listed wolf, cat, frog,
pig, cow, chicken and zombie nautilus but not the mooshroom: the component
was never reported, variant_name fell through to the frog's table and
variant_index could not recognise "brown".

The save format is untouched: a mooshroom still stores Type, not variant.
2026-10-31 10:20:29 +05:30
rdbtCVSandClaude Opus 5.5 d22bb9e177 Skip only items added this tick in tick_items, not every fresh one
The ticks == 0 guard never let a dropped item reach ItemEntity::tick, the
only place ticks counts up, so every drop froze: no gravity, pickup delay,
merging or despawn. An item now waits one tick only when its id is past
the last one the tick saw after its functions ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:49:50 +00:00
rdbtCVS 0ddc638be0 Merge remote-tracking branch 'origin/main' into pr-4440 2026-10-31 04:47:39 +00:00
rdbtCVSandClaude Opus 5.5 9c42d07257 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:46:04 +00:00
rdbtCVSandClaude Opus 5.5 3466915d77 Merge origin/main into fsr-frame-interpolation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:45:29 +00:00
rdbtCVS ed89678b47 Merge remote-tracking branch 'origin/main' into pr4923
# Conflicts:
#	docs/graphics.md
2026-10-31 04:45:24 +00:00
rdbtCVSandClaude Opus 5.5 c1ba8b5e66 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:45:14 +00:00
rdbtCVS 252ac76369 Merge branch 'pr4335' into pr4345 2026-10-31 04:45:02 +00:00
rdbtCVS 3dbcf3c6c6 Merge remote-tracking branch 'origin/main' into pr4335
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-31 04:44:54 +00:00
rdbtCVS 070046d358 Merge remote-tracking branch 'origin/main' into pr-3945 2026-10-31 04:44:24 +00:00
rdbtCVSandClaude Opus 5.5 3a88476964 Take MapFrame by value in to_nbt (clippy wrong_self_convention)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:24 +00:00
rdbtCVSandClaude Opus 5.5 48beef082a Merge main; drop the melee killer note that #4927 already adds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:14 +00:00
7ee89b0e4c Make the launcher warning prominent in game releases
Game release notes open and close with the warning to get the launcher and not run the files; the closing one sits above the disclaimer. Rebuilt on main's release notes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:42:49 +00:00
reservedbytes 45094a9007 fix(combat): firework rockets hurt as fireworks 2026-10-31 03:38:30 -01:00
levzzzandClaude Sonnet 5.5 44b9c67290 Merge #3870 into native-fast-profile: one compile step; the build log keeps what follows a progress redraw
The release switch picks Release; Fast still falls back to release for a
revision without [profile.fast]. The log filter now strips only the progress
pieces of a line, so an error after a redraw reaches build.log.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:33:45 +05:00
levzzz bf27153a24 Merge main into native-fast-profile 2026-10-31 09:31:25 +05:00
SenjinandClaude Opus 5.5 975666d3d5 Merge remote-tracking branch 'origin/main' into hdr-glow-2
Main's OIT_ENTITY shares the layer's high bits with the HDR tier: the overlay
consumers mask 0xffff, vs_lightning masks the tier, and a test pins that a tier
keeps the material.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:28:39 +01:00
ElyTheCat fca2bb2b79 Merge remote-tracking branch 'origin/main' into feat/ui-slider-keyboard-controls 2026-10-31 07:55:22 +03:30
ElyTheCatandAntigravity f4531d6e0d UI: fix missing click sounds for keyboard Space/Enter; add focused-widget wheel
- options_ui: Space/Enter on FocusId::Cycle now emits Event::ButtonClick
  before cycling (was silent before)
- options_ui: wheel on a keyboard-focused slider steps it; wheel on a
  keyboard-focused cycle button cycles it (Horizon improvement; Vanilla 26.3
  does not intercept the wheel for sliders at all)
- graphics_ui: same wheel improvement for the graphics settings page — when
  can_change_value is set, wheel steps the focused slider or cycles the focused
  cycle item instead of scrolling the list
- title.rs: confirm_key_size returns (Action, bool); the bool is true for any
  enabled focused button press, false for disabled buttons or no-ops
- game.rs: title keyboard confirm plays the click sound from the bool, not just
  on Action::Play
- startup_menu.rs: extract .0 from confirm_key_size return
- Tests: options_keyboard_space_enter_emits_button_click_for_cycle_and_default
  and title_screen_keyboard_navigation_and_activation updated and passing

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 07:41:20 +03:30
socksproxandDevin (SWE-2 High) 0feb6f945e Unscale the default rider attachment before applying model scale
passenger_attachment multiplies the base attachment by the entity scale; the bounding-box fallback now reads height() already scaled after getDimensions learned minecraft:scale, which double-applied it for players and mannequins.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 05:03:41 +01:00
levzzzandClaude Opus 5.5 bcbbdea7a8 Merge launcher/native-builds-stay-put (#3870): its review fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:59:06 +05:00
socksproxandDevin (SWE-2 High) f6e255ef2b Merge main: keep both the scaled-box and sulfur cube fuse tests
Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:48:34 +01:00
socksproxandDevin (SWE-2 High) ed17feee57 fmt: wrap the interaction_range signature
Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:41:48 +01:00
ElyTheCatandAntigravity 65e5c14a7a Reset sprint and apply 0.6 attacker speed cut on sprint-knockback hits
When landing a melee attack while sprinting with attack strength scale > 0.9,
vanilla Minecraft 26.3 executes a sprint-knockback hit:
- Plays minecraft:entity.player.attack.knockback
- Multiplies attacker horizontal velocity by 0.6 (ATTACKER_SLOWDOWN)
- Cancels sprint state and syncs ServerboundPlayerCommandPacket(STOP_SPRINTING)
- Excludes spears from sprint-knockback attacks

Fixes #3736

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 07:04:23 +03:30
socksproxandDevin (SWE-2 High) 8b10fe0f2a Launcher: keep each account's secrets in one macOS keychain item
Secrets were split into ~1 KB pieces on every OS for Windows Credential
Manager's 2560-byte item limit. Each piece is its own keychain item and
asks consent for a launcher signature it doesn't know, so macOS players
got a prompt per piece after every ad hoc self-update (#4912). On macOS a
secret is now one item; split pieces an older launcher left fold into it
on first read, and a merged item reads fine as "piece 0" to launchers
that still split. has_owner also checks the selected account first, so
the owner gate touches one item when the player owns the game.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:25:41 +01:00
ErikKalikasandClaude Opus 5.5 89f4d8e635 Native mods: send mod block entities to clients
Each content block gets a minecraft:block_entity_type id after vanilla's,
named after the block. The chunk encoder sends a mod block's block entity
with that type and the tag native_block_entity_update_tag gives
(getUpdateTag), and leaves out a mod block that has none; BlockEntityData
on a block change uses the same hook. The client makes its copy through
native_block_entity and merges the tag into its nbt. NATIVE_HOOKS_API 23.

Refs #4615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:25:15 +02:00
NazaandClaude Opus 5.5 353f09034d Merge main: keep marking messages processed where the chat takes them, with its chat trust
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:21:20 -03:00
levzzzandClaude Opus 5.5 00ca93e19d Native mods: address review findings, restore the bundle docs
- post_builds: only a missing native-post-build.json means no steps; any other read error, or
  a mod id that isn't one, stops the build (arismoko 3, dimaniojk 1, small point).
- native_data::built and native_built_data return None when the game's path is unknown,
  never a folder relative to the working directory (arismoko 5, dimaniojk 2).
- .hnmod: data is only a bundle's data/ or a crate's top-level data/ (arismoko 4); README*,
  LICENSE*, COPYING*, NOTICE* and .gitignore are accepted in the bundle or a crate folder
  (reservedbytes 3).
- Post-build steps get HORIZON_NATIVE_BUNDLE_DATA_DIR, the bundle's staged data/ (reservedbytes 4).
- "Building horizon-mixins (once)" shows only when it is built; the crate check runs only when
  a mod has a crate; building a post-build step has its own label (reservedbytes 5).
- launcher/Cargo.toml: the libc block above the keychain comment (DUzzL 2).
- docs/native-mods.md: the "A bundle" section (crates, data files, post-build steps), lost in
  an earlier merge, is back, with the .hnmod limits, run-time data and why Horizon's own
  crates are refused for now (#3897; arismoko 1, dimaniojk 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:21:01 +05:00
levzzz bc310a28c8 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-31 08:16:50 +05:00
levzzzandClaude Opus 5.5 7cc5e640f9 Merge main into launcher/native-mod-crates: data files are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:14:35 +05:00
k3yhandClaude Opus 5.5 0e076e0559 Send custom_model_data and item_model to the client (#4916)
stack_to_network writes a fixed list of components, and single player's
window gets its stacks through it from the integrated server, as LAN
guests do. custom_model_data and item_model were not on it, so the
client's item model never saw them: custom_model_data tints stayed on
their default, selects fell back, and an item_model override was ignored.
Write both: item_model as its id, custom_model_data from its saved form
(CustomModelData.CODEC, colours as ints or float triples) into its stream
codec.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:10:36 -05:00
reservedbytes fb7f8a7656 fix(worldgen): placed templates update their shapes and neighbours in the live level 2026-10-31 01:51:32 -01:00
Dufru 387d6be2a8 i18n(launcher): fix spanish text to brazilian portuguese 2026-10-30 23:44:59 -03:00
SenjinandClaude Opus 5.5 d993634c18 HDR: calibration, full screen, one test picture at a time
Graphics: HDR gets a calibration for Black Level, Peak Brightness and Paper White:
each step a test picture drawn by the HDR pass straight in nits, with a slider
and one line of help. Horizon's logo disappears on black, then into a patch of 10%
of the screen; Paper White is a white card, by eye. Save writes the three, Cancel
keeps them, a step left untouched keeps Auto. Paper White goes down to 10 for
dark rooms.

Where the system reports the display's values the button reads Calibrate Anyway...
with "Already set up for your display", and Use Automatic Setup puts changed
values back; where nothing is reported it asks the player to calibrate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:41:48 +01:00
646b213087 Fix recipe book crafting on servers.
Predict craftable PlaceRecipe placements on the remote client, look up
ghost recipes by display id, and clear the ghost when the server updates
the open menu so red ghost slots do not stick after a successful place.

Fixes #4549

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 03:38:04 +01:00
rdbtCVS d0076a5119 Merge main 2026-10-31 02:14:19 +00:00
levzzzandClaude Sonnet 5.5 45a8b80d13 Secure chat: key pair only on online-mode logins; unsigned-session signable commands keep vanilla's signed packet
handleLogin prepares the key pair only when the login packet says the server
is in online mode, and tick asks shouldRefreshKeyPair only once there is a
chat session. sendCommand sends ServerboundChatCommandSignedPacket whenever
the command has signable arguments, with no signatures when there is no
session (ArgumentSignatures.signCommand drops null signatures).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:36 +05:00
levzzz cb8fe4dc2b Merge branch 'fix/pr-4376' into fix/pr-4377 2026-10-31 06:29:42 +05:00
levzzz 5c1d6be44c Merge remote-tracking branch 'hr/main' into fix/pr-4377 2026-10-31 06:29:42 +05:00
levzzz 65b1d9a122 Merge branch 'fix/pr-4375' into fix/pr-4376 2026-10-31 06:27:02 +05:00
levzzz 1ba7b1a3dc Merge remote-tracking branch 'hr/main' into fix/pr-4376 2026-10-31 06:27:02 +05:00
levzzz 43a2732250 Merge remote-tracking branch 'hr/main' into fix/pr-4375 2026-10-31 06:25:46 +05:00
Federico TaglianiandClaude Sonnet 5.5 ffb0e280b3 Exploration maps count the references a chunk saved for its starts
StructureCheck.tryLoadFromStorage and onStructureLoad take the references of
a chunk's saved starts as known, so a world saved by vanilla keeps the
starts its maps already pointed at. SaveDir::start_references reads them;
the sidecar file still holds the ones maps add here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:00:25 +01:00
socksproxandDevin (SWE-2 High) 3d5aed2b20 Scale fake player hitboxes with minecraft:scale; pick !removed entities
A lobby's giant fake player (minecraft:player or mannequin with a
minecraft:scale attribute) rendered huge but kept a 0.6x1.8 hitbox at its
feet, so right-clicking it did nothing while vanilla's scaled box answers
the click. mob_hit also skipped non-alive mobs although CAN_BE_PICKED is
isPickable = !isRemoved, hiding health-0 NPCs from clicks and the entities
command, and entity reach was hardcoded instead of the
entity_interaction_range/block_interaction_range attributes.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 01:59:28 +01:00
Federico Tagliani 0161c4d30b Merge remote-tracking branch 'origin/main' into fix/explorer-map-references 2026-10-31 01:43:21 +01:00
Federico Tagliani 7749d48fb7 Merge origin/main into feat/f3-density-line 2026-10-31 01:37:32 +01:00
chi4aandDeepSeek Harness a089727181 Merge upstream/main into fix/entity-chest-open
Keep both sides in boats.rs: upstream/main's AbstractBoat.remove lead drop
(boat_removed) and this branch's chest-content spill (unconditional) plus the
entity_drops-gated boat item drop.

Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-31 01:55:04 +02:00
Federico TaglianiandClaude Sonnet 5.5 e40a53d871 Map references: write the file beside it and rename over it
A crash mid-write left truncated JSON, which loads as no references and
made maps repeat structures.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:52:27 +01:00
Federico Tagliani d06b7d7fe3 Merge origin/main into fix/explorer-map-references 2026-10-31 00:52:06 +01:00
Federico TaglianiandClaude Sonnet 5.5 b387b911e4 F3 Density line: skip it while a long query holds the worker
The F3 overlay is built on the window thread every frame; locking the
shared query worker froze the frame behind /locate biome or /place jigsaw.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:51:35 +01:00
Federico Tagliani ce2e70d916 Merge origin/main into feat/f3-density-line 2026-10-31 00:51:23 +01:00
ElyTheCatandAntigravity b355b3540c Worldgen: zero scratch density buffer on reuse and preserve heightmap bounds
- Pre-zero reused thread-local scratch density buffer in fill_noise before density sampling.
- Track heightmap bounds dynamically with re-queried first_available across downward scans and non-opaque blocks.
- Add regression unit tests for poisoned scratch density buffer reuse and top-down surface heightmaps.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 03:13:34 +03:30
e115843756 Fix ghost sand when placing falling blocks next to each other.
Drain hub player packets before scheduled block ticks on the server thread so a second sand place in the same tick as the first's fall schedule still sees the support, matching vanilla's packet-before-block order.

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:40:37 +01:00
Łukasz KopańskiandCursor 38eb9d612e Drop unrelated recipe_book ghost edits from the cape fix.
Those private-field clears broke verify; keep only auth/skins/profile cape merge.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 00:28:32 +01:00
Dufru 551be35cc3 i18n(launcher): add Brazilian Portuguese (pt-BR) translation 2026-10-30 20:16:27 -03:00
ElyTheCat b0ed32ad9a Merge remote-tracking branch 'origin/main' into feat/ui-slider-keyboard-controls
# Conflicts:
#	crates/horizon-client/src/game/screens.rs
#	crates/horizon-client/src/title.rs
#	crates/horizon-client/src/title/tests.rs
2026-10-31 02:46:26 +03:30
ElyTheCatandAntigravity 4a06ae4d47 UI: Add keyboard navigation for Title, Pause, Death, and Confirm screens; refine slider parity
- Keyboard Navigation & Focus Parity (Java Edition 26.3 Screen / AbstractWidget):
  - Title Screen: Tab cycling, Arrow key 2D spatial navigation, Space/Enter activation, and button focus highlighting.
  - Create World & Select World: Tab / Arrow navigation, bidirectional option cycling, Space/Enter activation.
  - Pause & Death Screens: Tab cycling, Arrow navigation, button activation, and focused button rendering.
  - Confirm & Choice Screens: Tab / Arrow navigation between Yes/No/Cancel options, Space/Enter activation, Esc cancel.
  - LAN / World Options Screen: Full keyboard navigation and activation for all controls and modals.
- Slider & Wheel Parity Refinements:
  - Per Java 26.3 AbstractSliderButton parity, sliders do not intercept mouse wheel; mouse wheel in lists scrolls the list. Blocked rows fall through to scroll.
  - Derive slider steps directly from slider definitions via slider_steps and slider_position / set_slider, removing duplicate hardcoded ranges.
  - Added unit tests verifying stepping equals set_slider across all stepped options.
  - Dialog sliders: Arrow keys step by declared step / range ratio rather than fixed 0.01.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:30:51 +03:30
Łukasz KopańskiandCursor e2de9a0b28 Fetch the signed-in player's active cape from Minecraft profile.
Single-player often had a skin but no cape because the client only filled missing skins from the services, not missing capes. Ask GET /minecraft/profile once after join and merge ACTIVE skin and cape into local textures.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:45:15 +01:00
martipopsandClaude Opus 5.5 5726f5a551 Merge upstream main into split/10-profile-page
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:38:30 -04:00
martipops e8c951cb3a Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-30 18:37:10 -04:00
ElyTheCatandAntigravity 41da822ff8 fix(gl): restore clippy allow on UniformLocation and clarify multi-draw requirements
- Re-add clippy::clone_on_copy allow for UniformLocation in command.rs.
- Format indirect_execution expression in adapter.rs to satisfy rustfmt.
- Note in gfx.rs comment that multi_draw requires indirect first instance
  support (OpenGL 4.2 + GL_ARB_shader_draw_parameters) in addition to 4.0 indirect draws.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:00:38 +03:30
martipopsandClaude Opus 5.5 f68140d0e5 Splitscreen profile page: show the last page there is after a Forget
Forgetting the only profile on the last page left the page empty until
Previous was pressed: the page is now kept to the pages the list has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:20:44 -04:00
martipopsandClaude Opus 5.5 eff016f55d Splitscreen audio: say what the duplicate rule can merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:12:25 -04:00
GIGABAIT93 ec731c699a Merge branch 'feat/launch-api-prepare-complete' into feat/launch-api-prepare-inputs 2026-10-30 23:51:53 +02:00
GIGABAIT93 2643889010 Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete 2026-10-30 23:51:49 +02:00
GIGABAIT93andClaude Opus 5.5 e5cbdb422e horizon-prepare: one lock when --output is .local; absolute Java paths
An --output naming root/.local itself took that folder's lock a second
time through another handle, and waited for its own process forever.
Java found through a relative JAVA_HOME, --java-home or PATH entry is now
absolute, since the exporters run in .local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:51:40 +02:00
calclb 4d0630488d name player killers in death message
CombatTracker only tracked mob attackers, now we track players so we dont
print '%1$s was slain by %2$s'. pr covers melee hits, sweep attacks,
arrows, thorns.
2026-10-30 16:38:16 -05:00
T3Code 5fb61a983b Allow native rims around loaded ordinary blocks 2026-10-31 02:53:08 +06:00
NazaandClaude Opus 5.5 0d2cb928ba Merge the chat key branch and main: one signature cache, messages marked when the chat takes them
The client uses #4214's SignatureCache and its handling of a bad packed id, instead of its
own cache. markMessageAsProcessed runs where the chat takes a message, past any Chat Delay,
with whether it was shown; handleDeleteChat now calls LastSeenTracker::ignore_pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:24:45 -03:00
VaspyyyandClaude Opus 5.5 713c613140 Shader API: one switch case per sprite layer when names share it
Identical stills share a layer under several names, so sprite_tags gave
the generated switch duplicate cases (naga: ConflictingSwitchCase) and the
pack failed to load. Their tags are now merged per layer. Reported with
the fix by DUzzL's agent on #4186.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-30 20:16:07 +00:00
NazaandClaude Opus 5.5 98dfd26b67 Merge the signing branch and main: the chat keys start where the session now loads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:12:34 -03:00
NazaandClaude Opus 5.5 7e8b9c1154 Merge main: SignatureCache::unpack is #4214's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:12:18 -03:00
DUzzL 50a501c334 Discard the replaced armour in creative when the inventory is full 2026-10-30 21:03:47 +01:00
chi4a 56c8af8bd8 Fix opening container vehicles (#4370)
Right-clicking a chest boat, a chest raft, a chest minecart or a hopper
minecart did nothing: the server-side container path (Game::lan_open_container,
shared by LAN guests and single-player players) only knew block containers and
silently dropped the vehicle's placeholder key.

- give a container vehicle its own menu and title: ChestMenu.threeRows (9x3,
  27 slots) for chest boats, rafts and chest minecarts, HopperMenu (5 slots)
  for hopper minecarts, titled with Entity.getDisplayName's name key, as
  AbstractChestBoat.createMenu / MinecartChest.createMenu /
  MinecartHopper.createMenu / MenuProvider.getDisplayName do;
- fire CONTAINER_OPEN with the player and PiglinAi.angerNearbyPiglins on a
  chest boat and a chest minecart (MinecartChest overrides interact; a hopper
  minecart does not), and CONTAINER_CLOSE on their close (MinecartChest
  overrides stopOpen; a hopper minecart inherits the no-op);
- send OPEN_INVENTORY while riding a chest boat (HasCustomInventoryScreen ->
  ServerGamePacketListenerImpl.handlePlayerCommand), so E opens the chest
  instead of the player's inventory;
- spill a chest boat's contents on every destroying removal, as
  AbstractChestBoat.remove does (a creative hit discards, DISCARDED
  shouldDestroy is true, and Containers.dropContents has no entity_drops
  gate; the boat item stays on the entity_drops death path);
- drop the chest minecart's open/close chest sounds: vanilla plays none.

Tests: tools/test_vehicle_container_parity.py (new, 13/13 menu cases against
the real 26.3 jars), live before/after on the server thread, and two-client LAN
item-loss/duplication runs.

Refs #4370
Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-30 21:57:07 +02:00
ElyTheCatandAntigravity e74b415d72 UI: Add keyboard navigation, arrow controls, and wheel scrolling for Horizon graphics settings and extras
- GraphicsUi (Horizon Graphics, Advanced, HDR):
  - Add full keyboard navigation (Tab/Shift+Tab, ArrowUp/ArrowDown, ArrowLeft/ArrowRight)
  - Slider keyboard controls (ArrowLeft/ArrowRight step discrete and continuous settings)
  - Selection key (Space/Enter) toggles can_change_value on sliders, cycles buttons, triggers actions
  - Escape cancels can_change_value before leaving the screen
  - Mouse wheel scrolling on sliders steps value; wheel on cycle buttons cycles mode
  - Visual parity: track highlighted when focused and !can_change_value, handle highlighted when hovered or can_change_value
  - Auto-scroll keeps focused list elements in view
- OptionsUi:
  - Route wheel and key_modifiers to GraphicsUi when on Horizon Graphics pages
  - Support mouse wheel cycling on Act::Extra toggle switches (Extras page)

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 23:07:04 +03:30
ElyTheCatandAntigravity ac08e26060 UI: Add keyboard navigation, arrow control, and wheel scrolling for sliders
- AbstractSliderButton / OptionInstanceSliderButton parity:
  - ArrowLeft / ArrowRight adjust slider values when in can_change_value mode
  - Space / Enter toggles can_change_value focus state
  - Escape cancels can_change_value before closing the options screen
  - Mouse wheel scrolling over a slider steps its value
  - Render state parity: track highlighted only when focused and !can_change_value,
    handle highlighted when hovered or can_change_value
- Dialog sliders: support ArrowLeft / ArrowRight nudge and mouse wheel scrolling
- General UI keyboard navigation: Tab/Shift-Tab cycle and 2D spatial Arrow navigation

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 22:43:42 +03:30
levzzzandClaude Sonnet 5.5 496d04368d Merge main into launcher/native-mod-crates: data files are API 15
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:58:32 +05:00
levzzzandClaude Sonnet 5.5 01b88f1524 Launcher: native builds use the fast profile by default, with a build status
fast (incremental, no LTO) is the default Cargo profile of native builds; a
setting, native-build --profile and 'settings native-profile' pick release.
The profile is in the key and has its own target folder. CARGO_INCREMENTAL is
removed from the build's environment. The build says up to date / incremental
(what changed) / first build before it starts, shows crates done of the total,
the profile and the time while it compiles, and 'Built in 42 s (fast,
incremental)' after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:56:19 +05:00
Muhammad AsifandOhMyAgent 4ef0559617 Let mobs board minecarts they walk into
OldMinecartBehavior.pushAndPickupEntities also picks up living entities
in the cart's box, 0.2 wider on each side. Horizon only pushed other
carts, so a cow shoved into a minecart stayed on the ground. Eligible
mobs now startRiding an empty cart on contact; players still click to
board, iron golems still push, and a cart that already has a rider
pushes instead. Entity.canRide refuses a warden and a boarding cooldown.

sync_riders treated only boats and mobs as vehicles, so a boarded cart
would drop its rider the next tick. Riders sit on the cart's passenger
attachment, get out when it breaks, and are saved under Passengers.

Vanilla: OldMinecartBehavior.pushAndPickupEntities, Entity.startRiding,
AbstractMinecart passenger attachment, Entity.positionRider.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-30 23:42:50 +05:00
DUzzL b094aa114f Pin the creative armour swap with tests and a pure swap plan 2026-10-30 19:37:54 +01:00
DUzzL 4f76341bdd Launcher: read ATLauncher instances from observed instance.json files, not its source 2026-10-30 19:18:19 +01:00
AurelienBelloirandClaude Opus 5.5 3033b8133f Name the extended transformation codec in display_data
Display.readAdditionalSaveData reads "transformation" with
Transformation.EXTENDED_CODEC (the four-part form or an ExtraCodecs.MATRIX4F
decomposed by MatrixUtil.svdDecompose) and falls back to
Transformation.IDENTITY. The comment above the read still described only
the four-part Transformation.CODEC; it now names the codec actually followed.

The MatrixUtil.svdDecompose test pins its expected bit patterns to u32 so the
0x8000_0000-style literals never fall back to i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:14:06 +01:00
AurelienBelloirandClaude Opus 5.5 e040b000b0 Decompose matrix display transformations like MatrixUtil
Display.readAdditionalSaveData reads "transformation" with
Transformation.EXTENDED_CODEC: the translation/left_rotation/scale/
right_rotation record, or else ExtraCodecs.MATRIX4F (sixteen floats,
row-major), which new Transformation(matrix) splits on demand with
MatrixUtil.svdDecompose (eigenvalueJacobi on a^T*a for the right
rotation, then QR Givens rotations about z, y and x for the left rotation
and scale). Horizon left a matrix-form display at the identity.

- horizon-assets math: svd_decompose, following MatrixUtil.svdDecompose,
  stepJacobi, approxGivensQuat, qrGivensQuat and GivensParameters with
  JOML 1.10.9's Matrix3f.mul/rotate and Quaternionf.mul arithmetic; the
  test checks the bits vanilla's own decomposition returns.
- text_display: the matrix alternative feeds DATA_TRANSLATION_ID to
  DATA_RIGHT_ROTATION_ID. Axis-angle rotations now follow
  new Quaternionf(AxisAngle4f) (the angle wrapped to [0, 2pi), the axis
  not normalized) and component rotations Quaternionf.normalize, through
  the existing horizon-assets helpers, instead of an approximation.
- entity_data: a test pins VECTOR3 and QUATERNION to
  FriendlyByteBuf.writeVector3f / writeQuaternion's big-endian floats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:05:49 +01:00
DUzzL a1c065740f Boat passengers' fluid box follows the boat
Entity.getFluidInteractionBox asks the vehicle, and
AbstractBoat.modifyPassengerFluidInteractionBox keeps only the part of the
passenger's box above the boat's top, unchanged when the boat is underwater and
absent when the boat reaches the passenger's top. The rider's fluid interaction
now uses that box instead of clearing the eyes afterwards, so the body
immersion, in_water and the eyes all follow vanilla and a boat floating on water
no longer drowns its passenger (#4001).
2026-10-30 18:56:49 +01:00
DUzzL 9784820d3c Boat passengers no longer drown while the boat floats on water
Entity.updateFluidOnEyes finds no fluid on the eyes of a rider whose eyes
are inside a boat that is not underwater. The server-side tick of a LAN
guest ignored that, so a boat bobbing in water drowned its passenger.
2026-10-30 18:56:47 +01:00
AurelienBelloirandClaude Opus 5.5 12a100719a Send the world's data pack dialogs in the dialog registry
RegistryDataLoader builds the synchronized registries from every enabled
pack, so a pack's data/<ns>/dialog/ files join minecraft:dialog in
Identifier order (path first, then namespace) and /dialog show can open
them. The data pack loader now reads dialog/ files, SyncData merges them
over the game's own and always sends their data (no client knows the
pack), and the commands' dialog ids follow SyncData so a parsed id sends
the index the clients hold.

Opening a world with data packs now drops the preloaded SyncData, which
was read without the world's packs and kept their recipes, advancements
and dialogs from the clients.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:38:26 +01:00
AurelienBelloirandClaude Opus 5.5 6a528894ad Fire the default_block_use and any_block_use advancement triggers
ServerPlayerGameMode.useItemOn fires DEFAULT_BLOCK_USE when the block's
useWithoutItem consumes the click, and handleUseItemOn fires ANY_BLOCK_USE
when the whole useItemOn does, with the hand's stack as the use left it.
Behaviours::use_item_on_branch says which of useItemOn and useWithoutItem
answered; the generic path now also fires ITEM_USED_ON_BLOCK for a
consuming useItemOn (a candle snuffed by hand). The jukebox eject, the
decorated pot wobble and the chiseled bookshelf removal fire
DEFAULT_BLOCK_USE on their useWithoutItem branch.

Fixes #4499

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:32:09 +01:00
Eshayat Al-WasiuandT3Code efd3dcaf86 Add bounded native emissive overlays for texture-matched materials
Co-Authored-By: T3Code (GPT-6 Sol) <[email protected]>
2026-10-30 23:04:10 +06:00
MSedgeMC 87231ee654 Let a brown mooshroom take a flower's stew effects
MushroomCow.getEffectsFromItemStack reads the item's block, so a brown
adult takes a flower, stores its suspicious stew entry and hands it back in
the next bowl of stew as a suspicious stew carrying that effect. Horizon
had the component, the brown variant and the bowl branch, but no flower
branch and no stored effects, so a bowl always gave plain stew.

The effect belongs to the block, not the item, so the table lives beside
the other block facts: seventeen entries, cross-checked against the jar's
own suspicious_stew_from_* recipes. The two eyeblossoms and the wither rose
are holders too, which a count of FlowerBlock constructor sites misses.
2026-10-30 22:23:21 +05:30
Михаил РоговцевandCodex c1ac65d5fa Inventory: handle recipe book clicks before container presses
Co-Authored-By: Codex <[email protected]>
2026-10-30 18:39:54 +03:00
Михаил РоговцевandCodex c13cf61609 Inventory: prevent recipe book clicks from throwing carried stacks
Co-Authored-By: Codex <[email protected]>
2026-10-30 18:39:39 +03:00
ElyTheCatandAntigravity ffc2c2e4b2 Worldgen: optimize terrain fill, aquifer cell caching, and 48-bit positional seeding
- Precompute the 48-bit masked seed base in LegacyPositionalRandom to eliminate full multiplicative seeding on position and string queries.
- Reuse thread-local scratch density buffers in fill_noise to eliminate 393 KB heap allocations per chunk pass.
- Hoist column density slices in fill_noise to eliminate 98,304 redundant volume.index() lookups.
- Implement monotonic top-down heightmap termination in fill_noise, bypassing all subterranean heightmap checks once settled.
- Cache horizontal squared distances and cell coordinates in NoiseBasedAquifer across vertical slabs, reducing inner distance loop work to a single subtraction and addition.
- Early-return in apply_carving_mask when the carving mask is empty to avoid redundant MaterialConditions setup and graph compilation.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 18:30:42 +03:30
Ludovic MarieandClaude Opus 5.5 1dde732325 Mod registry: a retry proves the branch is its own with a signed tag
Review (rdbtCVS): the "Submitted by @<login> through the form." line is
plain text anyone who writes to the registry repository can copy, and a
commit's author can be set by anyone too. The form's commit now carries
a "Submission: <tag>" trailer, an HMAC of the branch and the login with
the service's key; a stale branch is started again only when it holds no
commit of its own or the one commit with this account's tag. The test's
someone-else branch now copies alice's exact words with a forged tag and
is refused without a reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:51:15 +01:00
masa-masa-0314andZCode Agent 9c23d27666 Functions: tick before the levels' entities, and drop Age: 0 a tick early
`MinecraftServer.tickChildren` runs `commandFunctions` before any
`ServerLevel.tick`, and `EntityTickList.ensureActiveIsNotIterated` gives
an entity added while the list iterates its first tick on the next tick.
Together they mean a tick function that polls for the drop a block just
made still finds it with `Age: 0` — the pattern data packs use to hook a
block break. Horizon ticked its functions inside the entity pass, after
the items: the drop had counted its age up already and the poll found
nothing.

The functions now run at the top of the tick, before the levels, with
the world's saved data read first (a load tag's `data modify storage`
must land after the save is restored, not before). An item entity added
this tick first ticks the next one.

`type_specific/player` also matches players only, as
`PlayerPredicate.matches` starts with `instanceof ServerPlayer`, and
honours `input` (`InputPredicate`; `input.sneak` is `Input.shift`, the
held key, not the sneaking pose).

Verified with the Vein Miner data pack: its tick-driven chain mining
takes both ores it is asked to, and its `veinminer:sneaking` predicate
reports false while the player is not sneaking.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-30 22:47:12 +08:00
Ludovic MarieandClaude Opus 5.5 756e03fb81 Mod registry: a mod may turn native once; a retry takes up its own branch
publish() refused any kind change ("blockscope is a mod"), but the
registry's own review (horizon_mods::index::registry::review, run by
horizon-mods' CI) and docs/native-mods.md let a WebAssembly mod's entry
turn native-mod once, never back. The form now allows exactly that, by
an owner, and names the rule when it refuses any other kind change. The
pull request says the project turns native.

A submission GitHub broke off after making its submit/<id>-<version>
branch was refused as "already submitted" (409) on retry, with no pull
request. When the branch exists, the form now looks for an open pull
request (still 409, with its address) and at the branch's own commits:
if they are all this account's form commits (or there are none), it
resets the branch to main and submits again; anyone else's is 409.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:25:01 +01:00
Eshayat Al-WasiuandT3Code fd46f59c7d Add bounded native visual hook for block rims and colored lights
Co-Authored-By: T3Code (GPT-6 Sol) <[email protected]>
2026-10-30 19:50:02 +06:00
GIGABAIT93 5700a61de3 Merge branch 'feat/launch-api-prepare-complete' into feat/launch-api-prepare-inputs 2026-10-30 15:48:58 +02:00
GIGABAIT93 e81001ce80 Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete 2026-10-30 15:48:52 +02:00
GIGABAIT93andClaude Opus 5.5 277b7ee5da Merge main; refill_own_store asks missing_extras too
main added refill_own_store, which called the launcher's lacks_languages,
gone since missing_extras became the one check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:48:46 +02:00
GIGABAIT93 c83cf8cbed Merge remote-tracking branch 'upstream/main' into feat/launch-api-prepare 2026-10-30 15:44:05 +02:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol e0b4037a39 fix(bench): distinguish absent scene load measurements
Show a dash when no scene-load measurements exist; preserve measured zero and the mean of available scenes.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 10:42:54 -03:00
levzzzandClaude Opus 5.5 5355284d69 Merge hr/main into launcher/native-mod-crates: data files become hook API 8
Main took API 3 (world lifecycle) and 4-7 (arismoko's server tick, block reads,
neighbour and block-change observations). The data-file helpers (native_data!,
native_built_data) are API 8. MIXINS_REV moves to horizon-mixins 5140cdf
(#5, #7, #8 and #9: RETURN hooks on functions with closures and references).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:59 +05:00
GIGABAIT93andClaude Opus 5.5 78db89ba16 horizon-prepare: --java-home, log the jar read, document named inputs (#3545)
- Merges #3574 (and with it #3567 and #3551) for docs/api/launch.md.
- --java-home names a Java the launcher manages (Options::java_home was
  there for horizon-launcher only).
- The import logs the client jar it read, which --client-jar may have
  named outside the Minecraft folder.
- named_asset_store_serves_the_asset_commands: with an empty Minecraft
  folder, an asset command fails without --assets-dir and copies from
  the store it names.
- docs/api/launch.md and native-client.md: the named inputs, that an
  earlier verified import is reused instead, the server jar rule (Horizon
  never downloads it; a launcher only for an owner), --java-home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:31:26 +02:00
GIGABAIT93andClaude Opus 5.5 8311857928 docs: put --complete and --progress json back in native-client.md
The merge of #3567 into this branch dropped them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:29:19 +02:00
GIGABAIT93andClaude Opus 5.5 a91acdd348 Launch API: document horizon-prepare's output and --progress json (#3545)
- docs/api/launch.md gets a Preparing section: --output, --check (what it
  tests and what it doesn't), --complete, and --progress json's rules:
  one JSON object a line, the three keys, readers ignore what they don't
  know, a new key is a patch of the Launch API.
- The changelog lists the preparer's options under 0.1.0, still
  unreleased. The overview, docs/api/README.md and stability.md say the
  Launch API covers the preparer now.
- progress_json_lines_keep_their_shape pins message, error and fraction
  (a quote, a newline, three decimals); fraction_line is the one place
  that formats a fraction.
- steps_open_every_stage reads lib.rs with either line end, so it passes
  on a Windows checkout too.

This branch now holds #3551 as well, for docs/api/launch.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:28:56 +02:00
GIGABAIT93 8dbde815ba Merge branch 'feat/launch-api-client' into feat/launch-api-prepare-complete 2026-10-30 15:26:30 +02:00
GIGABAIT93 83fadad7bf Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete
# Conflicts:
#	crates/horizon-prepare/src/main.rs
#	docs/native-client.md
2026-10-30 15:26:25 +02:00
GIGABAIT93andClaude Opus 5.5 6f0f282cf1 horizon-prepare: lock a folder while preparing, unstamp before writing (#3545)
- A preparation removes the stamp before it writes anything and stamps
  again at the end, so --check calls a folder being prepared, or whose
  preparation stopped, not current.
- Each preparation holds .horizon-prepare.lock in the resources and in
  root's .local (add_assets in the resources); a second one says it is
  waiting and waits. tools/prepare_output.py leaves the lock out of the
  digest.
- missing_extras is the one completeness check: --check and the
  launcher's COMPLETIONS both use it.
- The Java exporters and the worker run in root's .local, so vanilla's
  logs/ stays in the scratch, not where the preparer was started.
- --check's help and docs/native-client.md say what it doesn't test
  (contents) and that exit 1 is either gaps or "can't check".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:26:08 +02:00
Teiwazik 2cefbebb64 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-30 14:14:50 +01:00
Teiwazik 45d19d1f87 Merge remote-tracking branch 'origin/main' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-30 14:14:49 +01:00
TeiwazikandClaude Opus 5.5 77fab15086 Shader API: [reflection] option, and facing culling for the mirror's eye
A pack can let players turn its water reflections off: [reflection] option
names an option the mirror is drawn only while it is on (a bool on, a number
above 0); without it, the mirror is drawn whenever the material reads it.
The sample pack shows it.

While mirroring, solid quads were all drawn, since the mirror's eye sees the
undersides the camera does not. Sides::visible_from keeps the quads that face
any eye from the camera straight down to its mirror below the plane, which
differ only in y, for the sections and the distant tiles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 14:11:12 +01:00
Federico TaglianiandClaude Sonnet 5.5 8d772a91d0 F3 shows the Density line of the noise settings' debug functions
NoiseBasedChunkGenerator.addDebugScreenInfo: DebugEntryChunkGeneration
adds 'Density N: .. T: .. V: .. C: .. E: .. D: .. W: .. PV: .. PS: ..' (the
overworld's debug_functions, three decimals) at the feet where the game
runs the level's generator: a single-player world on its server thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:53:24 +01:00
levzzzandClaude Opus 5.5 8cca9cd942 Native mods: horizon-mixins with Isloka's two fixes; post-build programs copied out
MIXINS_REV moves to horizon-mixins d9c83d7 (#7 and #8 by reservedbytes):
- a crate dependency with default features (hashbrown = "0.15") builds: cargo adds the
  lock links between crates Horizon already locks, any new crate is still refused;
- a post-build bin named like another mod's step bin or like horizon-client is refused
  (E_POST_BUILD).

The launcher also copies the game and each step's program out of the shared target folder as
soon as it is built, so a bundle staged by an older horizon-mixins can't run another mod's
program or replace the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:04 +05:00
Federico TaglianiandClaude Sonnet 5.5 bc92841fe6 Exploration maps skip the structures a map already pointed at
ChunkGenerator.findNearestMapStructure with createReference: a map with
skip_existing_chunks (the default, and every cartographer trade) points at
the nearest start no map pointed at yet and counts it as pointed at
(StructureStart.addReference, getMaxReferences 1), so two maps made in a
row no longer lead to the same structure. The starts are kept per
dimension in data/horizon_structure_references_<dim>.json.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:27:46 +01:00
TeiwazikandClaude Opus 5.5 9b53e24b33 Shader API: the material stage's water mirror
Water can reflect only what is on screen, which loses the sky over a lake and
everything above the picture's top. Within API 0.1, additively, the material
stage gains reflection and reflection_sampler: the world as a camera mirrored
about the horizontal water plane around the camera sees it, sky and solid
terrain at half resolution, cut away below the plane. Frame gains
water_plane, has_reflection and reflection_resolution.

The mirror is drawn only for a pack whose material function reads
reflection (CheckedStage::reads), so other packs and vanilla pay nothing and
the game scans for the plane only then: the median standing-water surface of
a grid of columns around the camera, rescanned when the camera changes block
or each second. Sections are culled against the union of the camera's and
the mirror's frusta; while mirroring, solid quads are not left out by facing
and occlusion culling is off, since both follow the camera's eye.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 13:24:14 +01:00
NazaandClaude Opus 5.5 eaf150712d Secure chat on a server's client: chat session, signed chat and commands
Online servers with enforce-secure-profile=true (26.3's default) refused every chat
message and /msg from Horizon ("Chat disabled due to missing profile public key").

game/remote_chat.rs (ClientPacketListener):
- setKeyPair: after each login packet, and when the key pair is renewed, a chat session
  (random id, chain from link 0) goes to the server as ServerboundChatSessionUpdatePacket,
  only when the server plays the account under its own profile id: an offline-mode
  server's offline id gets none, so chat stays unsigned there as before.
- sendChat / sendCommand: chat and each message argument of a command (SignableCommand.of
  over the server's tree) are signed with the same time, salt and last seen, one chain
  link each; every packet carries the last-seen update.
- handlePlayerChat / markMessageAsProcessed: other players' packed last seen through the
  signature cache, and a ChatAck once more than 64 messages wait.
- sendUnattendedCommand: a clicked chat line, a dialog's button and a mod's command stay
  unsigned (vanilla asks before signing one; there is no such screen here).

tools/secure_chat_client_e2e.py: vanilla 26.3's server and a mock of the services that
issues certificates; online with enforce-secure-profile (chat logged as secure, a /msg
whisper delivered, nothing refused) and offline (unsigned, as before). The net harness
takes its chat lines from HORIZON_NET_CHAT and reports whether it signed.

Fixes the LocalChatSession.createMessageEncoder, SignedMessageChain.encoder,
ClientPacketListener.setKeyPair and ClientPacketListener.sendCommand rows of
horizon-rust/horizon#3520 and horizon-rust/horizon#3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
NazaandClaude Opus 5.5 8f843abb92 Secure chat: the account's chat key pair, and the command arguments a client signs
- auth.rs: the services' certificates endpoint (player/getCertificates, discovery), POSTed
  with the account's access token: the PKCS#8 private key, the X.509 public key,
  publicKeySignatureV2, expiresAt and refreshedAfter (AccountProfileKeyPairManager.
  fetchProfileKeyPair). Fetched on a background thread at start and on each join, kept in
  memory like the token (no profilekeys file), renewed past refreshedAfter (checked at
  most hourly), and kept as it was when a fetch fails.
- horizon-command: ParseResults::signable_arguments (SignableCommand.of), moved out of
  the LAN host's handleSignedChatCommand so a client can run it over a server's tree.

Nothing sends the key yet: the client's wiring follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
NazaandClaude Opus 5.5 06f55b418d Secure chat: the client's signing (RSA private keys, the message chain encoder, the last-seen tracker)
The protocol pieces a client needs to sign its chat as vanilla 26.3's does, beside the
server's side that is already here:

- rsa.rs: PKCS#8 (and bare PKCS#1) RSA private keys, as the services hand out a player's
  chat key, and RSASSA-PKCS1-v1_5 signatures (Signer.from), checked with the public key
  before they leave. Signing is deterministic, so a test compares OpenSSL's bytes.
- secure_chat.rs: SignedMessageChain.Encoder (LocalChatSession.createMessageEncoder), the
  client's LastSeenMessagesTracker, and MessageSignature.Packed.unpack against the cache.
  Tests run what the encoder signs through the server's Chain, and the tracker's updates
  through LastSeenMessagesValidator.

Nothing calls them yet: the account's key pair and the client's wiring follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
iLostName e759c905f1 resource packs: a pack's core/text shader draws the GUI's text, so server HUDs show
Servers place HUDs with their pack's text.vsh (glyphs far right of a boss bar title, moved to the screen's corners) and colour effects in text.fsh. horizon-shaders translates the pack's GLSL (#include or #moj_import) to naga; the HUD draws GUI text runs with it.
2026-10-30 12:44:42 +01:00
TeiwazikandClaude Opus 5.5 f9ef4e0ea0 Shader API: the material stage's face, fluid, emission, normal and tangent
A pack's material stage cannot tell water from glass, a fluid's top from its
side, or a lamp from stone, and has no surface frame for normal maps. Within
API 0.1, additively, MaterialInput gains face and fluid (0 none, 1 water,
2 lava), emission (the block's HDR tier as 0..1), and the geometric normal and
tangent from the derivatives of the position and uv, snapped to an axis on
block faces so distant faces do not shimmer.

The meshers mark fluid quads in the high nibble of the colour's alpha byte,
which terrain.wgsl does not read (it reads only the emission in the low
nibble); the distant LOD tiles get the same flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 12:28:19 +01:00
TeiwazikandClaude Opus 5.5 07d302639e Shader API: the post stage's history, last frame's input to the last pass
A temporal pass (TAA, denoising) needs its own output from the frame before.
Within API 0.1, additively, the post prelude gains history (group 3, binding 8):
after the passes have run, what the last pass read is copied into it, so a
temporal pass put second to last reads its previous result next frame. It is
black on the first frame and with a single pass. Binding 8 leaves 6 and 7 for
the shadow map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 12:06:41 +01:00
MSedgeMC 015fbc82c1 Make the client-loaded immunity term real
ServerPlayer.isInvulnerableTo has three terms, and the third is
!connection.hasClientLoaded: from a death until the client reports the
world loaded, nothing hurts the player. That is what spares a respawned
player the void in the tick it is still standing where it died.

Horizon implemented all three terms, but the only production
implementation of the trait returned Ok(true) for this one, so it was
dead code. A guest now carries the flag: cleared by a respawn, set by
ServerboundPlayerLoadedPacket, copied onto the body each turn, and read by
the damage environment (including the starvation path, which builds one
too). A level change does not clear it, because vanilla resets the loaded
state on death and respawn only.
2026-10-30 16:29:13 +05:30
TeiwazikandClaude Opus 5.5 951c7d5165 Shader API: the frame's motion since the last one, and the eye's light eased
Temporal passes (reprojection, motion blur, accumulation) need where a point was
last frame; packs that adapt exposure or fade underwater fog need the eye's light
and medium without their steps. Within API 0.1, additively, Frame gains
previous_view_projection, camera_motion and delta_time, filled by NativeFrame from
the frame before, and eye_light_smooth, in_water and in_lava, eased with
half-lives of 2 s and a quarter second. The frame stays a multiple of 16 bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 11:55:38 +01:00
TeiwazikandClaude Opus 5.5 fa845cd580 Shader API: the material stage sees what lies behind translucent terrain
The material stage could shade water and glass only from their own texture: it
had no way to know what was behind them, so depth-tinted water and refraction
were impossible. Within API 0.1, additively:

- MaterialInput.screen: the point's place on the screen, 0..1.
- opaque_depth (binding 6) and opaque_color (binding 11): the world's depth and
  colour as the solid terrain and entities left them. The world pass ends before
  translucent terrain, the targets are copied, and a pass that loads them goes on;
  the depth is stored for that while a material stage runs. The colour can be
  copied from the scaled world target only: drawing straight to the window, it
  is a black 1x1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 11:45:06 +01:00
DUzzL 193c85660a Swap worn armour with the held piece in creative mode 2026-10-30 11:16:03 +01:00
ElyTheCatandAntigravity 5a2bfc3d24 OpenGL 4.2: Enable indirect execution and simulated multi-draw batching
Basic indirect drawing (glDrawArraysIndirect, glDrawElementsIndirect) is
standard in Desktop OpenGL 4.0+ and GLES 3.1+.

1. In wgpu-hal GLES adapter: decouple DownlevelFlags::INDIRECT_EXECUTION
   from compute shaders, enabling indirect execution on Desktop GL 4.0+
   (and GLES 3.1+), which unlocks multi_draw in horizon-client-render on
   OpenGL 4.2 hardware (e.g. Intel HD Graphics 4000).
2. In wgpu-hal GLES command and queue: avoid pushing draw_count separate
   Command variants into the command buffer. Push a single DrawIndirect /
   DrawIndexedIndirect command with draw_count. In queue execution, bind
   DRAW_INDIRECT_BUFFER once and loop glDraw*Indirect across offsets on the
   CPU, eliminating command buffer allocation churn and repeated buffer
   re-bindings.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 10:21:03 +03:30
Kacpherek ffd04438e3 Merge remote-tracking branch 'origin/main' into fix/split-maps-villager-trades 2026-10-30 06:32:12 +01:00
Kacpherek 237b58d952 Move the villager trade display out of this PR
The villager changes (crossed arms item, held offers, equipment sync) now
live in their own branch fix/split-villager-trades; this one keeps the map
banner and frame markers.
2026-10-30 06:32:12 +01:00
Kacpherek c38f433d59 Merge main into fix/split-maps-villager-trades
Keep main's held-stack API (set_main_hand, main_hand_stack) and bone meal
prediction; the villager's shown item now lives in the main hand stack, and
the equipment sync sends that stack.
2026-10-30 06:30:20 +01:00
VaspyyyandClaude Opus 5.5 df86a8dd4f Shader API: sprite tags and the weather at the camera for the material stage
Packs declare [[tags]] of block sprites (glass, say) and read them as
MaterialInput.tags; the frame gains precipitation, temperature, downfall
and wetness (rain smoothed as the shaders/ format's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-30 05:02:18 +00:00
Alexander KrylovandHermes Agent 5b2d5c7580 Launcher: an accent is any hue, not one of seven
Accent carried one of seven names; it carries a hue now, and none for the
violet, whose tokens stay the palette's. Settings shows a strip of hues
beside the swatches: a click or a drag picks any colour, the swatches stay
as shortcuts, settings.json keeps "violet"/"blue" for those and writes a
picked hue in degrees. The contrast test runs over hues nobody preset too.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:17:26 +03:00
martipopsandClaude Opus 5.5 10cc390c21 Audio: the mixer callback neither allocates nor copies world names
Review on #3777: worlds are small ids now (Mixer::world_id), so the callback compares and
copies integers; its camera list is a buffer the mixer keeps; a sound only looks for another
player's copy (and takes the lock for it) while another player shares the output.

Measured with 32 playing sounds and one player, 512-frame buffers (release, M-series Mac):
upstream main 21.9-22.5 us per buffer, this part before 21.3-21.8 us, after 21.3-21.6 us:
the same within noise; the time goes to mixing the samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops ad604ad630 Merge remote-tracking branch 'upstream/main' into split/10-profile-page 2026-10-29 23:07:35 -04:00
martipops d2ad22fe4c Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-29 23:07:35 -04:00
martipopsandClaude Opus 5.5 c7b5e41cbf Merge upstream main into the profile page
Upstream moved page titles and tooltips to translation keys: the profile page gets its own
(horizon.pages.split_profiles).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:44:48 -04:00
martipopsandClaude Opus 5.5 63325c9928 Audio: a shared output keeps the device name upstream added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:39:33 -04:00
martipops fbb1d24b53 Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-29 21:38:21 -04:00
SenjinandClaude Opus 5.5 c5ffee73bf HDR: lightning, beacons, end crystals, blazes, fireworks, orbs and glow frames shine
Light that isn't a block gets HDR output's tier too (entity.wgsl fs_hdr_mark):

- The additive pipelines marked nothing: lightning and the dragon's eyes and death
  rays get mark passes of their own (vs_lightning passes the tier on as vs_entity
  does). Lightning and the death rays very bright, the dragon's eyes bright.
- End crystals, their healing beam and beacon beams bright. The mark pass now wraps
  a beam's v in its sprite as fs_entity does, or it sampled transparent texels.
- Blazes bright, magma cubes and glow squid low, entity flames incandescent.
- Fireworks (sparks and the burst's flash) bright: translucent-layer particles
  carry a tier and get a mark pass after their draw.
- Experience orbs very bright, glow item frames incandescent (the item in one stays
  only lit).
- The overlay is the layer's low 16 bits everywhere (the entity batch's materials,
  the shader packs' plain-run and hurt-colour checks), so a tier never changes how
  anything draws, with HDR on or off.

Tuned in game on an AW3423DW (Senjin). HDR off: nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:43:59 +01:00
levzzz 4c08d3e128 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-30 03:08:41 +05:00
levzzz 4e6c5fc676 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates
# Conflicts:
#	launcher/src/native_mods.rs
2026-10-30 03:00:27 +05:00
levzzzandClaude Opus 5.5 166ce92969 Launcher: Polish for the native build steps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:54:41 +05:00
levzzz 87ff3f24b1 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-30 02:53:20 +05:00
levzzzandClaude Opus 5.5 d6653e2ee5 Launcher: horizon-mixins at its merged 0.6.0 (horizon-mixins#5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:53:20 +05:00
DUzzL 55b70b104f bots: add a fight behaviour (survival bots that attack the nearest mob and each other) 2026-10-29 22:48:06 +01:00
DUzzL c8144e531e Renderer: take a software adapter only after every backend failed (#3978) 2026-10-29 22:47:40 +01:00
levzzzandClaude Opus 5.5 263ca3d80a Docs: a native mod crate's features apply to the game too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:32:05 +05:00
levzzzandClaude Opus 5.5 80c9404cf4 Launcher: native mods' own crates, Horizon's lock as their allow list, post-build steps
A bundle's [[crate]] (staged by horizon-mixins 0.6.0 as native-crates/<mod>/<crate>/)
may use only the registry crates Horizon's Cargo.lock has: the build checks the staged
lock's downloaded packages against Horizon's and fetches with --locked, saying plainly
when a feature would add a crate. After the game compiles, each [[post_build]] bin is
built with the same profile and run in the new version folder with
HORIZON_NATIVE_{MOD,VERSION_DIR,DATA_DIR,CACHE_DIR}; a failure fails the build.
Progress steps are translated. MIXINS_REV moves to horizon-mixins#5's head.

A test bundle (launcher/tests/fixtures/native-crate-demo) uses all of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:24:24 +05:00
Kacpherek 65967c4324 Make the maps-villager-trades fixes build on current main 2026-10-29 22:18:23 +01:00
DUzzL 9a8455d017 Launcher: copy ATLauncher instances into Java instances 2026-10-29 22:16:46 +01:00
levzzzandClaude Opus 5.5 6f9a766556 Native mods: native_data! for bundles' data files, hook points API 3
native_data!("<mod>", "file") compiles a file of a bundle's data/ folder
(staged as native-data/<mod>/ by horizon-mixins) into the game;
native_built_data is the folder a mod's post-build step wrote into. A
.hnmod may carry a crate's own data/ folder too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:08:34 +05:00
Kacpherek 06e43b14df List a map's id, lock and scale in its tooltip
A filled map's tooltip showed none of its lines. MapId.addToTooltip gives Unknown Map when the client has no data for it, else the id line (not for a named map or one a cartography table is locking or scaling), Locked, and with advanced tooltips the scale and level, a scaling copy counting one more up to 4 (MapId.java:27). The lines sit between the instrument and bees lines as in ItemStack.addDetailsToTooltipComponents.
2026-10-29 22:06:48 +01:00
Kacpherek 12a8cc20b4 Clear a map's frame marker when its item frame loses the map
Taking a map out of an item frame, breaking the frame, a blast or /kill left the frame's marker and saved entry on the map. ItemFrame.removeFramedMap calls MapItemSavedData.removedFromFrame(pos, entityId) on every path that empties the frame (dropItem unless the drops are off and something caused it, onKilled), which removes the marker and the frame entry (MapItemSavedData.java:421, ItemFrame.java:247).
2026-10-29 22:06:48 +01:00
Kacpherek 811545ed8d Mark a framed map's item frame on the map
A map hanging in an item frame never showed the frame's marker, and a player who saw it framed without carrying it kept a marker. ServerEntity.sendChanges ticks every 10 ticks tickCarriedBy(player, stack, frame) for each player: one carrying no copy loses their marker, and with position tracking the frame's marker is added under its entity and the frame saved with its block, rotation (2D direction times 90) and entity, replacing an older entity's marker at the same block (MapItemSavedData.java:159).
2026-10-29 22:06:48 +01:00
Kacpherek 079b4bea68 Hide players in map invisibility gear from others' maps
A player wearing a carved pumpkin was still drawn on other players' maps. MapItemSavedData.tickCarriedBy removes the marker of every other holder for whom hasMapInvisibilityItemEquipped finds a piece of #map_invisibility_equipment in an armor slot (MapItemSavedData.java:225). Each holder keeps what it wore on its own tick.
2026-10-29 22:06:48 +01:00
Kacpherek 4941dd40b2 Drop a map's banner marker when its banner is gone
A banner marker stayed on the map after its banner was broken or changed. MapItem.update calls MapItemSavedData.checkBanners for every column it reads: a marker in that column whose banner is no longer the saved one (colour, name or the banner itself) loses its decoration and its entry (MapItemSavedData.java:401).
2026-10-29 22:06:48 +01:00
Kacpherek 910d5479eb Mark a banner on the map when the map is used on it
A map used on a placed banner did nothing. MapItem.useOn toggles that banner's marker through toggleBanner: SUCCESS swings and counts as a use, FAIL (the map refuses) ends the use quietly, and the item is never placed or used otherwise (MapItem.java:336).
2026-10-29 22:06:48 +01:00
Kacpherek 955214ae1b Toggle a banner's marker on a map
A map could not add or drop a banner marker on request. MapItemSavedData.toggleBanner works only for a banner inside the map: one whose marker stands as it is loses it, otherwise the marker is added (renewed when the banner changed) unless the map already tracks more than 256 markers (MapItemSavedData.java:371, MapDecorationType.trackCount).
2026-10-29 22:06:48 +01:00
Kacpherek e108d1d57f Read a placed banner as a map marker source
A map had no way to ask what banner stands at a position. MapBanner.fromWorld returns the banner block entity's base colour (the block's dye colour) and custom name, or nothing when no banner is there (MapBanner.java:25).
2026-10-29 22:06:48 +01:00
Kacpherek a5b45dead5 Load and save map banner and frame markers as vanilla does
A map kept its banners and frames lists as raw tags, so the markers never showed. MapItemSavedData's constructor adds a banner marker in the banner's dye colour (MapBanner.getDecoration, the 16 banner decoration types) and a frame marker for each saved entry, without a level, and the lists are written back (MapBanner.CODEC, MapFrame.CODEC, MapItemSavedData.java:100). A locked copy keeps the banners and the decorations (MapItemSavedData.locked).
2026-10-29 22:06:48 +01:00
Kacpherek 5430fa0f6a Route map markers through addDecoration with vanilla keys
A map kept one marker per player and rebuilt the item ones, with no place for a banner or frame marker. MapItemSavedData.addDecoration files each MapDecoration under a key (player, banner, frame), keeps its place in the drawing order, clamps it to the map, swaps a player's arrow for the off-map ones and drops what falls outside (MapItemSavedData.java:253). Markers now carry their name and key, and the update packet sends the name.
2026-10-29 22:06:48 +01:00
Kacpherek e743e4e418 Cycle the items a villager holds up every 40 ticks
With two or more matching offers a villager swapped nothing: the held item never changed. Vanilla counts 40 ticks, then holds up the next display item and wraps to the first (ShowTradesToPlayer.displayCyclingItems); start resets the counter and index.
2026-10-29 22:06:47 +01:00
Kacpherek 837d409e31 Hold up the offer a villager sells for the item you hold
A villager looking at a player who holds a trade's cost now collects the offers in stock that take that item, holds up the first and keeps looking for 900 ticks instead of 40 (ShowTradesToPlayer.findItemsToDisplay, updateDisplayItems, displayFirstItem). The held stack keeps its count and enchantments and never drops (displayAsHeldItem). Offers not made yet are rolled by the level first.
2026-10-29 22:06:47 +01:00
Kacpherek 4ce5d55664 Empty a villager's hand when it stops showing trades
ShowTradesToPlayer clears the main hand every tick it has nothing to show and again when it stops, restoring the default 0.085 drop chance (ShowTradesToPlayer.clearHeldItem). Horizon left whatever the villager held in place.
2026-10-29 22:06:47 +01:00
Kacpherek c8a6cf4364 Let a villager brain ask the level to roll its offers
A brain tick holds only the mob, but AbstractVillager.getOffers makes the trades the first time they are asked for and that needs the level's trade data and random sequences. VillagerOffers has the game run ensure_offers for the villager, as the trading screen does.
2026-10-29 22:06:47 +01:00
Kacpherek 834cc83e85 Sync a mob's main hand enchantments to its viewers
SetEquipment carried only the main hand's item and count, so an enchanted item a mob holds (the offer a villager shows) never glinted on a client. Vanilla sends the whole stack (ClientboundSetEquipmentPacket); the server now adds the held enchantments from the gear table and the client keeps them there.
2026-10-29 22:06:47 +01:00
Kacpherek f8eebefe0a Draw the item a villager holds on its crossed arms
A villager's held item was never drawn: its model has no hand arms, so ItemInHandLayer found no hand. Vanilla's VillagerRenderer adds CrossedArmsItemLayer, which puts the main hand's item on the arms and shows it in the GROUND display context (CrossedArmsItemLayer.java), as the witch already does.
2026-10-29 22:06:43 +01:00
levzzz 24c84d13d5 Merge #3880 (arismoko: data files in native mod bundles) 2026-10-30 01:58:19 +05:00
levzzz b88b7976f6 Merge #3871 (arismoko: catalog versioned native hook points) 2026-10-30 01:58:19 +05:00
rdbtCVSandOpenAI Codex 2b46e411f5 Update zip to 9.1.3
Co-Authored-By: OpenAI Codex (gpt-6.1-terra) <[email protected]>
2026-10-29 18:21:18 +00:00
martipops 77682df7e9 Merge remote-tracking branch 'upstream/main' into split/10-profile-page
# Conflicts:
#	crates/horizon-client-render/src/lib.rs
#	crates/horizon-client/src/lib.rs
2026-10-29 13:35:38 -04:00
martipopsandClaude Opus 5.5 482adff394 Splitscreen: the profile page ("Who's playing?"), as a model
Part of the splitscreen series (#3541). The page a joining splitscreen player picks its
profile on, in horizon-client-render with its own tests, and the profile store it reads:
names kept in the game folder (splitscreen/profiles/<name>/), pages of them, New Profile...
(a player name: 3 to 16 letters, digits or underscores), forgetting one (hidden, its settings
kept), Cancel. Options pages it opens from grey Language and Resource Packs for another
player (the whole process's). Nothing opens it yet: the game side joins next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:11:16 -04:00
spiderandClaude Opus 5.5 572c5c4664 FSR 3.1 frame interpolation, ported to WGSL (frame generation 2/3)
AMD FidelityFX SDK 1.1.4's frame interpolation (MIT): dilated depth and camera
motion vectors, the estimated depths, the game and optical flow vector fields,
the disocclusion mask, the interpolation and the inpainting, as WGSL compute
passes. Not used by the game yet; the setting and the present thread follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:32:10 +01:00
martipopsandCodex 296603342f Allow sound engines to share one dimension-aware audio output
Select the nearest listener in each world, suppress duplicate world sounds, and clean up shared listeners and channels. Inaudible sources continue advancing. Part 2 of the splitscreen series; game integration follows separately.

Co-Authored-By: Codex <[email protected]>
2026-10-29 09:34:42 -04:00
dacrabandDeepSeek V4.1 Flash 1017 eea5bd0808 Launcher: a Linux AppImage that updates in place
Linux releases ship horizon-launcher-linux-x86_64.AppImage beside the
.tar.gz: one file to download, make executable and run, with the
libraries GPUI needs beside it. glibc, the graphics drivers and the
session's D-Bus stay the machine's (a driver cannot be packaged), so the
image needs glibc 2.35 or newer, like the .tar.gz. appimagetool 1.9.1
is pinned by SHA-256 and run extracted, so building needs no FUSE; the
image itself needs none either, as its runtime mounts it where FUSE
works and unpacks it where it does not.

Inside an AppImage, current_exe names a copy in the read-only mount, so
a launcher in one updates by replacing the file $APPIMAGE names, and
the menu entry it writes starts that file rather than the mount's copy.
The .tar.gz stays what every other launcher updates itself from.

Closes #2110.

Co-Authored-By: DeepSeek V4.1 Flash 1017 (River) <[email protected]>
2026-10-29 13:54:23 +02:00
GIGABAIT93andClaude Opus 5.5 7f8c190b29 horizon-prepare: name the game files one by one (#3545)
--version-json, --client-jar, --libraries-dir and --assets-dir name
the files a launcher keeps in a layout of its own (instance folders,
shared libraries and assets), each replacing only what --minecraft-dir
would give; every file is still checked against research-lock.json's
pins. GameFiles gains the libraries and asset store folders, and
Options the Inputs (Default in the launcher, so it is unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:47:51 +02:00
GIGABAIT93andClaude Opus 5.5 82cf8916e2 horizon-prepare: --complete and --progress json (#3545)
--complete prepares, copies the sounds, then fails unless --check would
pass: one command for resources with nothing missing (without the sounds
step the game is silent, which is easy to miss).

--progress json prints one JSON object a line: {"message": ...} for each
log line, {"fraction": F} as each of the preparation's 18 stages begins
and at the end, {"error": ...} on failure. prepare_with_progress takes
the fraction; prepare keeps its signature, and a test keeps STAGES equal
to the stages steps() opens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:39:47 +02:00
GIGABAIT93andClaude Opus 5.5 8dfd37428a horizon-prepare: name check's extras type for clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:37:10 +02:00
GIGABAIT93andClaude Opus 5.5 da7aa1f73b horizon-prepare: --output and --check (#3545)
--output DIR puts the prepared resources elsewhere than
<root>/.local/horizon-client/<version>, for preparing and for the asset
commands (sounds, panorama, fonts, packs, languages, now one add_assets
behind them); the exporters' scratch files stay in <root>/.local.

--check exits 0 when the resources are complete and current for this
preparer and 1 otherwise, printing each gap with the command that fills
it: the manifest's schema and Minecraft version, the OUTPUT_VERSION that
made them, and the extras the launcher completes (packs, panorama,
Unifont, languages, sounds). A preparation now stamps its resources with
its OUTPUT_VERSION (horizon-prepare.json), which the output digest
leaves out, so the stamp needs no new OUTPUT_VERSION.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:28:44 +02:00
socksproxandClaude Sonnet 5.5 40d30b411e launcher: let the player hide the partner banner on the Servers page too
One Hide, shown after the same three minutes on screen, now sits on the Servers
banner as well as the sidebar card, and hiding either hides both for good.
The on-screen timer counts on every page, so a player who lives in the Servers tab
reaches the Hide too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 04:00:55 +01:00
AHHhHHHHH-HandClaude Sonnet 5.5 32f99ed5f7 LOD Distance: a slider up to the whole world, with heightmap far tiles
The LOD Distance button becomes a slider from 64 chunks to 4194304 (the whole world). Far terrain plans up to 22 levels with larger cells past 1024 chunks, meshes tiles from level 6 up as heightmaps, makes a very wide face one scaled quad instead of thousands, and bounds the clip subdivision of coarse tiles around full-detail chunks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 21:50:15 -03:00
i114rkandI114rk's AI agent 358b211e97 Boot loading screen: the window paints black with the wordmark, tagline and live console lines before the renderer's first frame
Co-Authored-By: I114rk's AI agent (OpenCode) <[email protected]>
2026-10-28 17:04:55 +03:00
Kacpherek 8d680ba46a Launcher on Windows: delete the binary an update moved aside
The next start removes horizon-launcher.exe.old-* beside it. The launcher
that just restarted into the new binary is still exiting, so it keeps
trying for a few seconds in the background; a file still in use (another
launcher window) is left for a later start.
2026-10-28 13:57:08 +01:00
Kacpherek 9130faeae9 Launcher on Windows: update by renaming the running file aside, not self_replace
self_replace copied the launcher under a random hidden name
(.horizon-launcher.<random>.__relocated__.exe), started that copy to wait
for the launcher to exit and delete it, and deleted the copy through a
hidden cmd.exe. Microsoft Defender quarantines that self-deleting pattern
as Trojan:Script/Wacatac.H!ml (#3175).

Windows lets a running binary be renamed, so the running launcher now
steps aside to horizon-launcher.exe.old-<pid>-<n> in its own folder and
the new binary takes its path. No copy is started, and the crate (with
its start-up hook that ran in every launch) is gone from the build.
2026-10-28 13:57:08 +01:00
811 changed files with 177443 additions and 4964 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+15 -60
View File
@@ -1,7 +1,7 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
@@ -24,10 +24,9 @@ env:
HORIZON_TELEMETRY_URL: "off"
jobs:
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
# verify runs always() and fails unless all three passed.
python:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -37,6 +36,13 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
@@ -52,30 +58,16 @@ jobs:
fi
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
clippy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify-clippy
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
@@ -84,32 +76,7 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
@@ -121,18 +88,6 @@ jobs:
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
verify:
if: always()
needs: [python, clippy, tests]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Did python, clippy and tests pass?
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "python, clippy, tests: $RESULTS"
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+14 -2
View File
@@ -8,6 +8,9 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
# itself instead (launcher/src/backend.rs).
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
@@ -19,7 +22,7 @@ on:
push:
branches: [main, launcher, 'launcher-*']
paths:
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
@@ -29,6 +32,8 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- 'packaging/linux/**'
- 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -161,7 +166,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
@@ -254,6 +259,13 @@ jobs:
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
if [[ "$RUNNER_OS" == "Linux" ]]; then
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
# player downloads and double-clicks, and it replaces itself instead
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
fi
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
+55
View File
@@ -0,0 +1,55 @@
name: native hook stamp
# Opens a PR that numbers pending fragments after they merge. Only pushes to
# main run main's own tool; fragments are read as data, not PR code.
on:
push:
branches: [main]
paths:
- 'tools/native-hook-fragments/*.json'
permissions:
contents: read
concurrency:
group: native-hook-stamp
cancel-in-progress: false
jobs:
stamp:
if: github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Open a stamp pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
branch=automation/native-hook-stamp
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
echo "A stamp pull request is already open."
exit 0
fi
python3 tools/stamp_native_hooks.py
if git diff --quiet; then
echo "No fragments to stamp."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -b "$branch"
git add -u
git commit -m 'Stamp native hook API fragments'
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
+1
View File
@@ -21,6 +21,7 @@
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/debug-profile.json
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
+6 -2
View File
@@ -2,7 +2,7 @@
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
@@ -136,5 +140,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
Generated
+20 -28
View File
@@ -118,15 +118,6 @@ dependencies = [
"libc",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -605,17 +596,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -914,8 +894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
@@ -1201,6 +1183,7 @@ dependencies = [
"cpal",
"flate2",
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1371,14 +1354,17 @@ dependencies = [
name = "horizon-server"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-core",
"horizon-release-sign",
"horizon-server-files",
"libc",
"serde",
"serde_json",
"sha1",
"sha2",
"toml",
"toml_edit",
@@ -1416,8 +1402,10 @@ dependencies = [
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"naga",
"png",
"zip",
]
@@ -1963,8 +1951,6 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
@@ -3475,6 +3461,12 @@ version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4672,18 +4664,18 @@ dependencies = [
[[package]]
name = "zip"
version = "2.4.2"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"thiserror 2.0.21",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
+3 -1
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
@@ -59,6 +59,8 @@ large_enum_variant = "allow"
# Drop this and the folder once a wgpu release has these fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+1 -1
View File
@@ -111,7 +111,7 @@ What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md)
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). 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). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
+3 -3
View File
@@ -742,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
</details>
#### AppImage and a Nix flake
#### A Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
Install Horizon on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
</details>
+11 -4
View File
@@ -8,6 +8,12 @@ developers, with upstream fixes applied
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
`third_party/naga` is [naga](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs developers, with
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -23,13 +29,14 @@ the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
+99
View File
@@ -0,0 +1,99 @@
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
//!
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
//! first; a swing without an attack (a miss, a block) is fine.
/// What breaks vanilla's way of attacking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Foul {
/// A client tick with more attacks than swings: how many more.
NoSwing(u32),
/// A second entity attacked in the same client tick: the first one and this one.
TwoTargets(i32, i32),
}
/// The attacks of the client tick being heard.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Attacks {
/// The entity this tick attacked first.
target: Option<i32>,
attacks: u32,
swings: u32,
}
impl Attacks {
/// ServerboundAttackPacket on entity `target`.
pub fn attack(&mut self, target: i32) -> Option<Foul> {
self.attacks = self.attacks.saturating_add(1);
let first = *self.target.get_or_insert(target);
(first != target).then_some(Foul::TwoTargets(first, target))
}
/// ServerboundPunchPacket: the arm swung.
pub fn swing(&mut self) {
self.swings = self.swings.saturating_add(1);
}
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
/// target again.
pub fn tick_end(&mut self) -> Option<Foul> {
let unswung = self.attacks.saturating_sub(self.swings);
*self = Self::default();
(unswung > 0).then_some(Foul::NoSwing(unswung))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_vanilla_tick_is_clean() {
let mut a = Attacks::default();
// Two clicks in one tick on the same entity, each swung for.
for _ in 0..2 {
assert_eq!(a.attack(7), None);
a.swing();
}
assert_eq!(a.tick_end(), None);
// The next tick may attack another entity, swinging first (an older client).
a.swing();
assert_eq!(a.attack(8), None);
assert_eq!(a.tick_end(), None);
// A swing on its own (a miss, a block) is fine.
a.swing();
assert_eq!(a.tick_end(), None);
}
#[test]
fn an_attack_not_swung_for_is_caught() {
let mut a = Attacks::default();
a.attack(7);
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
// A swing in the tick before doesn't count for this one.
a.swing();
a.tick_end();
a.attack(7);
a.attack(7);
a.swing();
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
}
#[test]
fn two_entities_in_one_tick_are_caught() {
let mut a = Attacks::default();
a.attack(7);
a.swing();
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
a.swing();
// Still against the tick's first target.
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
a.swing();
assert_eq!(a.tick_end(), None);
}
}
+37 -17
View File
@@ -2,17 +2,21 @@
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
//!
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
//! Any threshold at 0 turns that action off.
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
//! their own (`combat`); the others log only until they have shown no false positive with real
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
//! player is sent back where the server last had them); from `alert` on, online operators and the
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
//! that action off.
//!
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
//! do, never from another anticheat's code.
use serde::{Deserialize, Serialize};
pub mod combat;
pub mod prediction;
pub mod timer;
@@ -72,7 +76,7 @@ macro_rules! checks {
}
)*
/// `server.toml`'s `[anticheat]`: off by default.
/// `server.toml`'s `[anticheat]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
@@ -89,7 +93,7 @@ macro_rules! checks {
impl Default for Config {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
@@ -120,7 +124,8 @@ checks! {
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
tolerance_ms: f64 = 300.0,
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
@@ -129,9 +134,10 @@ checks! {
}
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back.
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
@@ -141,11 +147,18 @@ checks! {
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
/// until the moves agree again.
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
/// `kick = 60`.
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
}
/// How many checks there are.
@@ -219,6 +232,8 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
impl Player {
@@ -262,12 +277,17 @@ mod tests {
}
#[test]
fn off_by_default_and_without_a_section() {
assert!(!Config::default().enabled);
fn on_by_default_but_only_combat_acts() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(!c.runs(Check::Timer));
assert!(!c.runs(Check::Ground));
for &check in Check::ALL {
assert!(c.runs(check), "{}", check.name());
let a = c.actions(check);
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
assert_eq!(acts, check == Check::Combat, "{}", check.name());
}
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
}
#[test]
+10 -10
View File
@@ -16,6 +16,7 @@ use std::{
sync::{Arc, Mutex},
};
mod component_predicate;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
ExtendedView,
KeybindDown(String),
ViewEntity,
/// A `component` predicate, evaluated by the host.
/// A single `component` predicate; external predicate types use the host.
Component(Value),
}
impl ConditionProperty {
@@ -651,13 +652,7 @@ impl ConditionProperty {
"minecraft:extended_view" => Self::ExtendedView,
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
"minecraft:view_entity" => Self::ViewEntity,
"minecraft:component" => {
let predicate = required(m, "predicate")?;
if object(predicate)?.len() != 1 {
return Err(err("a single component predicate is expected"));
}
Self::Component(predicate.clone())
}
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
_ => return Err(err("unknown condition property")),
})
}
@@ -964,10 +959,14 @@ pub trait ItemEnv {
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
0.0
}
/// `ItemStack.getMaxStackSize` when the prototype has no component.
/// Additional component predicate types supplied by the host.
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
false
}
/// Membership in the level's enchantment registry tags.
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
false
}
fn is_keybind_down(&self, _keybind: &str) -> bool {
false
}
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
ConditionProperty::Component(predicate) => {
self.env.component_matches(self.stack, predicate)
component_predicate::matches(predicate, self.components, self.env)
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
}
}
}
@@ -0,0 +1,177 @@
//! Component conditions used by enchantment-dependent item models.
use super::*;
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
/// (`predicate`) and its wrapped codec's separate `value` field.
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
let id = component_type(required(m, "predicate")?)?;
let mut value = required(m, "value")?.clone();
if matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
let mut entries = Vec::new();
for entry in list(&value)? {
let mut entry = object(entry)?.clone();
if let Some(selector) = get(&entry, "enchantments") {
let selector = match selector {
Value::Array(ids) => Value::Array(
ids.iter()
.map(ident)
.map(|r| r.map(Value::String))
.collect::<Result<_>>()?,
),
Value::String(name) if name.starts_with('#') => {
Value::String(format!("#{}", identifier(&name[1..])?))
}
_ => Value::String(ident(selector)?),
};
entry.insert("enchantments".into(), selector);
}
if let Some(levels) = get(&entry, "levels") {
let levels = if levels.is_object() {
let mut bounds = Map::new();
for key in ["min", "max"] {
if let Some(n) = get(object(levels)?, key) {
bounds.insert(key.into(), Value::from(int(n)?));
}
}
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
if bounds
.get("min")
.and_then(Value::as_i64)
.zip(bounds.get("max").and_then(Value::as_i64))
.is_some_and(|(min, max)| min > max)
{
return Err(err("minimum exceeds maximum"));
}
Value::Object(bounds)
} else {
Value::from(int(levels)?)
};
entry.insert("levels".into(), levels);
}
entries.push(Value::Object(entry));
}
value = Value::Array(entries);
}
Ok(Value::Object(Map::from_iter([(id, value)])))
}
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
/// all entries must match; each entry needs one selected, present enchantment
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
if !matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
return None;
}
let Some(have) = components.get(id).and_then(Value::as_object) else {
return Some(false);
};
Some(expected.as_array()?.iter().all(|entry| {
let Some(entry) = entry.as_object() else {
return false;
};
have.iter().any(|(id, level)| {
let level = level.as_i64().unwrap_or(0);
let selector = get(entry, "enchantments");
let selected = match selector {
None => true,
Some(Value::String(s)) => s
.strip_prefix('#')
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
_ => false,
};
selected
&& if selector.is_some() {
matches_enchantment(level, get(entry, "levels"))
} else {
level_matches(level, get(entry, "levels"))
}
})
}))
}
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
/// nonzero level even when its bounds accept zero.
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
level != 0 && level_matches(level, bounds)
}
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
match bounds {
None => true,
Some(Value::Number(n)) => n.as_i64() == Some(level),
Some(Value::Object(bounds)) => {
get(bounds, "min")
.and_then(Value::as_i64)
.is_none_or(|min| level >= min)
&& get(bounds, "max")
.and_then(Value::as_i64)
.is_none_or(|max| level <= max)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct Tags;
impl ItemEnv for Tags {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
enchantment == "minecraft:density" && tag == "horizon:mace"
}
}
#[test]
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
let components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
&NoEnv
),
Some(false)
);
}
#[test]
fn enchantment_selectors_use_the_hosts_registry_tags() {
let condition = json!({"predicate":"enchantments", "value":[
{"enchantments":"#horizon:mace", "levels":{"min":3}}
]});
let predicate = decode(condition.as_object().unwrap()).unwrap();
let mut components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
+216
View File
@@ -335,6 +335,177 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
/// A JOML `Matrix3f`: `m[column][row]`.
type Mat3 = [[f32; 3]; 3];
/// `Matrix3f.mul`: `a * b`.
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
std::array::from_fn(|c| {
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
})
}
/// `Matrix3f.transpose`.
fn transpose3(m: &Mat3) -> Mat3 {
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
}
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let rotation = [
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
];
std::array::from_fn(|c| {
let rc = rotation[c];
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
})
}
/// `Quaternionf.mul`: `a * b`.
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
[
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
]
}
/// `GivensParameters`: the half angle's sine and cosine.
#[derive(Clone, Copy)]
struct Givens(f32, f32);
impl Givens {
/// `GivensParameters.fromUnnormalized`.
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
Self(inv * sin_half, inv * cos_half)
}
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
fn quarter_pi() -> Self {
let half = std::f32::consts::FRAC_PI_4 / 2.0;
let sin = joml_sin(half);
Self(sin, cos_from_sin(sin, half))
}
fn inverse(self) -> Self {
Self(-self.0, self.1)
}
fn cos(self) -> f32 {
self.1 * self.1 - self.0 * self.0
}
fn sin(self) -> f32 {
2.0 * self.0 * self.1
}
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
fn quaternion(self, axis: usize) -> [f32; 4] {
let mut q = [0.0, 0.0, 0.0, self.1];
q[axis] = self.0;
q
}
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
fn matrix(self, axis: usize) -> Mat3 {
let (cos, sin) = (self.cos(), self.sin());
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
let mut m = [[0.0; 3]; 3];
m[axis][axis] = 1.0;
m[a][a] = cos;
m[b][b] = cos;
m[a][b] = sin;
m[b][a] = -sin;
m
}
}
/// `MatrixUtil.approxGivensQuat`.
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
let g = 3.0 + 2.0 * 2.0f32.sqrt();
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
if g * sin_half * sin_half < cos_half * cos_half {
Givens::unnormalized(sin_half, cos_half)
} else {
Givens::quarter_pi()
}
}
/// `MatrixUtil.qrGivensQuat`.
fn qr_givens(a1: f32, a2: f32) -> Givens {
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
if a1 < 0.0 {
std::mem::swap(&mut sin_half, &mut cos_half);
}
Givens::unnormalized(sin_half, cos_half)
}
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
/// `similarityTransform` (`R^T * m * R`).
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
*q = quaternion_mul(*q, g.quaternion(axis));
let r = g.matrix(axis);
*m = mul3(&transpose3(&r), &mul3(m, &r));
};
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
rotate(m, g, 2);
}
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
rotate(m, g, 1);
}
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
rotate(m, g, 0);
}
}
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
/// splits a matrix: translation, left rotation, scale, right rotation. The
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
let inv = 1.0 / m.0[3][3];
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
let mut b = mul3(&transpose3(&a), &a);
let mut right = [0.0, 0.0, 0.0, 1.0];
for _ in 0..5 {
step_jacobi(&mut b, &mut right);
}
right = normalize_quaternion(right);
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
let mut left = [0.0, 0.0, 0.0, 1.0];
let mut r = rotate3(&a, right);
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
left = quaternion_mul(left, g.quaternion(axis));
*r = mul3(&transpose3(&g.matrix(axis)), r);
};
let g = if x_small {
qr_givens(r[1][1], -r[1][0])
} else {
qr_givens(r[0][0], r[0][1])
};
qr(&mut r, g, 2);
let g = if x_small {
qr_givens(r[2][2], -r[2][0])
} else {
qr_givens(r[0][0], r[0][2])
};
qr(&mut r, g.inverse(), 1);
let g = if y_small {
qr_givens(r[2][2], -r[2][1])
} else {
qr_givens(r[1][1], r[1][2])
};
qr(&mut r, g, 0);
let scale = [r[0][0], r[1][1], r[2][2]];
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
let [x, y, z, w] = right;
(translation, left, scale, [-x, -y, -z, w])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -355,6 +526,51 @@ mod tests {
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn svd_decomposes_like_matrix_util() {
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
let parts = |m: [f32; 16]| {
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
[bits(&t), bits(&l), bits(&s), bits(&r)]
};
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
let scaled = [
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(scaled),
[
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
vec![0u32, 0, 0, 0x3f80_0000],
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let quarter_turn = [
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(quarter_turn),
[
vec![0x3f00_0000u32, 0, 0],
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let sheared = [
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
];
assert_eq!(
parts(sheared),
[
vec![0u32, 0, 0x3fc0_0000],
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
]
);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
+366 -40
View File
@@ -6,6 +6,12 @@
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! `--behaviour fight` is the exception: the bots play in survival (`gamemode=survival`, `force-gamemode=true`,
//! and `allow-flight=true` because they hold their spawn height instead of standing on terrain they cannot
//! see). They track the entities the server sends, walk to the nearest living one and attack it at vanilla's
//! bare-hand cooldown (every 5 ticks). With `--pvp true` (the default) the other bots are targets too, which
//! hurts only when the server has `pvp=true`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
@@ -23,7 +29,7 @@ use horizon_protocol::{
buf::{Reader, Writer},
connection::{ConnWriter, Connection, Counters},
crypto::offline_uuid,
ids,
entity_data, ids,
item::Stack,
packets::{
Empty, configuration,
@@ -34,7 +40,7 @@ use horizon_protocol::{
};
use serde_json::json;
use std::{
collections::HashSet,
collections::{BTreeMap, HashSet},
sync::{
Arc, LazyLock, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
@@ -48,7 +54,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
--behaviour B idle, walk, explore, fight or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
fight needs gamemode=survival, force-gamemode=true and allow-flight=true.
stand: stays on the ground where the server puts it (/tp), for a survival test.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
@@ -63,9 +70,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
--no-read stop reading from the server once spawned (a client that lets the
@@ -75,6 +85,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--view-distance N what the bots ask for; default 10
--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
--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
@@ -96,6 +107,7 @@ enum Behaviour {
Stand,
Walk,
Explore,
Fight,
FloodMove,
FloodAcks,
FloodPackets,
@@ -115,6 +127,7 @@ impl Behaviour {
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::Fight => "fight",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
@@ -139,9 +152,11 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
no_swing: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -172,8 +187,10 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
pvp: true,
attack: None,
attack_after: 5.0,
no_swing: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -197,6 +214,10 @@ fn options() -> Result<Options, String> {
o.no_read = true;
continue;
}
if arg == "--no-swing" {
o.no_swing = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -222,6 +243,7 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -285,10 +307,11 @@ fn options() -> Result<Options, String> {
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
const BEHAVIOURS: [&str; 15] = [
const BEHAVIOURS: [&str; 16] = [
"idle",
"walk",
"explore",
"fight",
"mixed",
"stand",
"flood-move",
@@ -304,7 +327,7 @@ fn options() -> Result<Options, String> {
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
"--behaviour {}: use idle, walk, explore, fight, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -325,6 +348,10 @@ struct Bot {
pulled_back: AtomicU64,
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
waited: AtomicU64,
/// Attack packets sent (fight bots and --attack).
attacks: AtomicU64,
/// The entities the server tells the bot about (fight bots only).
entities: Mutex<Entities>,
/// The chunks the bot holds (ClientChunkCache).
held: Mutex<HashSet<(i32, i32)>>,
/// Where the server last put the bot (a teleport), to start from.
@@ -335,9 +362,8 @@ struct Bot {
counters: OnceLock<Arc<Counters>>,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<(Option<u128>, Option<i32>)>,
attacks: AtomicU64,
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<Vec<(u128, Option<i32>)>>,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
@@ -390,6 +416,142 @@ impl Random {
}
}
/// Vanilla's bare-hand attack speed is 4 a second: a full-strength swing every 20 / 4 ticks.
const ATTACK_COOLDOWN_TICKS: u32 = 5;
/// How far, in blocks, a fight bot strikes (the survival entity interaction range is 3).
const ATTACK_REACH: f64 = 2.5;
/// A fight bot walks up to this horizontal distance, so it does not stand inside its target.
const CLOSE_IN: f64 = 1.5;
/// Walking speed in blocks a tick.
const FIGHT_STEP: f64 = 0.215;
/// `LivingEntity.DATA_HEALTH_ID`: only living entities carry it, and it is sent once it differs from 1.
const HEALTH_DATA: u8 = 9;
/// What a fight bot knows of one entity.
#[derive(Clone, Debug, PartialEq)]
struct Tracked {
uuid: u128,
pos: [f64; 3],
/// Set once the server sends the health of the entity, which only living entities have.
health: Option<f32>,
}
/// The entities around a bot, from AddEntity, the move packets, SetEntityData and RemoveEntities.
/// Ordered by id, so ties between equally near targets always go to the same entity.
#[derive(Default)]
struct Entities(BTreeMap<i32, Tracked>);
impl Entities {
fn update(&mut self, id: i32, body: &[u8]) {
let Ok(packet) = play::Clientbound::decode(id, body) else {
return;
};
match packet {
play::Clientbound::AddEntity(p) => {
self.0.insert(
p.id,
Tracked {
uuid: p.uuid,
pos: p.position,
health: None,
},
);
}
play::Clientbound::MoveEntityPos(play::MoveEntityPos(m))
| play::Clientbound::MoveEntityPosRot(play::MoveEntityPosRot(m)) => {
if let (Some(e), Some(delta)) = (self.0.get_mut(&m.id), &m.delta) {
e.pos = delta.end(e.pos);
}
}
play::Clientbound::EntityPositionSync(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
e.pos = p.path.end();
}
}
play::Clientbound::SetEntityData(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
for (index, _, value) in &p.values {
if *index == HEALTH_DATA
&& let entity_data::Value::Float(health) = value
{
e.health = Some(*health);
}
}
}
}
play::Clientbound::RemoveEntities(p) => {
for id in p.ids {
self.0.remove(&id);
}
}
_ => {}
}
}
/// The nearest living entity that is not dead; other bots only when `pvp`.
fn nearest(&self, from: [f64; 3], pvp: bool, bots: &HashSet<u128>) -> Option<(i32, [f64; 3])> {
let mut best: Option<(f64, i32, [f64; 3])> = None;
for (id, e) in &self.0 {
if !e.health.is_some_and(|h| h > 0.0) || (!pvp && bots.contains(&e.uuid)) {
continue;
}
let d = distance(from, e.pos);
if best.is_none_or(|(b, ..)| d < b) {
best = Some((d, *id, e.pos));
}
}
best.map(|(_, id, pos)| (id, pos))
}
}
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()
}
/// 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> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
let step = FIGHT_STEP.min(horizontal - CLOSE_IN);
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).
fn walk_step(
pos: &mut [f64; 3],
target: &mut [f64; 3],
spawn: [f64; 3],
cruise: f64,
random: &mut Random,
) {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
*target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -645,6 +807,7 @@ fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -701,11 +864,15 @@ fn read(
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
let mut target = lock(&bot.target);
if target.0 == Some(p.uuid) {
target.1 = Some(p.id);
for target in lock(&bot.target).iter_mut() {
if target.0 == p.uuid {
target.1 = Some(p.id);
}
}
}
if track {
lock(&bot.entities).update(id, &body);
}
Ok(())
}
cb::SET_HEALTH => {
@@ -758,6 +925,16 @@ fn read(
}
_ => Ok(()),
},
cb::MOVE_ENTITY_POS
| cb::MOVE_ENTITY_POS_ROT
| cb::ENTITY_POSITION_SYNC
| cb::SET_ENTITY_DATA
| cb::REMOVE_ENTITIES
if track =>
{
lock(&bot.entities).update(id, &body);
Ok(())
}
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
@@ -787,6 +964,7 @@ fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bots: Arc<HashSet<u128>>,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
@@ -802,11 +980,17 @@ fn run_bot(
let (reader, writer) = connection.split();
let _ = bot.counters.set(reader.counters.clone());
let writer = Arc::new(Mutex::new(writer));
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
*lock(&bot.target) = o
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
thread::spawn(move || read(reader, writer, bot));
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -857,7 +1041,13 @@ fn run_bot(
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + o.height;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
let cruise = if behaviour == Behaviour::Fight {
spawn[1]
} else {
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -911,22 +1101,22 @@ fn run_bot(
} else {
match behaviour {
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
Behaviour::Walk => walk_step(&mut pos, &mut target, spawn, cruise, &mut random),
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) {
// 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)));
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
} else {
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
since_attack = since_attack.saturating_add(1);
walk_step(&mut pos, &mut target, spawn, cruise, &mut random);
}
}
Behaviour::FloodMove
@@ -1012,13 +1202,16 @@ fn run_bot(
send(body);
}
}
let target = lock(&bot.target).1;
if let Some(id) = target
&& joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
{
send(play::Serverbound::Attack(play::Attack { id }).encode());
bot.attacks.fetch_add(1, Ordering::Relaxed);
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
if !o.no_swing {
send(play::Serverbound::Punch(Empty).encode());
}
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
tick += 1;
let ok = send(
@@ -1026,7 +1219,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
// Fight bots stand on the ground; the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -1053,6 +1249,7 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"fight" => Behaviour::Fight,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
@@ -1083,14 +1280,23 @@ fn main() {
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
// The uuids of all bots, so that `--pvp false` leaves them alone.
let bot_uuids: Arc<HashSet<u128>> = Arc::new(
(0..o.bots)
.map(|i| offline_uuid(&format!("{}{i}", o.name)))
.collect(),
);
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
let bot_uuids = bot_uuids.clone();
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
let (behaviour, uuids) = (behaviour_of(&o.behaviour, i), bot_uuids.clone());
handles.push(thread::spawn(move || {
run_bot(o2, i, behaviour, uuids, bot, until);
}));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
@@ -1127,6 +1333,7 @@ fn main() {
"resent": total(|b| &b.resent),
"pulled_back": total(|b| &b.pulled_back),
"waited_ticks": total(|b| &b.waited),
"attacks": total(|b| &b.attacks),
})
);
previous_chunks = chunks;
@@ -1146,6 +1353,7 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
@@ -1153,6 +1361,7 @@ fn main() {
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"attacks": bots.iter().map(|b| b.attacks.load(Ordering::Relaxed)).sum::<u64>(),
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
"join_time": join_summary(
@@ -1205,6 +1414,123 @@ fn main() {
#[cfg(test)]
mod tests {
use super::*;
use play::{
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
let id = r.varint().unwrap();
entities.update(id, &bytes[bytes.len() - r.remaining()..]);
}
fn add(id: i32, uuid: u128, pos: [f64; 3]) -> Clientbound {
Clientbound::AddEntity(AddEntity {
id,
uuid,
kind: 1,
position: pos,
velocity: [0.0; 3],
pitch: 0,
yaw: 0,
head_yaw: 0,
data: 0,
})
}
fn health(id: i32, health: f32) -> Clientbound {
Clientbound::SetEntityData(SetEntityData {
id,
values: vec![(HEALTH_DATA, 3, entity_data::Value::Float(health))],
})
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
feed(&mut e, &add(1, 10, [3.0, 64.0, 0.0]));
feed(&mut e, &add(2, 11, [5.0, 64.0, 0.0]));
feed(&mut e, &health(2, 20.0));
// Entity 1 never sent a health (an item, an arrow), so it is no target.
assert_eq!(
e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()),
Some((2, [5.0, 64.0, 0.0]))
);
feed(&mut e, &health(2, 0.0));
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
feed(&mut e, &health(2, 4.0));
feed(
&mut e,
&Clientbound::RemoveEntities(RemoveEntities { ids: vec![2] }),
);
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
}
#[test]
fn moves_follow_the_deltas() {
let mut e = Entities::default();
feed(&mut e, &add(7, 1, [0.0, 64.0, 0.0]));
feed(&mut e, &health(7, 20.0));
feed(
&mut e,
&Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([4096, 0, -8192])),
rotation: None,
on_ground: true,
})),
);
assert_eq!(e.0[&7].pos, [1.0, 64.0, -2.0]);
}
#[test]
fn pvp_false_spares_the_bots_and_ties_go_to_the_lowest_id() {
let mut e = Entities::default();
feed(&mut e, &add(9, 100, [2.0, 64.0, 0.0]));
feed(&mut e, &add(4, 101, [-2.0, 64.0, 0.0]));
feed(&mut e, &add(5, 102, [0.0, 64.0, 6.0]));
for id in [9, 4, 5] {
feed(&mut e, &health(id, 20.0));
}
let bots = HashSet::from([100, 101]);
let from = [0.0, 64.0, 0.0];
assert_eq!(e.nearest(from, true, &bots).map(|t| t.0), Some(4));
assert_eq!(e.nearest(from, false, &bots).map(|t| t.0), Some(5));
}
#[test]
fn attacks_at_the_vanilla_cooldown() {
let mut pos = [0.0, 64.0, 0.0];
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))
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
hits.windows(2)
.all(|w| w[1] - w[0] == ATTACK_COOLDOWN_TICKS)
);
assert_eq!(hits.len(), 8);
}
#[test]
fn walks_into_reach_before_striking() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [10.0, 64.0, 0.0]);
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() {
first = Some(tick);
break;
}
}
assert!(first.is_some_and(|t| t > 30));
assert!((target.1[0] - pos[0]).abs() <= ATTACK_REACH);
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn percentiles_use_nearest_rank() {
+18
View File
@@ -57,6 +57,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/zh_cn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
@@ -66,6 +69,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -288,6 +294,16 @@ mod tests {
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let zh = Language::load(&[&empty], "zh_cn");
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
assert_eq!(zh.get("Preset"), Some("预设"));
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
// Not every bundle is complete: the en_us layer is in every map, so a
// missing key falls back to English (`horizon.mods.not_built` is not
// in ru_ru either).
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
@@ -296,6 +312,8 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let tr = Language::load(&[&empty], "tr_tr");
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
+56 -3
View File
@@ -118,6 +118,12 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
None => mod_effect(id),
}
}
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
/// without `minecraft:`.
#[inline]
pub fn is_mod_effect(id: &str) -> bool {
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
@@ -130,6 +136,28 @@ pub fn description_id(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("effect.{namespace}.{}", path.replace('/', "."))
}
/// The effect's sprite in the GUI atlas (`Hud.getMobEffectSprite`: the key
/// with `mob_effect/` before its path): `mob_effect/<path>` for vanilla's,
/// `<ns>:mob_effect/<path>` for a mod's.
pub fn sprite(id: &str) -> String {
let mut out = String::new();
push_sprite(&mut out, id);
out
}
/// [`sprite`], appended to `out`.
pub fn push_sprite(out: &mut String, id: &str) {
let path = match id.split_once(':') {
None => id,
Some(("minecraft", path)) => path,
Some((namespace, path)) => {
out.push_str(namespace);
out.push(':');
path
}
};
out.push_str("mob_effect/");
out.push_str(path);
}
/// Namespaced effect ids for mob effect storage; a mod's id already is.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
@@ -272,10 +300,15 @@ pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
}
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
/// their colours weighted by amplifier + 1, else the base potion colour.
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
for (e, amp) in effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
let w = amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
@@ -295,10 +328,28 @@ mod tests {
use super::*;
#[test]
fn description_ids_keep_the_namespace() {
fn description_ids_and_sprites_keep_the_namespace() {
assert_eq!(description_id("speed"), "effect.minecraft.speed");
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
assert_eq!(sprite("speed"), "mob_effect/speed");
assert_eq!(sprite("minecraft:speed"), "mob_effect/speed");
assert_eq!(sprite("ruby:curse/dark"), "ruby:mob_effect/curse/dark");
}
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
#[test]
fn effects_mix_their_colours_by_amplifier() {
let c = |id: &str| effect(id).unwrap().color;
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
let (p, s) = (c("poison"), c("speed"));
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
assert_eq!(
effects_color([("poison", 0), ("speed", 2)].into_iter()),
mix(16) | mix(8) | mix(0)
);
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
}
/// The only test that registers: the registry is set once per process.
@@ -320,6 +371,8 @@ mod tests {
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
assert!(!is_mod_effect(namespaced("speed")));
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
+1
View File
@@ -10,5 +10,6 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod window;
@@ -0,0 +1,232 @@
//! OS modifier reconciliation and modifiers synthesized by agent control.
use crate::{
keybinds::{KeyBinds, key_code},
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
};
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
(
ModifiersState::SHIFT,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
),
(
ModifiersState::CONTROL,
[KeyCode::ControlLeft, KeyCode::ControlRight],
),
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
(
ModifiersState::SUPER,
[KeyCode::SuperLeft, KeyCode::SuperRight],
),
];
/// A synthesized key updates the same modifier word as window events.
/// Releasing one side must preserve the other side's hold.
pub fn set_key(
modifiers: &mut ModifiersState,
physical: &mut HashSet<KeyCode>,
key: KeyCode,
down: bool,
) {
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
return;
};
if down {
physical.insert(key);
} else {
physical.remove(&key);
}
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
}
/// A modifier held for one synthesized click (the pad's Y, an agent's
/// shift-click): what [`restore`] puts back once the click is done.
#[derive(Clone, Copy, Debug)]
pub struct Held {
key: KeyCode,
was_down: bool,
bit: bool,
}
/// Holds `key` in both the physical keys and the modifier word, which is
/// what screens read (`InputWithModifiers.hasShiftDown`).
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
let bit = MODIFIERS
.iter()
.find(|(_, pair)| pair.contains(&key))
.is_some_and(|(bit, _)| modifiers.contains(*bit));
let held = Held {
key,
was_down: physical.contains(&key),
bit,
};
set_key(modifiers, physical, key, true);
held
}
/// Undoes [`hold`]: a key or bit that was already down stays down.
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
if !held.was_down {
physical.remove(&held.key);
}
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
modifiers.set(*bit, held.bit);
}
}
/// Recover missing modifier releases without releasing a rebound key or a pad.
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
pub fn reconcile(
modifiers: ModifiersState,
physical: &mut HashSet<KeyCode>,
bindings: &KeyBinds,
toggles: &mut ToggleKeys,
options: [bool; 2],
pad_held: [bool; 2],
) {
for (bit, pair) in MODIFIERS {
if modifiers.contains(bit) {
continue;
}
for key in pair {
if !physical.remove(&key) {
continue;
}
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
toggles.key(slot, KeyAction::Release, false, options[slot]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_taps_release_both_bits() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, pair) in MODIFIERS {
for key in pair {
set_key(&mut modifiers, &mut physical, key, true);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, key, false);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
}
}
}
#[test]
fn releasing_one_side_keeps_the_other_modifier_held() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, [left, right]) in MODIFIERS {
set_key(&mut modifiers, &mut physical, left, true);
set_key(&mut modifiers, &mut physical, right, true);
set_key(&mut modifiers, &mut physical, left, false);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, right, false);
assert!(modifiers.is_empty());
}
}
#[test]
fn a_held_click_restores_the_previous_state() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
assert!(modifiers.shift_key());
restore(&mut modifiers, &mut physical, held);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
// Right Shift already held, or the OS word set with no physical key.
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
let mut modifiers = ModifiersState::SHIFT;
let mut physical = start.clone();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
restore(&mut modifiers, &mut physical, held);
assert_eq!(modifiers, ModifiersState::SHIFT);
assert_eq!(physical, start);
}
}
#[test]
fn lost_releases_respect_rebindings_and_pad_holds() {
for slot in [SNEAK, SPRINT] {
for rebound in [false, true] {
for pad in [false, true] {
for toggle in [false, true] {
let mut bindings = KeyBinds::default();
let (name, modifier) = if slot == SNEAK {
("key.sneak", KeyCode::ShiftLeft)
} else {
("key.sprint", KeyCode::ControlLeft)
};
if rebound {
bindings.set(name, "key.keyboard.c");
}
let mut physical = HashSet::from([modifier]);
if rebound {
physical.insert(KeyCode::KeyC);
}
let mut toggles = ToggleKeys::default();
toggles.key(slot, KeyAction::Press, false, toggle);
let mut options = [false; 2];
options[slot] = toggle;
let mut pad_held = [false; 2];
pad_held[slot] = pad;
reconcile(
ModifiersState::ALT,
&mut physical,
&bindings,
&mut toggles,
options,
pad_held,
);
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
assert!(!physical.contains(&modifier));
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
}
}
}
}
}
#[test]
fn unrelated_modifier_change_does_not_release_an_action() {
let mut toggles = ToggleKeys::default();
toggles.key(SNEAK, KeyAction::Press, false, false);
reconcile(
ModifiersState::ALT,
&mut HashSet::new(),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
#[test]
fn lost_modifier_release_preserves_a_controller_toggle() {
let mut toggles = ToggleKeys::default();
toggles.pad_toggle(SNEAK, false);
reconcile(
ModifiersState::empty(),
&mut HashSet::from([KeyCode::ShiftLeft]),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
}
+55
View File
@@ -473,6 +473,37 @@ pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<Video
.collect()
}
/// The video modes of the window's monitor, listed once per monitor. Listing them takes tens of
/// milliseconds on Windows, and settings apply at every step of a dragged slider: listing them
/// each time made the slider stutter.
#[derive(Debug)]
pub struct VideoModeList<M = winit::monitor::MonitorHandle> {
listed: Option<M>,
}
impl<M> Default for VideoModeList<M> {
fn default() -> Self {
Self { listed: None }
}
}
impl<M: PartialEq> VideoModeList<M> {
/// The modes of `monitor` when it is not the monitor listed last, else `None` (the list
/// stands). No monitor lists none.
pub fn refresh(
&mut self,
monitor: Option<M>,
list: impl FnOnce(&M) -> Vec<VideoMode>,
) -> Option<Vec<VideoMode>> {
if monitor.is_some() && monitor == self.listed {
return None;
}
let modes = monitor.as_ref().map(list).unwrap_or_default();
self.listed = monitor;
Some(modes)
}
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
@@ -698,4 +729,28 @@ mod tests {
Some(Immediate)
);
}
#[test]
fn video_modes_are_listed_again_only_for_another_monitor() {
let mode = |width| VideoMode {
width,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let mut list = VideoModeList::default();
let mut listings = 0;
let mut modes = |monitor: &i32| {
listings += 1;
vec![mode(*monitor)]
};
assert_eq!(list.refresh(Some(1), &mut modes), Some(vec![mode(1)]));
assert_eq!(list.refresh(Some(1), &mut modes), None);
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(list.refresh(None, &mut modes), Some(vec![]));
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(listings, 3);
}
}
+2 -1
View File
@@ -33,6 +33,7 @@ glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
@@ -44,7 +45,7 @@ unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
@@ -235,6 +235,9 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
crate::native_emissive::load(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
+568 -54
View File
@@ -39,6 +39,12 @@ struct Channel {
looping: bool,
at: [f32; 3],
relative: bool,
/// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`).
owner: u8,
/// Its world (`Mixer::world_id`; 0 none).
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
}
#[derive(Default)]
struct Mixer {
@@ -46,6 +52,15 @@ struct Mixer {
listener: [f64; 3],
listener_yaw: f32,
listener_pitch: f32,
/// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is
/// heard from the nearest camera, and panned half as wide while several share the speakers.
listeners: HashMap<u8, ([f64; 3], f32, f32)>,
/// Each camera's world by owner, as ids: the callback compares and copies integers.
worlds: HashMap<u8, u32>,
/// World names by id - 1 (`world_id`).
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -54,6 +69,21 @@ struct Mixer {
#[cfg(feature = "audio")]
device_lost: bool,
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
impl Mixer {
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
Some(i) => i,
None => {
self.world_names.push(name.to_owned());
self.world_names.len() - 1
}
};
i as u32 + 1
}
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
#[derive(Clone, Debug, PartialEq)]
@@ -75,7 +105,34 @@ pub struct MixerBackend {
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
/// Whose channels these are (`Channel::owner`).
owner: u8,
world: u32,
/// How many other players share this output: alone, a sound never looks for its copy.
shares: Arc<std::sync::atomic::AtomicUsize>,
}
/// A splitscreen player's backend leaving: its camera and sounds go with it.
impl Drop for MixerBackend {
fn drop(&mut self) {
if self.owner == 0 {
return;
}
self.shares
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut m) = self.mixer.lock() {
m.listeners.remove(&self.owner);
m.worlds.remove(&self.owner);
let owner = self.owner;
m.channels.retain(|_, c| c.owner != owner);
}
}
}
/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out
/// again, so a player in a seat used before never reuses an id a departed one's decoder may
/// still attach to. The game's own backend counts from 0, as before.
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Two players' copies of one sound: started within this of each other.
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
@@ -176,6 +233,11 @@ pub struct Audio {
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
/// The ALSA cards (Linux) or Core Audio devices (macOS) last seen: listing
/// the output devices probes every one, so it waits for these to change
/// (`card_fingerprint`).
#[cfg(feature = "audio")]
cards_seen: Option<String>,
/// The other half of `lastSeenDevices`: every output device last seen.
#[cfg(feature = "audio")]
devices_seen: Vec<String>,
@@ -225,7 +287,59 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
}
Some((mono, rate))
}
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
/// player's game picks its own variant of the same event.
// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it.
fn variantless(key: &str) -> &str {
key.trim_end_matches(".ogg")
.trim_end_matches(|c: char| c.is_ascii_digit())
}
impl MixerBackend {
/// Splitscreen: another player's game started this same sound here just now (both clients
/// heard it); this copy stays stopped, the first is heard from the nearest camera.
///
/// A copy is recognised only by its file (without the variant), world, place and time: the
/// games pick variants on their own and play many sounds client-side (steps, digging), so
/// nothing marks two copies of one event. Two different events alike in all four (two
/// players' steps on grass, side by side, within 150 ms) are heard once. That is accepted:
/// at that distance and timing they sound like one; a doubled sound is the louder fault.
fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool {
// Alone: nobody else's copy, and no lock taken for it.
if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 {
return false;
}
let Ok(mut m) = self.mixer.lock() else {
return false;
};
// Alone (no other player's camera), or a sound by the listener (a click, music): its own.
let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else {
return false;
};
if m.listeners.is_empty() {
return false;
}
let key = format!("{namespace}:{path}");
let key = variantless(&key);
let at = c.at;
let world = c.world;
let twin = m.channels.values().any(|o| {
o.owner != self.owner
&& o.world == world
&& !o.relative
&& o.started
.as_ref()
.is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND)
&& (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::<f32>() < 4.0
});
if let Some(c) = m.channels.get_mut(&channel) {
if twin {
c.state = State::Stopped;
} else {
c.started = Some((key.to_owned(), std::time::Instant::now()));
}
}
twin
}
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
@@ -250,6 +364,9 @@ impl Backend for MixerBackend {
looping: false,
at: [0.0; 3],
relative: false,
owner: self.owner,
world: self.world,
started: None,
},
);
Some(id)
@@ -287,6 +404,10 @@ impl Backend for MixerBackend {
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
// A looping sound follows its entity in one client's tracking only: each keeps its own.
if !looping && self.duplicate(channel, (namespace, &path)) {
return;
}
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
@@ -395,28 +516,50 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
// and OpenAL's right = forward x up.
let (yaw, pitch) = (
f64::from(mixer.listener_yaw.to_radians()),
f64::from(mixer.listener_pitch.to_radians()),
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
))
.chain(
mixer
.listeners
.iter()
.map(|(&owner, &camera)| (owner, camera)),
)
.map(|(owner, (at, yaw, pitch))| {
(
at,
basis(yaw, pitch),
mixer.worlds.get(&owner).copied().unwrap_or_default(),
)
}),
);
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let shared = cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
@@ -427,27 +570,48 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let d = if c.relative {
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
} else {
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
f64::from(c.at[0]) - listener[0],
f64::from(c.at[1]) - listener[1],
f64::from(c.at[2]) - listener[2],
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
} else {
0.0
};
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [left, right] = if c.relative {
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
.map(|pan| gain * pan);
let [left, right] = [left, right].map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -538,6 +702,36 @@ fn open_device(
}
Ok((stream, config))
}
/// Try one output device for `open`: skip it when it was already tried, log it
/// when it opens, and keep its error when it does not.
#[cfg(feature = "audio")]
fn try_open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
tried: &mut Vec<String>,
errors: &mut Vec<String>,
) -> Option<cpal::Stream> {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
return None;
}
tried.push(name.clone());
match open_device(device, mixer) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
Some(stream)
}
Err(e) => {
errors.push(format!("{name}: {e}"));
None
}
}
}
/// The name of the system's default output device.
#[cfg(feature = "audio")]
fn default_output_name() -> Option<String> {
@@ -545,6 +739,42 @@ fn default_output_name() -> Option<String> {
.default_output_device()
.and_then(|d| d.name().ok())
}
/// A cheap fingerprint of what `output_devices` can list. On Linux, the ALSA
/// cards: the rest are `cpal`'s fixed names. On macOS, every Core Audio
/// device's name, inputs included: telling which of them play opens each one
/// (about 90 ms a second with twenty devices), naming them takes 2 or 3 ms.
/// `None` elsewhere, where the list is always looked at.
#[cfg(feature = "audio")]
fn card_fingerprint() -> Option<String> {
#[cfg(target_os = "linux")]
{
std::fs::read_to_string("/proc/asound/cards").ok()
}
#[cfg(target_os = "macos")]
{
let devices = cpal::default_host().devices().ok()?;
Some(
devices
.filter_map(|d| d.name().ok())
.collect::<Vec<_>>()
.join("\n"),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
/// Whether the output device list should be looked at again: only the ALSA
/// cards can add or remove an entry, and `None` means there is no fingerprint,
/// so the list has to be checked as before.
#[cfg(any(feature = "audio", test))]
fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
match (seen, cards) {
(Some(seen), Some(cards)) => seen != cards,
_ => true,
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
@@ -649,6 +879,17 @@ impl Audio {
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
#[cfg(feature = "audio")]
let cards = if live { card_fingerprint() } else { None };
// Where the cards gate the list (Linux, macOS), it starts empty: opening the
// output does not need it, and listing it probes every device; the first
// change fills it. Elsewhere it starts with every device listed, as before.
#[cfg(feature = "audio")]
let devices = if live && cards.is_none() {
output_devices()
} else {
Vec::new()
};
Self {
_stream: stream,
device_name: target_device.to_owned(),
@@ -660,6 +901,9 @@ impl Audio {
decoder: None,
next: 0,
live,
owner: 0,
world: 0,
shares: Arc::default(),
},
record_heard: false,
listener: [0.0; 3],
@@ -674,7 +918,9 @@ impl Audio {
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
devices_seen: if live { output_devices() } else { Vec::new() },
cards_seen: cards,
#[cfg(feature = "audio")]
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
}
@@ -741,29 +987,20 @@ impl Audio {
}
let host = cpal::default_host();
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
// The default device first: it is what plays, and listing the others
// probes every PCM, which is slow and prints ALSA's errors for the
// plugins this system lacks.
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => errors.push(format!("{name}: {e}")),
if let Some(device) = host.default_output_device()
&& let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors)
{
return Ok(stream);
}
// A default that cannot open (busy, or a stale ALSA default) should not
// leave the game silent when another device works.
for device in host.output_devices().into_iter().flatten() {
if let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors) {
return Ok(stream);
}
}
if errors.is_empty() {
@@ -777,15 +1014,75 @@ impl Audio {
backend: &mut self.backend,
}
}
/// World identity for this player's sources and camera. Existing channels keep their world.
pub fn set_world(&mut self, world: &str) {
if let Ok(mut mixer) = self.backend.mixer.lock() {
let id = mixer.world_id(world);
self.backend.world = id;
mixer.worlds.insert(self.backend.owner, id);
}
}
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
if self.backend.owner == 0 {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
} else {
m.listeners
.insert(self.backend.owner, (position, yaw, pitch));
}
}
}
/// Splitscreen: another player's sounds on this output, heard from its camera too
/// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go.
pub fn share(&self, owner: u8) -> Self {
assert_ne!(owner, 0, "shared audio owners must be nonzero");
self.backend
.shares
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut engine = SoundEngine::new(self.engine.registry.clone());
engine.loaded = self.backend.live;
Self {
_stream: None,
device_name: self.device_name.clone(),
engine,
backend: MixerBackend {
mixer: self.backend.mixer.clone(),
root: self.backend.root.clone(),
cache: self.backend.cache.clone(),
decoder: None,
next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1)
<< 40,
live: self.backend.live,
owner,
world: self.backend.world,
shares: self.backend.shares.clone(),
},
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
#[cfg(feature = "audio")]
cards_seen: self.cards_seen.clone(),
#[cfg(feature = "audio")]
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -810,6 +1107,11 @@ impl Audio {
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
// and the mixer's lost flag, to that output.
if self.backend.owner != 0 {
return false;
}
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
@@ -817,6 +1119,7 @@ impl Audio {
if lost {
log::info!("Horizon: audio device was lost");
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
@@ -826,6 +1129,17 @@ impl Audio {
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
}
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
@@ -1022,6 +1336,13 @@ impl Audio {
self.stop_instance(id);
self.release(id);
}
/// `SoundBufferLibrary.enumerate`: the decoded buffers kept and their bytes.
pub fn cache_stats(&self) -> (usize, usize) {
self.backend
.cache
.lock()
.map_or((0, 0), |c| (c.entries.len(), c.bytes))
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
@@ -1140,6 +1461,9 @@ mod tests {
looping,
at,
relative: false,
owner: 0,
world: 0,
started: None,
}
}
@@ -1297,6 +1621,185 @@ mod tests {
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
}
#[test]
fn splitscreen_sounds_are_heard_from_the_nearest_camera() {
// P1 far away, a second player beside the source: heard at full gain, centred.
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Mixer::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(
data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4,
"{data:?}"
);
// Alone, P1 would not hear it: 100 blocks past its 16.
m.listeners.clear();
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2]);
}
#[test]
fn a_shared_listener_registers_even_in_the_hosts_world() {
let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
host.set_world("minecraft:overworld");
let mut guest = host.share(1);
guest.set_world("minecraft:overworld");
guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0);
let id = guest.backend.create(false).unwrap();
let mixer = host.backend.mixer.lock().unwrap();
let overworld = mixer
.world_names
.iter()
.position(|w| w == "minecraft:overworld");
let overworld = overworld.map(|i| i as u32 + 1);
assert_eq!(mixer.worlds.get(&1).copied(), overworld);
assert_eq!(Some(mixer.channels[&id].world), overworld);
}
#[test]
fn cameras_in_other_dimensions_cannot_hear_a_source() {
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Default::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
let nether = m.world_id("nether");
m.worlds.insert(1, nether);
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(
data, [0.0; 2],
"nearby Nether camera must not hear Overworld source"
);
m.channels.get_mut(&1).unwrap().world = nether;
mix(&mut m, &mut data, 2, 48_000);
assert!(data[0] > 0.5, "same-world camera must hear source");
let end = m.world_id("end");
m.worlds.insert(1, end);
data.fill(0.0);
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2], "no listener remains in source's world");
}
#[test]
fn sounds_in_a_world_without_listeners_finish_while_inaudible() {
let mut mixer = Mixer::default();
let mut source = channel(vec![1.0; 4], None, [0.0; 3], false);
let nether = mixer.world_id("nether");
source.world = nether;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4]);
assert!(mixer.channels[&1].position > 0.0);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(mixer.channels[&1].state, State::Stopped);
mixer.worlds.insert(0, nether);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
variantless("minecraft:mob/creeper/say1.ogg"),
"minecraft:mob/creeper/say"
);
assert_eq!(
variantless("minecraft:step/grass6.ogg"),
"minecraft:step/grass"
);
assert_eq!(
variantless("minecraft:random/click.ogg"),
"minecraft:random/click"
);
}
#[test]
fn another_players_copy_of_a_sound_is_dropped() {
let mixer = Arc::new(Mutex::new(Mixer::default()));
// One other player shares the output.
let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1));
let backend = |owner| MixerBackend {
mixer: mixer.clone(),
root: PathBuf::new(),
cache: Arc::default(),
decoder: None,
next: (u64::from(owner)) << 40,
live: true,
owner,
world: 0,
shares: shares.clone(),
};
let (mut p1, mut p2) = (backend(0), backend(1));
// P2 is listening (its camera is in the mixer), so two players share the speakers.
mixer
.lock()
.unwrap()
.listeners
.insert(1, ([0.0; 3], 0.0, 0.0));
let started = |b: &mut MixerBackend, at: [f64; 3]| {
let id = b.create(false).unwrap();
b.set_position(id, at);
!b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg"))
};
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// P2's client got the same sound at the same place: one copy plays.
assert!(!started(&mut p2, [5.2, 64.0, 5.0]));
// Elsewhere, or P1's own second one, it is a sound of its own.
assert!(started(&mut p2, [40.0, 64.0, 5.0]));
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// Equal coordinates in another dimension describe a different sound.
p2.world = mixer.lock().unwrap().world_id("nether");
assert!(started(&mut p2, [5.0, 64.0, 5.0]));
// P2 leaving takes its sounds.
drop(p2);
assert!(
mixer
.lock()
.unwrap()
.channels
.values()
.all(|c| c.owner == 0)
);
}
#[test]
fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() {
let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
let mut guest = host.share(1);
guest.set_world("overworld");
guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0);
let old = guest.backend.create(false).unwrap();
drop(guest);
{
let mixer = host.backend.mixer.lock().unwrap();
assert!(!mixer.channels.contains_key(&old));
assert!(!mixer.listeners.contains_key(&1));
assert!(!mixer.worlds.contains_key(&1));
}
let mut replacement = host.share(1);
let new = replacement.backend.create(false).unwrap();
assert_ne!(old, new);
assert!(
replacement
.backend
.mixer
.lock()
.unwrap()
.channels
.contains_key(&new)
);
}
#[test]
fn a_mono_output_plays_sources_unpanned() {
let mut m = Mixer::default();
@@ -1306,4 +1809,15 @@ mod tests {
mix(&mut m, &mut data, 1, 48_000);
assert_eq!(data, [0.5; 2]);
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
// has to be looked at as before.
assert!(cards_changed(None, None));
assert!(cards_changed(Some("0 [PCH ]"), None));
// The same cards list the same devices; a card added or removed does not.
assert!(!cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]")));
assert!(cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]\n 1 [USB ]")));
}
}
+303
View File
@@ -272,6 +272,211 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// The account's chat key pair (ProfileKeyPair): the private key that signs
/// its chat, the public key with the services' signature over it, and when
/// to fetch a new one (refreshedAfter, epoch milliseconds). Like the access
/// token it stays in memory: vanilla's profilekeys file is never written.
pub struct ChatKeyPair {
pub private: horizon_protocol::rsa::RsaPrivateKey,
pub public: horizon_protocol::secure_chat::ProfileKey,
pub refreshed_after: i64,
}
impl std::fmt::Debug for ChatKeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatKeyPair")
.field("expires_at", &self.public.expires_at)
.field("refreshed_after", &self.refreshed_after)
.finish_non_exhaustive()
}
}
impl ChatKeyPair {
/// ProfileKeyPair.dueRefresh.
pub fn due_refresh(&self, now: i64) -> bool {
self.refreshed_after < now
}
}
/// Epoch milliseconds now.
pub fn now_millis() -> i64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Instant.parse for the services' UTC times
/// (`2022-07-30T23:05:24.548227Z`): epoch milliseconds.
pub fn parse_instant(text: &str) -> Option<i64> {
let text = text.strip_suffix('Z')?;
let (date, time) = text.split_once('T')?;
let mut date = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
let (y, m, d) = (date.next()??, date.next()??, date.next()??);
let (hms, fraction) = time.split_once('.').unwrap_or((time, ""));
let mut hms = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
let (h, min, s) = (hms.next()??, hms.next()??, hms.next()??);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || min > 59 || s > 60 {
return None;
}
if !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let millis = format!("{fraction:0<3}")[..3].parse::<i64>().ok()?;
// Days from 1970-01-01 (Howard Hinnant's days_from_civil).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(((days * 24 + h) * 60 + min) * 60_000 + s * 1000 + millis)
}
/// Crypt.stringToPemRsaPrivateKey / stringToRsaPublicKey: the base64
/// between a PEM block's armour lines.
fn pem_der(pem: &str) -> Option<Vec<u8>> {
let body: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
crate::skins::base64_decode(&body)
}
/// KeyPairResponse from the certificates endpoint, as
/// AccountProfileKeyPairManager.fetchProfileKeyPair reads it: the PEM key
/// pair, publicKeySignatureV2 (the signature over the profile id too),
/// expiresAt and refreshedAfter. None for anything malformed, or a private
/// key that isn't the public key's other half.
pub fn parse_certificates(body: &str) -> Option<ChatKeyPair> {
use horizon_protocol::rsa::{RsaPrivateKey, RsaPublicKey};
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let field = |path: &str| v.pointer(path).and_then(|s| s.as_str());
let private = RsaPrivateKey::from_der(&pem_der(field("/keyPair/privateKey")?)?).ok()?;
let key = pem_der(field("/keyPair/publicKey")?)?;
if RsaPublicKey::from_der(&key).ok()? != private.public {
return None;
}
let signature = crate::skins::base64_decode(field("/publicKeySignatureV2")?)?;
Some(ChatKeyPair {
private,
public: horizon_protocol::secure_chat::ProfileKey {
expires_at: parse_instant(field("/expiresAt")?)?,
key,
signature,
},
refreshed_after: parse_instant(field("/refreshedAfter")?)?,
})
}
/// UserApiService.getKeyPair: the certificates endpoint, POSTed with the
/// access token.
pub fn fetch_chat_keys(session: &AuthSession) -> Result<ChatKeyPair, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "player", "getCertificates").ok_or(JoinError::Unavailable)?;
let body = match agent()
.post(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.set("Content-Type", "application/json; charset=utf-8")
.send_string("")
{
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
return Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
));
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
parse_certificates(&body)
.ok_or_else(|| JoinError::Other("Malformed profile key pair response".to_owned()))
}
/// AccountProfileKeyPairManager.MINIMUM_PROFILE_KEY_REFRESH_INTERVAL.
const KEY_RETRY: Duration = Duration::from_secs(60 * 60);
/// The chat key pair as AccountProfileKeyPairManager keeps it: fetched on a
/// background thread, kept until it is due a refresh, and on a failure kept
/// as it was (the next try waits an hour, or the next join).
#[derive(Default)]
struct ChatKeys {
current: Option<Arc<ChatKeyPair>>,
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
/// due a refresh. Nothing without a signed-in account.
pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
}
keys.next_try = Some(instant + KEY_RETRY);
if keys
.current
.as_ref()
.is_some_and(|k| !k.due_refresh(now_millis()))
{
return;
}
keys.fetching = true;
drop(keys);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
Err(e) => log::error!("Failed to retrieve profile key pair: {e:?}"),
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
CHAT_KEYS
.lock()
.unwrap()
.current
.clone()
.filter(|k| !k.public.has_expired(now))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
/// Single-player and integrated servers often send no `textures` property, or
/// one without the equipped cape; the client asks the profile endpoint once.
pub fn own_textures(session: &AuthSession) -> Option<crate::skins::Textures> {
let response = agent()
.get(MINECRAFT_PROFILE)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?;
if response.status() != 200 {
return None;
}
let body = response.into_string().ok()?;
let profile: serde_json::Value = serde_json::from_str(&body).ok()?;
Some(crate::skins::services_profile_textures(&profile))
}
/// What the session service says about a player joining this server
/// (MinecraftServicesSessionService.hasJoinedServer).
#[derive(Clone, Debug, PartialEq)]
@@ -466,6 +671,104 @@ mod tests {
);
}
#[test]
fn instants_like_java() {
assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_instant("1970-01-01T00:00:01.5Z"), Some(1500));
// Instant.parse("2022-07-30T23:05:24.548227Z").toEpochMilli().
assert_eq!(
parse_instant("2022-07-30T23:05:24.548227Z"),
Some(1_659_222_324_548)
);
assert_eq!(
parse_instant("2024-02-29T12:00:00Z"),
Some(1_709_208_000_000)
);
assert_eq!(parse_instant("1969-12-31T23:59:59Z"), Some(-1000));
for bad in [
"2022-07-30T23:05:24",
"2022-13-01T00:00:00Z",
"2022-07-30 23:05:24Z",
"2022-07-30T23:05:24.5x1Z",
"",
] {
assert_eq!(parse_instant(bad), None, "{bad}");
}
}
/// A throwaway 1024-bit key (`openssl genrsa`, PKCS#8 as the services
/// send it under "RSA PRIVATE KEY") and its public half.
const PRIVATE_B64: &str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKSDqaLOF2Ltt4tzWblkFeNYhdss67BhXTbOMUYjpcPdtf4dzNtgZsTf4WEluvc8pItGWChdpq3xzPC9GL4PZ+Oh1o6VR/wSm20/50QT7V+YcSP1VrVKjDpDSg/UoC1Fmdc/vb9AO8Ip7TxuRYd/uyog/ThQ8aGcWtvvtwPqx4XrAgMBAAECgYA+ZGy2gQDflRZSPeoeZHXkphMF/7BXUWW/vk1zs1tPwEsgV/70iEgRjHL2nA+fapFg/Mm8bWAkghrehmjW1BLq5w8sGdGJCHfE/kauu78jJK0/ME38e9bsSEvv5HMMxyx0mlV8DQu2pzCT71ceT8VD/1pewUbpeZypj5pxtsCIMQJBANXgca/mnAQXTuowI8FqMiald6OuAcAWbOaNrCB5HHWuxH7hFatSqGrgp4YLqHv7bzeodU1y/uxwc+/01khd0rMCQQDE6mPfIdZxHhWkMUAKhMEnrLanfCL0dfyAe+lBpqgPwO3UurCfCaFX5RdYB3fCyvgy5foHN1/I8/Eobp+enrvpAkBgkZnjeXWiZz/Lf/b1AtUoG/g8IHpL2BRNuVQpyWVvQX0ZaNoW5BiFQXCClsr5SBr8f4SPdsfhzBidFOiX/jz/AkBDuk6Bd3T3UR6ma54YQBLjX38vWf330QD4/CV9RCH30Sa8DT4kZT6OKah+yYrCf+cgMoNtqfkS9/xRKMNT3krJAkAaFrAT6HF5Y9b5OLASbbFe7MZ4zNCzuEl6plKNUi+JCHESaU/VCCuYOLoWLeSDFYkZUoSmqiRxH8fVTsBz393x";
const PUBLIC_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkg6mizhdi7beLc1m5ZBXjWIXbLOuwYV02zjFGI6XD3bX+HczbYGbE3+FhJbr3PKSLRlgoXaat8czwvRi+D2fjodaOlUf8EpttP+dEE+1fmHEj9Va1Sow6Q0oP1KAtRZnXP72/QDvCKe08bkWHf7sqIP04UPGhnFrb77cD6seF6wIDAQAB";
fn pem(label: &str, b64: &str) -> String {
let lines: Vec<&str> = b64
.as_bytes()
.chunks(76)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
format!(
"-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
lines.join("\n")
)
}
fn certificates(private: &str, public: &str) -> String {
serde_json::json!({
"keyPair": {
"privateKey": pem("RSA PRIVATE KEY", private),
"publicKey": pem("RSA PUBLIC KEY", public),
},
"publicKeySignature": "djE=",
"publicKeySignatureV2": "c2ln",
"expiresAt": "2022-07-30T23:05:24.548227Z",
"refreshedAfter": "2022-07-30T15:05:24.548227Z",
})
.to_string()
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
assert_eq!(
pair.public.key,
crate::skins::base64_decode(PUBLIC_B64).unwrap()
);
assert_eq!(pair.public.signature, b"sig", "publicKeySignatureV2");
assert_eq!(pair.public.expires_at, 1_659_222_324_548);
assert_eq!(pair.refreshed_after, 1_659_193_524_548);
assert_eq!(pair.private.public_der(), pair.public.key);
assert!(pair.due_refresh(1_659_193_524_549));
assert!(!pair.due_refresh(1_659_193_524_548));
assert!(!format!("{pair:?}").contains("MII"));
// A public key that isn't the private key's, and broken fields.
let other = horizon_protocol::rsa::RsaPrivateKey::generate(1024).unwrap();
let other = base64(&other.public_der());
assert!(parse_certificates(&certificates(PRIVATE_B64, &other)).is_none());
assert!(parse_certificates(&certificates("bm9wZQ==", PUBLIC_B64)).is_none());
assert!(parse_certificates("{}").is_none());
assert!(parse_certificates("").is_none());
}
fn base64(bytes: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for c in bytes.chunks(3) {
let n = c
.iter()
.enumerate()
.fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= c.len() {
char::from(A[(n >> (18 - 6 * i) & 63) as usize])
} else {
'='
});
}
}
out
}
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -73,6 +73,9 @@ pub struct BlockEntities {
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
}
impl std::ops::Deref for BlockEntities {
@@ -162,6 +165,13 @@ impl BlockEntities {
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
self.mod_fluids = fluids;
}
/// The fluid tanks of a mod block's block entity in block state `state`.
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -1118,9 +1118,10 @@ impl BlockEntityRenderer {
}
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
/// BellRenderer.submit, LecternRenderer.submit (the book),
/// CopperGolemStatueBlockRenderer.submit, ConduitRenderer.submit (the shell
/// or cage) and EnchantTableRenderer.submit (the book): the model drawn
/// again under the destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1138,6 +1139,8 @@ impl BlockEntityRenderer {
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
| Kind::Conduit
| Kind::EnchantingTable
) {
return;
}
@@ -1683,6 +1686,10 @@ impl BlockEntityRenderer {
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
draw_part(model, "root", m, &none, paint, out);
}
// The crumbling overlay covers the shell and the cage only.
if crumbling {
return;
}
let wind = if phase == 1 {
"entity/conduit/wind_vertical"
} else {
+2 -1
View File
@@ -13,7 +13,8 @@ use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
+55
View File
@@ -210,6 +210,14 @@ impl Tag {
text: Text::translate("chat.tag.not_secure", &[]),
}
}
/// `GuiMessageTag.chatError`: red (`CHAT_ERROR`, 16733525), the tooltip `chat.tag.error`.
pub fn chat_error() -> Self {
Self {
color: 0xFF_5555,
icon: false,
text: Text::translate("chat.tag.error", &[]),
}
}
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
pub fn modified(original_content: &str) -> Self {
Self {
@@ -1005,6 +1013,53 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
/// this, not what was typed: a line of spaces sends nothing, and a message the
/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
/// server.
pub fn normalize_chat_message(message: &str) -> String {
// String.trim: the code points up to U+0020, which is not `str::trim`'s
// Unicode whitespace (U+00A0 is not Java whitespace and stays).
let trimmed = message.trim_matches(|c: char| c <= ' ');
truncate_utf16(&normalize_space(trimmed), 256)
}
/// `StringUtils.normalizeSpace`: one space for every run of
/// `Character.isWhitespace`, and none at either end.
fn normalize_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if crate::ui::java_whitespace(c) {
space = !out.is_empty();
} else {
if space {
out.push(' ');
space = false;
}
out.push(c);
}
}
out
}
/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
/// `chars().take(256)` would keep all 129 (258 units) for the server to
/// reject. A cut between the halves of a surrogate pair keeps the character
/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
/// may hold and a Rust `String` may not.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut units = 0;
for (byte, c) in s.char_indices() {
units += c.len_utf16();
if units > max_units {
return s[..byte].to_owned();
}
}
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
@@ -578,3 +578,38 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
#[test]
fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
// ChatScreen.normalizeChatMessage: String.trim, then StringUtils
// .normalizeSpace, so a command gets the arguments the player meant.
assert_eq!(normalize_chat_message(" hello there "), "hello there");
assert_eq!(normalize_chat_message("/msg Alex\thi"), "/msg Alex hi");
assert_eq!(normalize_chat_message("a\n\nb\r\nc"), "a b c");
assert_eq!(normalize_chat_message("\t \n"), "");
assert_eq!(normalize_chat_message(""), "");
// Character.isWhitespace is not str::trim's Unicode whitespace: U+00A0 is
// no Java whitespace, so it is neither trimmed nor collapsed, while the
// ideographic space U+3000 and Java's U+001C separators are.
assert_eq!(normalize_chat_message(" \u{a0} hi"), "\u{a0} hi");
assert_eq!(normalize_chat_message("a\u{a0}\u{a0}b"), "a\u{a0}\u{a0}b");
assert_eq!(normalize_chat_message("a\u{3000}\u{3000}b"), "a b");
assert_eq!(normalize_chat_message("a\u{1c}b"), "a b");
}
#[test]
fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
// StringUtil.trimChatMessage counts String.length(), UTF-16 code units:
// 256 ASCII characters are all kept, 257 are cut to 256.
assert_eq!(normalize_chat_message(&"a".repeat(256)).len(), 256);
assert_eq!(normalize_chat_message(&"a".repeat(300)).len(), 256);
// An emoji is two units, so 128 of them fill the packet and the 129th is
// dropped: chars().take(256) would keep all 129 and the server would
// reject the message.
let emoji = normalize_chat_message(&"\u{1f600}".repeat(129));
assert_eq!(emoji, "\u{1f600}".repeat(128));
assert_eq!(emoji.encode_utf16().count(), 256);
// A cut between the halves of a pair keeps the character below it.
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
+5 -1
View File
@@ -212,7 +212,7 @@ impl Default for ColumnLayout {
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
/// (what vanilla servers and clients send), more when content mods add states
@@ -391,6 +391,9 @@ pub struct Chunk {
pub light_loaded: bool,
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Finished from a proto chunk just now, in any kind of world, not read
/// from a save (`ChunkEvent.Load`'s `isNewChunk`).
pub generated: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
@@ -428,6 +431,7 @@ impl Chunk {
lit: false,
light_loaded: false,
fresh: false,
generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
@@ -120,6 +120,21 @@ pub struct ModContainer {
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The fluid tanks of a mod block's block entity, in the container's shape:
/// each tank's capacity in millibuckets, the tanks each face reaches, and
/// the tanks a fill may reach and a drain may take. The tanks' contents are
/// not here: they live in the entity's saved compound, under `fluids`, so
/// they survive restarts and the block's own tick reads them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModFluidTanks {
/// Each tank's capacity in millibuckets, in tank order.
pub tanks: Vec<i32>,
pub faces: [Vec<usize>; 6],
/// The tanks a fill may reach (`canPlaceItemThroughFace`).
pub inputs: Vec<usize>,
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
+260 -3
View File
@@ -1,7 +1,13 @@
//! Resident chunk membership, with a token for consumers that cache its keys.
use crate::{chunk::Chunk, fast_hash::FxHashMap};
use crate::{
chunk::Chunk,
fast_hash::{FxHashMap, FxHashSet},
};
use std::{
collections::hash_map::{Iter, ValuesMut},
collections::{
VecDeque,
hash_map::{Iter, ValuesMut},
},
ops::Deref,
sync::{
Arc,
@@ -16,18 +22,46 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
/// The membership changes one map keeps for the consumers that read them
/// every tick (`TickChunkMembership.synchronize`); older ones are dropped.
const MAX_RECENT_DELTAS: usize = 256;
/// One membership change of one `ChunkMap`, stamped with the revision it
/// brought the map to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkMembershipDelta {
Added(Key),
Removed(Key),
Reset,
}
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
/// The oldest revision `recent_deltas` still answers for: the revision of
/// the change it last dropped, or the revision that cleared them. A
/// consumer holding an older one rebuilds, it does not replay.
min_valid_revision: u64,
/// This map's recent membership changes, in revision order.
/// Revisions are process-wide: the numbers missing in between belong to
/// the other maps (a server thread's, another dimension's), which say
/// nothing about this one's keys.
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
}
impl Default for ChunkMap {
fn default() -> Self {
let revision = next_revision();
Self {
columns: Columns::default(),
revision: next_revision(),
revision,
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
}
}
}
@@ -35,26 +69,107 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
fn record_delta(&mut self, delta: ChunkMembershipDelta) {
if self.recent_deltas.len() >= MAX_RECENT_DELTAS
&& let Some((evicted, _)) = self.recent_deltas.pop_front()
{
self.min_valid_revision = evicted;
}
self.recent_deltas.push_back((self.revision, delta));
}
/// This map's membership changes since `last_revision`, one of its own
/// earlier `membership_revision`s, or `None` when they can no longer be
/// answered: the consumer's revision predates what the ring kept, the map
/// was cleared since (a reset rebuilds everything), or the revision is not
/// this map's, which its own revisions give away: every one of them in the
/// answered range is the opening revision or a change the ring still
/// holds, so a revision another map took in between is refused, not
/// replayed.
pub fn deltas_since(
&self,
last_revision: u64,
) -> Option<impl Iterator<Item = (u64, ChunkMembershipDelta)> + '_> {
if last_revision == 0
|| last_revision < self.min_valid_revision
|| last_revision > self.revision
|| self.recent_deltas.is_empty()
{
return None;
}
let pos = self
.recent_deltas
.partition_point(|(rev, _)| *rev <= last_revision);
// A revision this map never took: another map's, drawing on the same
// process-wide counter. Only the opening revision and the ones the
// ring still holds are this map's.
let is_own_revision = last_revision == self.min_valid_revision
|| (pos > 0 && self.recent_deltas[pos - 1].0 == last_revision);
if !is_own_revision {
return None;
}
let slice = self.recent_deltas.range(pos..);
if slice
.clone()
.any(|(_, d)| *d == ChunkMembershipDelta::Reset)
{
return None;
}
Some(slice.copied())
}
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
if chunk.fresh {
self.fresh.insert(key);
} else {
self.fresh.remove(&key);
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
previous
}
pub fn clear(&mut self) {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
.push_back((self.revision, ChunkMembershipDelta::Reset));
self.min_valid_revision = self.revision;
}
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
/// The fresh keys in the chunk map's own iteration order, so the spawns
/// they drive run in the order a scan of the columns gave.
pub fn fresh_keys_ordered(&self) -> Vec<Key> {
if self.fresh.is_empty() {
Vec::new()
} else if self.fresh.len() == 1 {
self.fresh.iter().copied().collect()
} else {
self.columns
.keys()
.filter(|k| self.fresh.contains(k))
.copied()
.collect()
}
}
pub fn remove_fresh(&mut self, key: &Key) {
self.fresh.remove(key);
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
self.columns.get_mut(key)
}
@@ -125,4 +240,146 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
/// The deltas' kinds, checking the ring answers in revision order.
fn kinds(deltas: Vec<(u64, ChunkMembershipDelta)>) -> Vec<ChunkMembershipDelta> {
let mut last = 0;
for (rev, _) in &deltas {
assert!(*rev > last, "the ring answers in revision order");
last = *rev;
}
deltas.into_iter().map(|(_, d)| d).collect()
}
/// Revision numbers are process-wide: the ones another map takes in
/// between, and a payload-only rewrite that takes none at all, say nothing
/// about this map's keys.
#[test]
fn deltas_since_ignores_revisions_other_maps_take() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
for key in [(1, 0), (2, 0), (3, 0)] {
other.insert(key, chunk.clone());
}
map.insert((0, 0), Arc::new(Chunk::empty(0, 0))); // Payload only.
map.insert((4, 0), chunk.clone());
map.remove(&(0, 0));
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[
ChunkMembershipDelta::Added((4, 0)),
ChunkMembershipDelta::Removed((0, 0)),
]
);
// The consumer's own revision answers, with nothing new since.
assert!(
map.deltas_since(map.membership_revision())
.unwrap()
.next()
.is_none()
);
// A revision another map took in between: refused, not replayed.
assert!(map.deltas_since(other.membership_revision()).is_none());
}
/// A map built ahead of the live one, its revisions interleaved with it,
/// answering for a revision it never took: refused, so the consumer
/// rebuilds rather than replay another map's changes.
#[test]
fn deltas_since_none_for_interleaved_revision_from_another_map() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
other.insert((1, 0), chunk.clone());
let foreign = other.membership_revision();
map.insert((2, 0), chunk.clone());
assert!(map.deltas_since(foreign).is_none());
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[ChunkMembershipDelta::Added((2, 0))]
);
}
/// The ring answers for the changes it kept, and only those.
#[test]
fn deltas_since_none_for_a_revision_the_ring_dropped() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let created = map.membership_revision();
let mut revisions = Vec::new();
let changes = MAX_RECENT_DELTAS as i32 + 1;
for i in 0..changes {
map.insert((i, 0), chunk.clone());
revisions.push(map.membership_revision());
}
let (first, kept) = revisions.split_first().unwrap();
// The oldest change fell out with the consumer's revision: rebuild.
assert!(map.deltas_since(created).is_none());
assert_eq!(map.deltas_since(*first).unwrap().count(), kept.len());
}
/// A reset rebuilds whatever the consumer last saw, and the ring answers
/// for what follows it.
#[test]
fn deltas_since_none_across_a_clear() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let before = map.membership_revision();
map.clear();
assert!(map.deltas_since(before).is_none());
let cleared = map.membership_revision();
map.insert((1, 0), chunk);
assert_eq!(
kinds(map.deltas_since(cleared).unwrap().collect()),
[ChunkMembershipDelta::Added((1, 0))]
);
}
#[test]
fn fresh_chunks_tracking() {
let mut map = ChunkMap::default();
assert!(!map.has_fresh());
let mut chunk = Chunk::empty(1, 2);
chunk.fresh = true;
map.insert((1, 2), Arc::new(chunk));
assert!(map.has_fresh());
assert_eq!(map.fresh_keys_ordered(), vec![(1, 2)]);
map.remove_fresh(&(1, 2));
assert!(!map.has_fresh());
let mut chunk2 = Chunk::empty(3, 4);
chunk2.fresh = true;
map.insert((3, 4), Arc::new(chunk2));
assert!(map.has_fresh());
map.remove(&(3, 4));
assert!(!map.has_fresh());
}
#[test]
fn fresh_keys_ordered_matches_chunk_map_iteration_order() {
let mut map = ChunkMap::default();
let keys = [(-5, 10), (12, -3), (0, 0), (7, 8), (-1, -1), (4, 4)];
for (i, &key) in keys.iter().enumerate() {
let mut chunk = Chunk::empty(key.0, key.1);
chunk.fresh = i % 2 == 0;
map.insert(key, Arc::new(chunk));
}
let ordered = map.fresh_keys_ordered();
let expected: Vec<Key> = map
.keys()
.copied()
.filter(|k| map.fresh.contains(k))
.collect();
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
}
+41 -11
View File
@@ -169,11 +169,13 @@ impl Mesh {
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal: [0; 4],
normal,
pos: v,
uv,
layer: 4,
@@ -186,17 +188,41 @@ impl Mesh {
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0);
quad(p, 0, [0; 4]);
}
continue;
}
let quads = [
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
@@ -210,8 +236,12 @@ impl Mesh {
],
);
for (direction, inside) in faces(mask) {
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
}
self.key = Some(key);
+158 -4
View File
@@ -343,8 +343,15 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
/// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
/// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
/// asks the block entity, which has grown once the lid starts to open).
fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result<Option<bool>> {
Ok(self
.world
.shulkers
.get(&p)
.map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -373,11 +380,17 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
/// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
/// (`getShape`) is the free shape grown by the lid's progress, which is
/// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
_: [i32; 3],
p: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(None)
Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
previous_progress: s.progress_old,
progress: s.progress,
}))
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -497,6 +510,8 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
/// No block-entity data: a lookup world has no shulker box to ask, so the
/// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
@@ -505,6 +520,8 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
/// No block-entity data: the block table's own shape stands in for the lid
/// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -611,3 +628,140 @@ mod border_movement_tests {
);
}
}
#[cfg(test)]
mod shulker_collision_tests {
use super::*;
use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
/// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
#[test]
fn shulker_closed_collision_is_full_block() {
let shulker = Shulker {
status: ShulkerStatus::Closed,
progress: 0.0,
progress_old: 0.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Closed box: height 1.0
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
// Full horizontal extent
assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
}
/// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
#[test]
fn shulker_open_collision_grows_upward() {
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Fully open: height 1.5 (lid extends 0.5 above the block)
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
}
/// A shulker box halfway open: collision height is 1.25.
#[test]
fn shulker_half_open_collision_height() {
let shulker = Shulker {
status: ShulkerStatus::Opening,
progress: 0.5,
progress_old: 0.5,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Half open: height 1.25
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
}
/// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
/// moving DOWN is not stopped by the box (collision only triggers when
/// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
#[test]
fn shulker_grown_box_allows_clip_when_inside() {
// Box at progress 1.0: spans y=0..1.5
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
// Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
// Moving DOWN from inside the box: collide() should NOT stop it
// (the entity is already intersecting the box, so the downward movement
// sees no face to hit from above).
let down = -0.1;
let moved_down = box_shape.collide(Axis::Y, entity, down);
assert!(
(moved_down - down).abs() < 1.0e-9,
"entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
);
// Entity box whose feet are at y=1.6 (just ABOVE the grown box)
let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
// Moving DOWN from above: collide() SHOULD stop it at y=1.5
let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
// The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
assert!(
(moved_down_above - (-0.1)).abs() < 1.0e-9,
"entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
);
}
/// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
/// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
/// This is tested at the integration level via `push_shulker_entities`;
/// here we only verify the swept box geometry matches vanilla's
/// `Shulker.getProgressDeltaAabb`.
#[test]
fn shulker_swept_box_matches_vanilla_delta() {
// From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
// to y=0..1.5 (half open), so the swept volume in world coords at position
// [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
// From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
assert!((delta2[1] - 1.5).abs() < 1.0e-9);
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
+34 -1
View File
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
PotentSulfur, Shelf, Skull, SkullSkin,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -82,6 +82,39 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
display_name(first.or(second), "container.chestDouble")
}
/// `BaseContainerBlockEntity.sendChestLockedNotifications`: the action bar
/// message of a container that refuses to open, `container.isLocked` naming it
/// by its display name.
pub fn locked_message(title: &horizon_world::nbt::Tag) -> horizon_world::nbt::Tag {
use horizon_world::nbt::Tag;
Tag::compound([
("translate", Tag::String("container.isLocked".into())),
("with", Tag::List(vec![title.clone()])),
])
}
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
/// `HopperMenu` (five slots). `None` for any other entity type.
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
match entity_type {
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
Some(("minecraft:generic_9x3", 27))
}
_ => None,
}
}
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
/// is its custom name, else `getName`, the entity type's name key. Horizon's
/// vehicles keep no custom name, so this is the type's name key.
pub fn vehicle_menu_name(entity_type: &str) -> String {
format!("entity.{}", entity_type.replacen(':', ".", 1))
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
+188 -14
View File
@@ -75,6 +75,24 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
/// The `features.enabled` flag ids of `pack.mcmeta`
/// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
pub fn features(&self) -> Vec<String> {
self.mcmeta()["features"]["enabled"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
/// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
fn mcmeta(&self) -> serde_json::Value {
self.read("pack.mcmeta")
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -127,14 +145,43 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
/// selects them.
pub fn feature_packs(resources: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
else {
return Vec::new();
};
let mut packs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: e.file_name().to_string_lossy().into_owned(),
source: Source::Dir(path),
});
}
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The packs of `<world>/datapacks` plus the resources root's feature packs:
/// the world's whole repository, as `PackRepository.reload` sees it.
pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec<DataPack> {
let mut packs = discover(world_root);
packs.extend(feature_packs(resources));
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
/// built-in one (`resources` holds its `pack.png`), and the feature packs
/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
/// until picked — the Experiments.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -172,6 +219,23 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
position: Position::Bottom,
fixed: false,
});
for p in feature_packs(resources) {
let meta = p.mcmeta();
let (description, description_style) =
crate::packs::component_root(&meta["pack"]["description"]);
infos.push(PackInfo {
icon: None,
id: p.id.clone(),
title: Text::translate(&format!("dataPack.{}.name", p.id), &[]),
description,
description_style,
source: Source::Feature,
compat: crate::packs::compatibility_for(&meta, format),
required: false,
position: Position::Bottom,
fixed: false,
});
}
infos
}
/// A new empty folder for Create World's packs
@@ -185,6 +249,59 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
} else {
let _ = std::fs::copy(&path, &to);
}
}
}
let name = || {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
if path.is_file() {
return path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
&& {
let _ = std::fs::create_dir_all(dir);
std::fs::copy(path, dir.join(name())).is_ok()
};
}
if !path.is_dir() {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
_ => false,
}
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
@@ -230,7 +347,11 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
// No flags here: the recreated world keeps the original's
// `enabled_features`, and the flag-aware load re-adds the feature packs
// it asks for (`for_recreate`'s selection only seeds the new world's
// `datapacks.json`).
let mut selected = enabled_ids(world, &packs, &[]);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
@@ -453,7 +574,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -461,17 +582,20 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" => kind,
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
return Some(format!("tags/{ns}:{}", rest.strip_suffix(".json")?));
}
_ => return None,
};
@@ -497,12 +621,15 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"dialog",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"tags",
"trade_set",
"villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -616,10 +743,25 @@ fn hash_normalize(s: &str) -> String {
}
}
/// Whether every flag `pack` asks for (`Pack.getRequestedFeatures`) is among
/// the world's `enabled_features`.
fn features_enabled(pack: &DataPack, enabled_features: &[String]) -> bool {
pack.features()
.iter()
.all(|f| enabled_features.iter().any(|e| e == f))
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
///
/// `enabled_features` are the world's feature flags
/// (`WorldDataConfiguration.enabledFeatures`): a pack a pack file selected
/// while asking for missing features comes off
/// (`MinecraftServer.configurePackRepository`), and a feature pack whose
/// features the world enables is forced on however the world's selection
/// feels about it (`MinecraftServer.enableForcedFeaturePacks`).
pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
@@ -638,9 +780,32 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.filter(|id| {
packs
.iter()
.find(|p| &p.id == id)
.is_none_or(|p| features_enabled(p, enabled_features))
})
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
// A pack nobody mentions comes on (`PackRepository.reload` selects
// every new pack), but a feature pack stays off until the world
// enabled its flag (`PackSource.FEATURE` starts unselected).
if !enabled.contains(&p.id)
&& !disabled.contains(&p.id)
&& p.id.starts_with("file/")
&& features_enabled(p, enabled_features)
{
enabled.push(p.id.clone());
}
}
for p in packs {
let feature_pack = !p.id.starts_with("file/") && p.id != "vanilla";
if feature_pack
&& !p.features().is_empty()
&& features_enabled(p, enabled_features)
&& !enabled.contains(&p.id)
{
enabled.push(p.id.clone());
}
}
@@ -751,7 +916,7 @@ mod tests {
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
let enabled = enabled_ids(&dir, &packs, &[]);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
@@ -865,6 +1030,10 @@ mod tests {
"data/mine/predicate/p.json",
"{\"condition\":\"minecraft:random_chance\",\"chance\":1}",
);
write(
"data/mine/dialog/config/points.json",
"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}",
);
let packs = discover(&dir);
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].id, "file/mypack");
@@ -877,6 +1046,11 @@ mod tests {
["mine:a", "mine:sub/b"]
);
assert!(store.predicates.contains_key("mine:p"));
// Dialogs reach the synchronized registry through the content files.
assert_eq!(
store.files["dialog/mine:config/points"],
[b"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}".to_vec()]
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -13,6 +13,37 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
pub fn select_colored(
sources: impl Iterator<Item = ([f64; 3], u32, f32, f32)>,
camera: DVec3,
) -> Vec<Colored> {
let mut nearest: Vec<_> = sources.collect();
nearest.sort_by(|a, b| {
(DVec3::from(a.0) - camera)
.length_squared()
.total_cmp(&(DVec3::from(b.0) - camera).length_squared())
});
nearest
.into_iter()
.take(MAX)
.map(|(at, rgb, radius, intensity)| {
let at = (DVec3::from(at) - camera).as_vec3();
[
[at.x, at.y, at.z, radius],
[
((rgb >> 16) & 255) as f32 / 255.0,
((rgb >> 8) & 255) as f32 / 255.0,
(rgb & 255) as f32 / 255.0,
intensity,
],
]
})
.collect()
}
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
@@ -87,6 +118,17 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
(0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
DVec3::new(2.0, 64.0, 0.0),
);
assert_eq!(lights.len(), MAX);
assert_eq!(lights[0][0][0], 0.0);
assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
}
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
+39 -9
View File
@@ -235,10 +235,12 @@ impl PlayerEffects {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
/// (on its last tick) and a content mod's effect (every tick, with the
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
/// absorption is used up. Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -247,7 +249,7 @@ impl PlayerEffects {
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -269,8 +271,9 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"bad_omen" => omen(e, t),
"raid_omen" if t == 1 => omen(e, t),
id if potions::is_mod_effect(id) => omen(e, t),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -508,7 +511,7 @@ mod tests {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
let mut convert = |e: &EffectInstance| match e.id {
let mut convert = |e: &EffectInstance, _: i32| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -550,12 +553,39 @@ mod tests {
.is_empty()
);
}
/// A content mod's effect answers each tick before it counts down (an
/// infinite one with the tick count), and goes at once on Remove.
#[test]
fn a_mod_effect_answers_before_it_counts_down() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("ruby:glow", 3, 1));
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
let mut seen = Vec::new();
let mut answer = |e: &EffectInstance, t: i32| {
seen.push((e.id, t));
match (e.id, t) {
("ruby:glow", 2) => OmenTick::Remove,
_ => OmenTick::Continue,
}
};
fx.tick_with(&mut answer, false);
fx.tick_with(&mut answer, false);
let ticks = [
("ruby:glow", 3),
("ruby:bleed", 1),
("ruby:glow", 2),
("ruby:bleed", 2),
];
assert_eq!(seen, ticks);
assert!(fx.get("ruby:glow").is_none());
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
let mut watch = |e: &EffectInstance| {
let mut watch = |e: &EffectInstance, _: i32| {
seen.push(e.duration);
OmenTick::Remove
};
+23 -2
View File
@@ -22,6 +22,10 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@@ -90,6 +94,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -103,6 +116,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
@@ -141,7 +157,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
var out = entity_vertex(offset + position, uv, overlay & 0xffffu, color, light, normal);
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
struct CloudInfo {
offset: vec4<f32>,
@@ -294,7 +312,10 @@ fn shadow_color(in: Out) -> vec4<f32> {
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
File diff suppressed because it is too large. Load diff
@@ -46,12 +46,13 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer {
match layer & 0xffff {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -213,6 +214,15 @@ mod tests {
);
}
#[test]
fn a_tier_in_the_high_bits_keeps_the_material() {
for layer in [0u32, 11, 12, 15, 32, 33, 35, OIT_ENTITY] {
for oit in [false, true] {
assert_eq!(material(layer | 4 << 16, oit), material(layer, oit));
}
}
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
+54 -18
View File
@@ -63,6 +63,8 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -117,29 +119,52 @@ pub struct Flat {
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
/// Whether flat draws exist: vertex shaders can read storage buffers large
/// enough for an index page. A shader pack's terrain draws flat wherever they
/// do, since only there does it read each quad's face normal and `mc_Entity`;
/// frames without one draw flat only where `for_vanilla` says.
/// `HORIZON_FLAT_DRAWS=1` draws every frame flat, `0` none.
pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
match std::env::var("HORIZON_FLAT_DRAWS").as_deref() {
Ok("0") => return false,
Ok("1") => return supported(adapter, device),
_ => {}
}
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
std::env::var("HORIZON_FLAT_DRAWS").as_deref() != Ok("0") && supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -261,7 +286,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -273,6 +298,7 @@ impl Flat {
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -326,13 +352,23 @@ impl Flat {
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
flatten,
compute_layout,
@@ -1,7 +1,9 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
// Unused until the interpolation and the renderer use it.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod optical_flow;
@@ -0,0 +1,806 @@
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
//! (`optical_flow.rs`) and the depth.
//!
//! The passes and their order are `ffxFrameInterpolationPrepare` then
//! `ffxFrameInterpolationDispatch`'s: dilate the depth and motion vectors and estimate
//! the previous frame's depth; estimate the made frame's depth; splat the game's and the
//! optical flow's vectors to the made frame's pixels (atomic maximums of packed
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
use glam::Mat4;
/// One real frame's inputs.
pub struct Inputs<'a> {
/// This frame's colour without the hand and HUD: `display` size, a filterable
/// float texture (gamma-encoded RGB, the swapchain's values) that can be copied from.
pub color: &'a wgpu::TextureView,
/// This frame's depth: `Depth32Float` at `render_size`, reversed (1 at the near
/// plane, 0 at the far one), finite, jittered by `jitter`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
/// The projection's sub-pixel jitter this frame, in render pixels (FSR's
/// `jitterOffset`); zero without TAA.
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's (camera-relative, as
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
pub fov_y: f32,
/// The previous frame is unrelated (first frame, resize, teleport): show this one.
pub reset: bool,
/// The optical flow from the previous frame's `color` to this one's
/// (`OpticalFlow::vectors`, `scene_change`).
pub flow_vectors: &'a wgpu::TextureView,
pub scene_change: &'a wgpu::Buffer,
}
/// `interpolation.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
reproject: [[f32; 4]; 4],
device_to_view: [f32; 4],
render_size: [i32; 2],
display_size: [i32; 2],
jitter: [f32; 2],
optical_flow_scale: [f32; 2],
optical_flow_size: [i32; 2],
reset: u32,
tan_half_fov: f32,
level_size: [i32; 2],
_pad: [u32; 2],
}
/// The game vector field pyramid's levels (`LoadInpaintedGameFieldMv`) and the
/// inpainting pyramid's (`ComputeInpainting`).
const GAME_LEVELS: u32 = 11;
const INPAINTING_LEVELS: u32 = 10;
struct Pipelines {
prepare: wgpu::ComputePipeline,
setup: wgpu::ComputePipeline,
reconstruct_previous_depth: wgpu::ComputePipeline,
game_motion_vector_field: wgpu::ComputePipeline,
game_field_pyramid_first: wgpu::ComputePipeline,
game_field_pyramid: wgpu::ComputePipeline,
optical_flow_vector_field: wgpu::ComputePipeline,
disocclusion_mask: wgpu::ComputePipeline,
interpolate: wgpu::ComputePipeline,
inpainting_pyramid_first: wgpu::ComputePipeline,
inpainting_pyramid: wgpu::ComputePipeline,
inpainting: wgpu::ComputePipeline,
}
/// A texture with a view of each of its levels (`levels`, for writing one from the one
/// above) and of all of them.
struct Pyramid {
all: wgpu::TextureView,
levels: Vec<wgpu::TextureView>,
sizes: Vec<[u32; 2]>,
}
/// What is sized like the world's render.
struct RenderTargets {
size: [u32; 2],
dilated_depth: wgpu::TextureView,
dilated_mv: wgpu::TextureView,
rec_prev_depth: wgpu::Buffer,
rec_interp_depth: wgpu::Buffer,
game_x: wgpu::Buffer,
game_y: wgpu::Buffer,
of_x: wgpu::Buffer,
of_y: wgpu::Buffer,
disocclusion: wgpu::TextureView,
game_pyramid: Pyramid,
}
pub struct Interpolation {
display: [u32; 2],
pipelines: Pipelines,
sampler: wgpu::Sampler,
/// The passes' constants, and each pyramid level's.
params: wgpu::Buffer,
game_level_params: Vec<wgpu::Buffer>,
inpainting_level_params: Vec<wgpu::Buffer>,
counters: wgpu::Buffer,
interpolated: wgpu::TextureView,
output: wgpu::TextureView,
inpainting_pyramid: Pyramid,
/// The last dispatch's colour (`PREVIOUS_INTERPOLATION_SOURCE`), made on the first.
previous: Option<wgpu::Texture>,
render: Option<RenderTargets>,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
levels: u32,
) -> wgpu::Texture {
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: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(4),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn uniform(device: &wgpu::Device) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame interpolation constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Levels of `base >> (i + 1)` for `i` below `max`, while both sides are 1 or more.
fn pyramid(device: &wgpu::Device, label: &str, base: [u32; 2], max: u32) -> Pyramid {
let sizes: Vec<[u32; 2]> = (0..max)
.map(|i| base.map(|v| v >> (i + 1)))
.take_while(|s| s[0] >= 1 && s[1] >= 1)
.collect();
let levels = (sizes.len() as u32).max(1);
let t = texture(
device,
label,
base.map(|v| (v / 2).max(1)),
wgpu::TextureFormat::Rgba16Float,
levels,
);
Pyramid {
all: t.create_view(&Default::default()),
levels: (0..levels)
.map(|i| {
t.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: i,
mip_level_count: Some(1),
..Default::default()
})
})
.collect(),
sizes,
}
}
impl RenderTargets {
fn new(device: &wgpu::Device, size: [u32; 2]) -> Self {
let view = |t: wgpu::Texture| t.create_view(&Default::default());
let field = |label| storage(device, label, u64::from(size[0] * size[1]) * 4);
Self {
size,
dilated_depth: view(texture(
device,
"Frame interpolation dilated depth",
size,
wgpu::TextureFormat::R32Float,
1,
)),
dilated_mv: view(texture(
device,
"Frame interpolation dilated motion vectors",
size,
wgpu::TextureFormat::Rg32Float,
1,
)),
rec_prev_depth: field("Frame interpolation previous depth"),
rec_interp_depth: field("Frame interpolation interpolated depth"),
game_x: field("Frame interpolation game vectors x"),
game_y: field("Frame interpolation game vectors y"),
of_x: field("Frame interpolation flow vectors x"),
of_y: field("Frame interpolation flow vectors y"),
disocclusion: view(texture(
device,
"Frame interpolation disocclusion",
size,
wgpu::TextureFormat::Rgba8Unorm,
1,
)),
game_pyramid: pyramid(
device,
"Frame interpolation game vector pyramid",
size,
GAME_LEVELS,
),
}
}
}
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame interpolation"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
/// `setupDeviceDepthToViewSpaceDepthParams` for inverted, finite depth, in blocks
/// (`viewSpaceToMetersFactor` 1).
fn device_to_view(near: f32, far: f32, fov_y: f32, aspect: f32) -> [f32; 4] {
let (f_min, f_max) = (near.max(far), near.min(far));
let q = f_max / (f_min - f_max);
let cot_half_fov = (0.5 * fov_y).cos() / (0.5 * fov_y).sin();
[-q, q * f_min, aspect / cot_half_fov, 1.0 / cot_half_fov]
}
impl Interpolation {
/// For frames of `display` size; the render size may change between dispatches.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame interpolation"),
source: wgpu::ShaderSource::Wgsl(include_str!("interpolation.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let view = |t: wgpu::Texture| t.create_view(&Default::default());
Self {
display,
pipelines: Pipelines {
prepare: pipeline("prepare_pass"),
setup: pipeline("setup_pass"),
reconstruct_previous_depth: pipeline("reconstruct_previous_depth_pass"),
game_motion_vector_field: pipeline("game_motion_vector_field_pass"),
game_field_pyramid_first: pipeline("game_field_pyramid_first_pass"),
game_field_pyramid: pipeline("game_field_pyramid_pass"),
optical_flow_vector_field: pipeline("optical_flow_vector_field_pass"),
disocclusion_mask: pipeline("disocclusion_mask_pass"),
interpolate: pipeline("interpolate_pass"),
inpainting_pyramid_first: pipeline("inpainting_pyramid_first_pass"),
inpainting_pyramid: pipeline("inpainting_pyramid_pass"),
inpainting: pipeline("inpainting_pass"),
},
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Frame interpolation"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
params: uniform(device),
game_level_params: (0..GAME_LEVELS).map(|_| uniform(device)).collect(),
inpainting_level_params: (0..INPAINTING_LEVELS).map(|_| uniform(device)).collect(),
counters: storage(device, "Frame interpolation counters", 8),
interpolated: view(texture(
device,
"Frame interpolation",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
output: view(texture(
device,
"Interpolated frame",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
inpainting_pyramid: pyramid(
device,
"Frame interpolation inpainting pyramid",
display,
INPAINTING_LEVELS,
),
previous: None,
render: None,
}
}
/// The frame halfway between the previous dispatch's `color` and this one's, into
/// `output`. Keeps what it needs of this frame for the next dispatch.
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
inputs: &Inputs,
) {
let color = inputs.color.texture();
let mut reset = inputs.reset;
if self
.previous
.as_ref()
.is_none_or(|p| p.size() != color.size() || p.format() != color.format())
{
self.previous = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame interpolation previous frame"),
size: color.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: color.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
reset = true;
}
if self
.render
.as_ref()
.is_none_or(|r| r.size != inputs.render_size)
{
self.render = Some(RenderTargets::new(device, inputs.render_size));
reset = true;
}
let r = self.render.as_ref().expect("made above");
let previous_texture = self.previous.as_ref().expect("made above");
let previous = previous_texture.create_view(&Default::default());
let display = self.display.map(|v| v as i32);
let render = inputs.render_size.map(|v| v as i32);
let flow_size = self
.display
.map(|v| v.div_ceil(super::optical_flow::BLOCK_SIZE) as i32);
let base = Params {
reproject: inputs.reproject.to_cols_array_2d(),
device_to_view: device_to_view(
inputs.near,
inputs.far,
inputs.fov_y,
render[0] as f32 / render[1].max(1) as f32,
),
render_size: render,
display_size: display,
jitter: inputs.jitter,
optical_flow_scale: self.display.map(|v| 1.0 / v as f32),
optical_flow_size: flow_size,
reset: u32::from(reset),
tan_half_fov: (0.5 * inputs.fov_y).tan() * render[0] as f32 / render[1].max(1) as f32,
level_size: [0; 2],
_pad: [0; 2],
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&base));
let level_params = |buffers: &[wgpu::Buffer], sizes: &[[u32; 2]]| {
for (buffer, size) in buffers.iter().zip(sizes) {
let params = Params {
level_size: size.map(|v| v as i32),
..base
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
};
level_params(&self.game_level_params, &r.game_pyramid.sizes);
level_params(
&self.inpainting_level_params,
&self.inpainting_pyramid.sizes,
);
for b in [
&r.rec_prev_depth,
&r.rec_interp_depth,
&r.game_x,
&r.game_y,
&r.of_x,
&r.of_y,
] {
encoder.clear_buffer(b, 0, None);
}
if reset {
encoder.clear_buffer(&self.counters, 0, None);
}
let p = &self.pipelines;
let params = || whole(&self.params);
let sampler = || wgpu::BindingResource::Sampler(&self.sampler);
let groups =
|pipeline, entries: &[(u32, wgpu::BindingResource)]| bind(device, pipeline, entries);
let prepare = groups(
&p.prepare,
&[
(0, params()),
(2, view(inputs.depth)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
],
);
let setup = groups(
&p.setup,
&[
(0, params()),
(22, whole(inputs.scene_change)),
(23, whole(&self.counters)),
],
);
let colors = || {
[
(3, view(inputs.color)),
(4, view(&previous)),
(1, sampler()),
]
};
let reconstruct = groups(
&p.reconstruct_previous_depth,
&[
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(10, whole(&r.rec_interp_depth)),
],
);
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(13, whole(&r.game_x)),
(14, whole(&r.game_y)),
];
entries.extend(colors());
let game_field = groups(&p.game_motion_vector_field, &entries);
let game_pyramid: Vec<_> = (0..r.game_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.game_level_params[i])),
(26, view(&r.game_pyramid.levels[i])),
];
if i == 0 {
entries.extend([(15, whole(&r.game_x)), (16, whole(&r.game_y))]);
groups(&p.game_field_pyramid_first, &entries)
} else {
entries.push((25, view(&r.game_pyramid.levels[i - 1])));
groups(&p.game_field_pyramid, &entries)
}
})
.collect();
let mut entries = vec![
(0, params()),
(21, view(inputs.flow_vectors)),
(17, whole(&r.of_x)),
(18, whole(&r.of_y)),
];
entries.extend(colors());
let flow_field = groups(&p.optical_flow_vector_field, &entries);
let game_mv = || {
[
(15, whole(&r.game_x)),
(16, whole(&r.game_y)),
(27, view(&r.game_pyramid.all)),
]
};
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(11, whole(&r.rec_prev_depth)),
(12, whole(&r.rec_interp_depth)),
(29, view(&r.disocclusion)),
];
entries.extend(game_mv());
let disocclusion = groups(&p.disocclusion_mask, &entries);
let mut entries = vec![
(0, params()),
(19, whole(&r.of_x)),
(20, whole(&r.of_y)),
(24, whole(&self.counters)),
(30, view(&r.disocclusion)),
(31, view(&self.interpolated)),
];
entries.extend(game_mv());
entries.extend(colors());
let interpolate = groups(&p.interpolate, &entries);
let inpainting_pyramid: Vec<_> = (0..self.inpainting_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.inpainting_level_params[i])),
(26, view(&self.inpainting_pyramid.levels[i])),
];
if i == 0 {
entries.push((32, view(&self.interpolated)));
groups(&p.inpainting_pyramid_first, &entries)
} else {
entries.push((25, view(&self.inpainting_pyramid.levels[i - 1])));
groups(&p.inpainting_pyramid, &entries)
}
})
.collect();
let inpainting = groups(
&p.inpainting,
&[
(0, params()),
(28, view(&self.inpainting_pyramid.all)),
(32, view(&self.interpolated)),
(33, view(&self.output)),
],
);
let groups_for = |size: [u32; 2]| (size[0].div_ceil(8), size[1].div_ceil(8));
let (rx, ry) = groups_for(inputs.render_size);
let (dx, dy) = groups_for(self.display);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Frame interpolation"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, (x, y)| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, 1);
};
run(&p.prepare, &prepare, (rx, ry));
run(&p.setup, &setup, (1, 1));
if !reset {
run(&p.reconstruct_previous_depth, &reconstruct, (rx, ry));
run(&p.game_motion_vector_field, &game_field, (rx, ry));
for (i, (group, size)) in game_pyramid.iter().zip(&r.game_pyramid.sizes).enumerate() {
let pipeline = if i == 0 {
&p.game_field_pyramid_first
} else {
&p.game_field_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(
&p.optical_flow_vector_field,
&flow_field,
groups_for(flow_size.map(|v| v as u32)),
);
run(&p.disocclusion_mask, &disocclusion, (rx, ry));
}
run(&p.interpolate, &interpolate, (dx, dy));
for (i, (group, size)) in inpainting_pyramid
.iter()
.zip(&self.inpainting_pyramid.sizes)
.enumerate()
{
let pipeline = if i == 0 {
&p.inpainting_pyramid_first
} else {
&p.inpainting_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(&p.inpainting, &inpainting, (dx, dy));
drop(pass);
encoder.copy_texture_to_texture(
color.as_image_copy(),
previous_texture.as_image_copy(),
color.size(),
);
}
/// The interpolated frame: `display` size, `Rgba16Float`, sampled; its colour is
/// encoded as `Inputs::color`'s is.
pub fn output(&self) -> &wgpu::TextureView {
&self.output
}
}
#[cfg(test)]
mod tests {
use super::super::optical_flow::{OpticalFlow, tests as of};
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("interpolation.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn f16(bits: u16) -> f32 {
let (sign, exp, frac) = (bits >> 15, (bits >> 10) & 0x1f, bits & 0x3ff);
let v = match exp {
0 => f32::from(frac) / 1024.0 * 2f32.powi(-14),
31 => f32::INFINITY,
e => (1.0 + f32::from(frac) / 1024.0) * 2f32.powi(i32::from(e) - 15),
};
if sign == 1 { -v } else { v }
}
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
let size = [512, 288];
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = depth.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.01),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
.iter()
.map(|s| {
let pixels = of::pattern(size, seed, *s);
// COPY_SRC as the frame generation's world texture has.
let t = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
queue.write_texture(
t.as_image_copy(),
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
t.size(),
);
(t.create_view(&Default::default()), pixels)
})
.collect();
for (i, (color, _)) in frames.iter().enumerate() {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(&device, &queue, &mut encoder, color, i == 0);
interpolation.dispatch(
&device,
&queue,
&mut encoder,
&Inputs {
color,
depth: &depth,
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
near: 0.05,
far: 512.0,
fov_y: 1.2,
reset: i == 0,
flow_vectors: flow.vectors(),
scene_change: flow.scene_change(),
},
);
queue.submit([encoder.finish()]);
}
let out = of::read(&device, &queue, interpolation.output.texture(), 8);
let made: Vec<f32> = out
.chunks(2)
.map(|c| f16(u16::from_le_bytes([c[0], c[1]])))
.collect();
let truth = of::pattern(size, seed, expected);
let n = frames.len();
let error = |pixels: &dyn Fn(usize) -> f32| {
let (mut sum, mut count) = (0.0, 0.0);
for y in 32..size[1] as usize - 32 {
for x in 32..size[0] as usize - 32 {
for c in 0..3 {
let i = (y * size[0] as usize + x) * 4 + c;
sum += (pixels(i) - f32::from(truth[i]) / 255.0).abs();
count += 1.0;
}
}
}
sum / count
};
Some([
error(&|i| made[i]),
error(&|i| f32::from(frames[n - 2].1[i]) / 255.0),
error(&|i| f32::from(frames[n - 1].1[i]) / 255.0),
])
}
/// Something moving on its own (the camera still): the optical flow puts the made
/// frame's blocks halfway where its colours match better than the still camera's
/// (FSR prefers the game's vectors unless they clearly don't: primaries make it
/// clear). The first 10 frames after a reset use the game's vectors only.
#[test]
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
else {
return;
};
// Where the still camera's two colours happen to be the same primary (a third of
// the pixels), FSR keeps the game's vectors.
assert!(
made * 3.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// The camera turning: the game's motion vectors put the made frame halfway.
#[test]
fn moves_the_view_halfway_with_the_camera() {
let size = [512.0, 288.0];
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
}
@@ -0,0 +1,803 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's frame interpolation (ffx_frameinterpolation_*.h), ported to WGSL
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
// be read_write everywhere);
// - NaNs AMD's hardware turns into 0 (max(0, NaN)) are avoided up front.
struct Params {
// This frame's unjittered clip space to the previous frame's.
reproject: mat4x4<f32>,
device_to_view: vec4<f32>,
render_size: vec2<i32>,
display_size: vec2<i32>,
// FSR's jitterOffset: render pixels.
jitter: vec2<f32>,
optical_flow_scale: vec2<f32>,
// GetOpticalFlowSize2.
optical_flow_size: vec2<i32>,
reset: u32,
tan_half_fov: f32,
// A mip level's size, for the pyramid passes.
level_size: vec2<i32>,
_pad: vec2<u32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@group(0) @binding(6) var dilated_mv_out: texture_storage_2d<rg32float, write>;
@group(0) @binding(7) var dilated_depth: texture_2d<f32>;
@group(0) @binding(8) var dilated_mv: texture_2d<f32>;
@group(0) @binding(9) var<storage, read_write> rec_prev_depth: array<atomic<u32>>;
@group(0) @binding(10) var<storage, read_write> rec_interp_depth: array<atomic<u32>>;
@group(0) @binding(11) var<storage, read> rec_prev_depth_in: array<u32>;
@group(0) @binding(12) var<storage, read> rec_interp_depth_in: array<u32>;
@group(0) @binding(13) var<storage, read_write> game_field_x: array<atomic<u32>>;
@group(0) @binding(14) var<storage, read_write> game_field_y: array<atomic<u32>>;
@group(0) @binding(15) var<storage, read> game_field_x_in: array<u32>;
@group(0) @binding(16) var<storage, read> game_field_y_in: array<u32>;
@group(0) @binding(17) var<storage, read_write> of_field_x: array<atomic<u32>>;
@group(0) @binding(18) var<storage, read_write> of_field_y: array<atomic<u32>>;
@group(0) @binding(19) var<storage, read> of_field_x_in: array<u32>;
@group(0) @binding(20) var<storage, read> of_field_y_in: array<u32>;
@group(0) @binding(21) var optical_flow: texture_2d<i32>;
@group(0) @binding(22) var<storage, read> scene_change: array<u32, 3>;
@group(0) @binding(23) var<storage, read_write> counters: array<u32, 2>;
@group(0) @binding(24) var<storage, read> counters_in: array<u32, 2>;
// A pyramid's previous level, and the level a pyramid pass writes.
@group(0) @binding(25) var pyramid_in: texture_2d<f32>;
@group(0) @binding(26) var pyramid_out: texture_storage_2d<rgba16float, write>;
// The whole game vector field and inpainting pyramids.
@group(0) @binding(27) var game_pyramid: texture_2d<f32>;
@group(0) @binding(28) var inpainting_pyramid: texture_2d<f32>;
@group(0) @binding(29) var disocclusion_out: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(30) var disocclusion: texture_2d<f32>;
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
const COUNTER_FRAME_INDEX_SINCE_LAST_RESET: u32 = 1u;
fn render_index(p: vec2<i32>) -> u32 {
return u32(p.y * params.render_size.x + p.x);
}
fn is_on_screen(p: vec2<i32>, size: vec2<i32>) -> bool {
return all(vec2<u32>(p) < vec2<u32>(size));
}
fn is_uv_inside(uv: vec2<f32>) -> bool {
return uv.x > 0.0 && uv.x < 1.0 && uv.y > 0.0 && uv.y < 1.0;
}
fn min_divided_by_max(v0: f32, v1: f32) -> f32 {
let m = max(v0, v1);
return select(0.0, min(v0, v1) / m, m != 0.0);
}
fn normalized_dot3(v0: vec3<f32>, v1: vec3<f32>) -> f32 {
let m = max(length(v0), length(v1));
return select(1.0, dot(v0 / m, v1 / m), m > 0.0);
}
fn linear_from_srgb(c: vec3<f32>) -> vec3<f32> {
return select(pow(c / 1.055 + 0.055 / 1.055, vec3(2.4)), c / 12.92, c < vec3(0.04045));
}
fn raw_rgb_to_luminance(c: vec3<f32>) -> f32 {
return dot(linear_from_srgb(c), vec3(0.2126, 0.7152, 0.0722));
}
struct Bilinear {
base: vec2<i32>,
weights: vec4<f32>,
};
fn bilinear(uv: vec2<f32>, size: vec2<i32>) -> Bilinear {
let px = uv * vec2<f32>(size) - 0.5;
let f = fract(px);
var b: Bilinear;
b.base = vec2<i32>(floor(px));
b.weights = vec4(
(1.0 - f.x) * (1.0 - f.y),
f.x * (1.0 - f.y),
(1.0 - f.x) * f.y,
f.x * f.y,
);
return b;
}
fn bilinear_offset(i: i32) -> vec2<i32> {
return vec2(i & 1, i >> 1u);
}
fn device_to_view_depth(d: f32) -> f32 {
return params.device_to_view.y / (d - params.device_to_view.x);
}
fn view_space_position(px: vec2<i32>, size: vec2<i32>, d: f32) -> vec3<f32> {
let z = device_to_view_depth(d);
let ndc = vec2<f32>(px) / vec2<f32>(size) * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
return vec3(params.device_to_view.z * ndc.x * z, params.device_to_view.w * ndc.y * z, z);
}
// MOTION VECTOR FIELD (ffx_frameinterpolation_common.h)
const PRIMARY_VECTOR_BIT: u32 = 0x80000000u;
const PRIORITY_LOW_MAX: u32 = 31u;
const PRIORITY_HIGH_MAX: u32 = 1023u;
const PRIORITY_LOW_OFFSET: u32 = 16u;
const PRIORITY_HIGH_OFFSET: u32 = 21u;
struct VectorFieldEntry {
motion_vector: vec2<f32>,
high_priority: f32,
low_priority: f32,
valid: bool,
primary: bool,
secondary: bool,
in_painted: bool,
velocity: f32,
neg_outside: bool,
pos_outside: bool,
};
fn new_entry() -> VectorFieldEntry {
return VectorFieldEntry(vec2(0.0), 0.0, 0.0, false, false, false, false, 0.0, false, false);
}
fn is_primary(packed: u32) -> bool {
return (packed & PRIMARY_VECTOR_BIT) != 0u;
}
// ffxF32ToF16 and its inverse in integer arithmetic: pack2x16float needs a capability
// some GPUs lack.
fn f32_to_f16(v: f32) -> u32 {
let x = bitcast<u32>(v);
let sign = (x >> 16u) & 0x8000u;
let e = i32((x >> 23u) & 0xffu) - 112;
let m = x & 0x7fffffu;
if e >= 31 {
return sign | 0x7c00u;
}
if e <= 0 {
if e < -10 {
return sign;
}
let full = m | 0x800000u;
let shift = u32(14 - e);
return sign | ((full >> shift) + ((full >> (shift - 1u)) & 1u));
}
return sign | (((u32(e) << 10u) | (m >> 13u)) + ((m >> 12u) & 1u));
}
fn f16_to_f32(h: u32) -> f32 {
let sign = (h & 0x8000u) << 16u;
let e = (h >> 10u) & 0x1fu;
let m = h & 0x3ffu;
if e == 0u {
let v = f32(m) * (1.0 / 16777216.0);
return select(v, -v, sign != 0u);
}
if e == 31u {
return bitcast<f32>(sign | 0x7f800000u);
}
return bitcast<f32>(sign | ((e + 112u) << 23u) | (m << 13u));
}
fn pack_entries(primary: bool, high: u32, low: u32, mv: vec2<f32>) -> vec2<u32> {
let priority = select(0u, PRIMARY_VECTOR_BIT, primary)
| ((high & PRIORITY_HIGH_MAX) << PRIORITY_HIGH_OFFSET)
| ((low & PRIORITY_LOW_MAX) << PRIORITY_LOW_OFFSET);
return vec2(priority | f32_to_f16(mv.x), priority | f32_to_f16(mv.y));
}
fn unpack_entries(packed: vec2<u32>) -> VectorFieldEntry {
var e = new_entry();
e.high_priority = f32((packed.x >> PRIORITY_HIGH_OFFSET) & PRIORITY_HIGH_MAX) / f32(PRIORITY_HIGH_MAX);
e.low_priority = f32((packed.x >> PRIORITY_LOW_OFFSET) & PRIORITY_LOW_MAX) / f32(PRIORITY_LOW_MAX);
e.primary = is_primary(packed.x);
e.valid = e.high_priority > 0.0;
e.secondary = e.valid && !e.primary;
if e.secondary {
e.high_priority = 1.0 - e.high_priority;
}
e.motion_vector = vec2(f16_to_f32(packed.x & 0xffffu), f16_to_f32(packed.y & 0xffffu));
return e;
}
fn load_game_field(p: vec2<i32>) -> vec2<u32> {
if !is_on_screen(p, params.render_size) {
return vec2(0u);
}
let i = render_index(p);
return vec2(game_field_x_in[i], game_field_y_in[i]);
}
fn compute_mv_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var sum = 0.0;
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let s = textureLoad(game_pyramid, p, level);
let weight = b.weights[i] * f32(s.z > 0.0) * s.z;
sum += weight;
color += s * weight;
}
}
return color / select(1.0, sum, sum > 0.0);
}
fn load_inpainted_game_field_mv(uv: vec2<f32>) -> VectorFieldEntry {
let px = vec2<i32>(uv * vec2<f32>(params.render_size));
var e = unpack_entries(load_game_field(px));
if !e.valid {
var size = params.render_size;
var v = vec4(0.0);
let levels = i32(textureNumLevels(game_pyramid));
for (var level = 0; level < min(11, levels) && v.w == 0.0; level++) {
size /= 2;
v = compute_mv_inpainting_level(uv, level, size);
}
e.motion_vector = v.xy;
e.high_priority = v.z;
e.low_priority = v.w;
e.in_painted = true;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn sample_optical_flow_field(uv: vec2<f32>) -> VectorFieldEntry {
let size = params.optical_flow_size;
let b = bilinear(uv, size);
var e = new_entry();
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let w = b.weights[i];
let j = render_index(p);
let s = unpack_entries(vec2(of_field_x_in[j], of_field_y_in[j]));
e.motion_vector += s.motion_vector * w;
e.high_priority += s.high_priority * w;
e.low_priority += s.low_priority * w;
weight_sum += w;
}
}
if weight_sum > 0.0 {
e.motion_vector /= weight_sum;
e.high_priority /= weight_sum;
e.low_priority /= weight_sum;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn has_scene_changed() -> bool {
return (scene_change[1] & 0xfu) != 0u;
}
// ffx_frameinterpolation_reconstruct_dilated_velocity_and_previous_depth.h, with the
// motion vectors computed from the depth and the camera.
fn camera_motion_vector(p: vec2<i32>, depth: f32) -> vec2<f32> {
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size - params.jitter / size;
let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let previous = params.reproject * vec4(ndc, depth, 1.0);
let previous_uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
return previous_uv - uv;
}
fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
}
@compute @workgroup_size(8, 8)
fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let offsets = array(
vec2(0, 0), vec2(1, 0), vec2(0, 1), vec2(0, -1), vec2(-1, 0),
vec2(-1, 1), vec2(1, 1), vec2(-1, -1), vec2(1, -1),
);
var nearest = load_input_depth(p);
var nearest_coord = p;
for (var i = 1; i < 9; i++) {
let q = p + offsets[i];
if is_on_screen(q, params.render_size) {
let d = load_input_depth(q);
if d > nearest {
nearest_coord = q;
nearest = d;
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
let moving = mv * f32(length(mv * vec2<f32>(params.display_size)) > 0.1);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + moving, params.render_size);
for (var i = 0; i < 4; i++) {
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
atomicMax(&rec_prev_depth[render_index(q)], bitcast<u32>(nearest));
}
}
}
}
// ffx_frameinterpolation_setup.h: the frames since the last reset (the vector fields are
// cleared by the encoder).
@compute @workgroup_size(1)
fn setup_pass() {
if params.reset != 0u || has_scene_changed() {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] = 0u;
} else {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] += 1u;
}
}
// ffx_frameinterpolation_reconstruct_previous_depth.h: the interpolated frame's depth.
@compute @workgroup_size(8, 8)
fn reconstruct_previous_depth_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let mv = textureLoad(dilated_mv, p, 0).xy * 0.5;
let depth = textureLoad(dilated_depth, p, 0).x;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + mv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD && is_on_screen(q, params.render_size) {
atomicMax(&rec_interp_depth[render_index(q)], bitcast<u32>(depth));
}
}
}
// ffx_frameinterpolation_game_motion_vector_field.h
fn priority_from_view_depth(view_depth_in: f32) -> u32 {
let d = pow(view_depth_in, 0.33);
let factor = u32((1.0 - d * (1.0 / (1.0 + d))) * f32(PRIORITY_HIGH_MAX));
return max(1u, factor);
}
fn update_game_field(p: vec2<i32>, packed: vec2<u32>) -> u32 {
let i = render_index(p);
let x = atomicMax(&game_field_x[i], packed.x);
let y = atomicMax(&game_field_y[i], packed.y);
return max(x, y);
}
@compute @workgroup_size(8, 8)
fn game_motion_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size;
let depth = textureLoad(dilated_depth, p, 0).x;
let mv = textureLoad(dilated_mv, p, 0).xy;
let half_mv = mv * 0.5;
let interpolated_uv = uv + half_mv;
let high_primary = priority_from_view_depth(device_to_view_depth(depth));
let previous = textureSampleLevel(previous_color, linear_clamp, uv + mv, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
var low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + mv));
let primary = pack_entries(true, high_primary, low, half_mv);
let b = bilinear(interpolated_uv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
update_game_field(q, primary);
}
}
if length(half_mv * size) > EPSILON {
var write_secondary = true;
var primary_hits = 0u;
let step_scale = length(1.0 / size);
let step_mv = normalize(mv);
let break_dist = min(length(half_mv), length(vec2(0.5, 0.5)));
for (var s = step_scale; s <= break_dist && write_secondary; s += step_scale) {
let secondary_uv = interpolated_uv - step_mv * s;
let sb = bilinear(secondary_uv, params.render_size);
let high_secondary = max(1u, PRIORITY_HIGH_MAX - high_primary);
let to_centre_raw = vec2(0.5, 0.5) - secondary_uv;
let to_centre = select(vec2(0.0), normalize(to_centre_raw), length(to_centre_raw) > 0.0);
low = u32(max(0.0, dot(to_centre, step_mv)) * f32(PRIORITY_LOW_MAX));
let secondary = pack_entries(false, high_secondary, low, half_mv);
let q = sb.base;
write_secondary = write_secondary && is_on_screen(q, params.render_size);
if write_secondary {
let existing = update_game_field(q, secondary);
primary_hits += u32(is_primary(existing));
write_secondary = write_secondary && primary_hits <= 3u;
}
}
}
}
// ffx_frameinterpolation_compute_game_vector_field_inpainting_pyramid.h: SPD, a level per
// pass. Level 0 reads the field, the others the level above.
fn reduce_game_field(v: array<vec4<f32>, 4>) -> vec4<f32> {
var sum = vec4(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let w = f32(v[i].z > 0.0);
sum += v[i] * w;
weight_sum += w;
}
return sum / select(1.0, weight_sum, weight_sum > EPSILON);
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let e = unpack_entries(load_game_field(p * 2 + bilinear_offset(i)));
v[i] = vec4(e.motion_vector, e.high_priority, e.low_priority);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
// ffx_frameinterpolation_optical_flow_vector_field.h
fn load_optical_flow(p: vec2<i32>) -> vec2<f32> {
let size = vec2<i32>(textureDimensions(optical_flow));
if !is_on_screen(p, size) {
return vec2(0.0);
}
return vec2<f32>(textureLoad(optical_flow, p, 0).xy) * params.optical_flow_scale;
}
@compute @workgroup_size(8, 8)
fn optical_flow_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
let rect = vec2<f32>(params.display_size);
var avg = vec2(0.0);
var sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let w = max(0.0, 512.0 - len) * f32(len > 1.0);
avg += vs * w;
sw += w;
}
}
// AMD divides by 0 here, and its max(0, NaN) below gives 0 weights: a zero vector.
if sw > 0.0 {
avg /= sw;
}
var v = vec2(0.0);
sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let len_factor = max(0.0, 512.0 - len) * f32(len > 1.0);
let w = pow(max(0.0, dot(avg, vs)), 1.25) * len_factor;
v += vs * w;
sw += w;
}
}
if sw > EPSILON {
v /= sw;
}
// computeOpticalFlowFieldMvs
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.optical_flow_size);
let half_mv = v * 0.5;
let previous = textureSampleLevel(previous_color, linear_clamp, uv + v, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
let velocity = length(v * rect);
let high = u32(velocity > 1.0)
* u32(saturate(velocity / length(rect * 0.05)) * f32(PRIORITY_HIGH_MAX));
if high > 0u {
let low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + v));
let packed = pack_entries(true, high, low, half_mv);
let b = bilinear(uv + half_mv, params.optical_flow_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.optical_flow_size) {
let j = render_index(q);
atomicMax(&of_field_x[j], packed.x);
atomicMax(&of_field_y[j], packed.y);
}
}
}
}
// ffx_frameinterpolation_disocclusion_mask.h
fn load_estimated_depth(which: u32, p: vec2<i32>) -> f32 {
if which == 0u {
return bitcast<f32>(rec_prev_depth_in[render_index(p)]);
}
return textureLoad(dilated_depth, p, 0).x;
}
fn compute_depth_clip(which: u32, uv: vec2<f32>, current_depth: f32) -> f32 {
let current_view = device_to_view_depth(current_depth);
let b = bilinear(uv, params.render_size);
var depth = 0.0;
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
let w = b.weights[i];
if is_on_screen(q, params.render_size) && w > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let previous_depth = load_estimated_depth(which, q);
let previous_view = device_to_view_depth(previous_depth);
let diff = current_view - previous_view;
if diff > 0.0 {
let plane_depth = min(previous_depth, current_depth);
let centre = view_space_position(params.render_size / 2, params.render_size, plane_depth);
let corner = view_space_position(vec2(0), params.render_size, plane_depth);
let half_viewport_width = length(vec2<f32>(params.render_size));
let threshold = min(current_view, previous_view);
let k_fov = length(corner) / length(centre);
let required = 1.37e-05 * k_fov * half_viewport_width * threshold;
depth += f32(required / diff >= 1.0) * w;
weight_sum += w;
}
}
}
return select(0.0, saturate(1.0 - depth / weight_sum), weight_sum > 0.0);
}
@compute @workgroup_size(8, 8)
fn disocclusion_mask_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let depth = bitcast<f32>(rec_interp_depth_in[render_index(p)]);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let game = load_inpainted_game_field_mv(uv);
var mask = vec2(
1.0 - compute_depth_clip(0u, uv + game.motion_vector, depth),
1.0 - compute_depth_clip(1u, uv - game.motion_vector, depth),
);
mask = vec2<f32>(mask >= vec2(EPSILON));
let source_mv = game.motion_vector * 2.0;
let size = vec2<f32>(params.render_size);
let previous = vec2<i32>((uv + source_mv) * size);
mask.x = saturate(mask.x + f32(!is_on_screen(previous, params.render_size)));
let current = vec2<i32>((uv - source_mv) * size);
mask.y = saturate(mask.y + f32(!is_on_screen(current, params.render_size)));
textureStore(disocclusion_out, p, vec4(mask, 0.0, 0.0));
}
// ffx_frameinterpolation.h
struct SourceColor {
raw: vec3<f32>,
weight_sum: f32,
};
fn sample_bilinear(is_current: bool, uv: vec2<f32>, mv: vec2<f32>) -> SourceColor {
let size = params.display_size;
let b = bilinear(uv + mv, size);
var color = vec3(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let w = b.weights[i];
if is_current {
color += textureLoad(current_color, q, 0).rgb * w;
} else {
color += textureLoad(previous_color, q, 0).rgb * w;
}
weight_sum += w;
}
}
return SourceColor(select(vec3(0.0), color / weight_sum, weight_sum != 0.0), weight_sum);
}
@compute @workgroup_size(8, 8)
fn interpolate_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
let frame_index = counters_in[COUNTER_FRAME_INDEX_SINCE_LAST_RESET];
if frame_index == 0u {
textureStore(interpolated_out, p, vec4(textureLoad(current_color, p, 0).rgb, 0.0));
return;
}
var in_painting = 0.0;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
let game = load_inpainted_game_field_mv(uv);
let flow = sample_optical_flow_field(uv);
var disocclusion_factor = vec2<f32>(
saturate(textureSampleLevel(disocclusion, linear_clamp, uv, 0.0).xy) == vec2(1.0),
);
let previous_game = sample_bilinear(false, uv, game.motion_vector);
let current_game = sample_bilinear(true, uv, -game.motion_vector);
let previous_of = sample_bilinear(false, uv, flow.motion_vector);
let current_of = sample_bilinear(true, uv, -flow.motion_vector);
disocclusion_factor.x *= f32(!game.pos_outside);
disocclusion_factor.y *= f32(!game.neg_outside);
in_painting = saturate(max(in_painting, f32(length(disocclusion_factor) <= EPSILON)));
var t = 0.5;
t += 0.5 * (1.0 - disocclusion_factor.x);
t -= 0.5 * (1.0 - disocclusion_factor.y);
var color = mix(previous_game.raw, current_game.raw, saturate(t));
let disoccluded = saturate(1.0 - min(disocclusion_factor.x, disocclusion_factor.y));
if previous_game.weight_sum == 0.0 {
color = current_game.raw;
} else if current_game.weight_sum == 0.0 {
color = previous_game.raw;
}
if previous_game.weight_sum == 0.0 && current_game.weight_sum == 0.0 {
in_painting = 1.0;
}
var of_t = 1.0;
if previous_of.weight_sum > 0.0 && current_of.weight_sum > 0.0 {
of_t = 0.5;
} else if previous_of.weight_sum > 0.0 {
of_t = 0.0;
}
let of_color = mix(previous_of.raw, current_of.raw, of_t);
let of_sim = normalized_dot3(previous_of.raw, current_of.raw);
var game_sim = normalized_dot3(previous_game.raw, current_game.raw);
game_sim = mix(max(EPSILON, game_sim), 1.0, disoccluded);
var game_bias = saturate(game_sim / max(EPSILON, of_sim));
game_bias = mix(game_bias, 1.0, f32(frame_index < 10u));
color = mix(of_color, color, saturate(game_bias));
textureStore(interpolated_out, p, vec4(color, in_painting));
}
// ffx_frameinterpolation_compute_inpainting_pyramid.h: SPD, a level per pass.
fn reduce_inpainting(v: array<vec4<f32>, 4>) -> vec4<f32> {
let sum = v[0].w + v[1].w + v[2].w + v[3].w;
if sum == 0.0 {
return vec4(0.0);
}
return (v[0] * v[0].w + v[1] * v[1].w + v[2] * v[2].w + v[3] * v[3].w) / sum;
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let q = p * 2 + bilinear_offset(i);
var c = vec4(0.0);
if is_on_screen(q, params.display_size) {
c = textureLoad(interpolated, q, 0);
c.w = saturate(1.0 - c.w);
}
v[i] = c;
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
// ffx_frameinterpolation_inpainting.h (no HUD-less source: Horizon draws its HUD over
// the made frame itself).
fn compute_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let s = textureLoad(inpainting_pyramid, q, level);
let w = b.weights[i] * f32(s.w > 0.0);
color += vec4(s.rgb * w, w);
}
}
return color;
}
fn compute_inpainting(p: vec2<i32>) -> vec3<f32> {
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
var color = vec4(0.0);
var size = params.display_size;
let levels = i32(textureNumLevels(inpainting_pyramid));
for (var level = 0; level < min(10, levels); level++) {
size /= 2;
let mip = compute_inpainting_level(uv, level, size);
if mip.w > 0.0 {
let weight = pow(1.0 - f32(level) / 10.0, 3.0) * mip.w;
color += vec4(mip.rgb / mip.w, 1.0) * weight;
}
}
return select(vec3(0.0), color.rgb / color.w, color.w > 0.0);
}
@compute @workgroup_size(8, 8)
fn inpainting_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
var c = textureLoad(interpolated, p, 0);
if c.w > EPSILON {
c = vec4(mix(c.rgb, compute_inpainting(p), c.w), 1.0);
}
textureStore(output, p, c);
}
+90 -4
View File
@@ -91,9 +91,22 @@ pub const TIERS: [Tier; 4] = [
full: 0.38,
},
];
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
/// above the controls instead of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Calibration {
/// 1 black level, 2 peak brightness, 3 paper white.
pub step: u32,
/// The slider's value, in nits.
pub nits: f32,
/// Where the controls start, as a share of the height.
pub band: f32,
}
pub struct Hdr {
layout: wgpu::BindGroupLayout,
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
logo: wgpu::TextureView,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
@@ -120,10 +133,17 @@ pub struct Hdr {
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
impl Hdr {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
@@ -143,6 +163,7 @@ impl Hdr {
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -232,12 +253,13 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
size: 96,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
logo: logo_mask(device, queue),
layout,
pipeline: pipeline_for(output.format()),
clear_alpha,
@@ -254,6 +276,7 @@ impl Hdr {
black: 0.0,
tiers: TIERS,
ui: 1.0,
calibration: None,
}
}
@@ -298,6 +321,10 @@ impl Hdr {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
self.targets = Some(Targets {
@@ -310,6 +337,16 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
pub fn declared_peak(&self) -> f32 {
match self.calibration {
Some(c) if c.step == 2 => 10_000.0,
_ => self.peak,
}
}
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
@@ -430,6 +467,10 @@ impl Hdr {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
queue.write_buffer(
@@ -442,9 +483,10 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
let paper = self.paper_white.max(1.0);
let mut params = [0.0; 20];
let c = self.calibration;
let mut params = [0.0; 24];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -459,6 +501,9 @@ impl Hdr {
params[12 + i] = t.start;
params[16 + i] = t.full;
}
params[20] = c.map_or(0.0, |c| c.step as f32);
params[21] = c.map_or(0.0, |c| c.nits);
params[22] = c.map_or(0.0, |c| c.band);
params
}
@@ -489,6 +534,47 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.map(|p| {
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
255
} else {
0
}
})
.collect();
device
.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Horizon HDR calibration logo"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&mask,
)
.create_view(&Default::default())
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -83,7 +83,8 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
normal: [0; 4],
pos: c,
uv,
layer: 2,
// HDR output: the bolt shines like the sun (entity.wgsl fs_hdr_mark).
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
color,
light: 240 | 240 << 8,
@@ -57,7 +57,8 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
layer: 2,
// The HDR tier lightning.rs adds; the geometry is vanilla's.
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
color,
light: 240 | 240 << 8,
@@ -28,6 +28,9 @@ struct HorizonMaterialOut {
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -45,6 +48,19 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
// The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
// high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
// is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
// interpolated value rounds back exactly.
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
out.face = select(0u, high & 7u, fluid);
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -65,8 +81,13 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent)).color;
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
@@ -91,6 +112,9 @@ pub struct TerrainShape<'a> {
pub sprites: [f64; 2],
/// The world target's colour format.
pub format: wgpu::TextureFormat,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -110,17 +134,34 @@ fn vanilla_terrain() -> String {
)
}
/// The terrain shader for the pack's material stage `source`.
fn shader_source(source: &str) -> String {
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
layers.join(", "),
group[0].1
);
}
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
vanilla_terrain()
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
let text = shader_source(&stage.source);
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -134,7 +175,7 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
) -> Result<Self, String> {
let module = module(stage)?;
let module = module(stage, shape.sprite_tags)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -297,15 +338,22 @@ mod tests {
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
return Surface(apply_fog(sprite(lit)));\n}\n";
let t = vec2<i32>(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let module = module(&stage).unwrap();
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -29,7 +29,20 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let frame = NativeFrame::new(device);
let mut frame = NativeFrame::new(device);
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.is_some_and(|s| s.reads("reflection"))
&& option.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -24,20 +24,87 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
history_blank: wgpu::Texture,
/// One bind group per pass, for these world views and either the valid history or black.
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the stages that read only the frame (the sky).
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
pub opaque_depth: wgpu::Texture,
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
/// Last frame's view-projection, camera and time (`write`).
last: Option<([[f32; 4]; 4], glam::DVec3, std::time::Instant)>,
/// The eased eye light (block, sky) and being in water and lava (`write`); NaN until the
/// first frame sets them.
eye: [f32; 4],
/// A post stage must use black history after a long gap until it has rendered a frame.
history_reset: bool,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
}
/// A depth texture of `size` the world's depth is copied into.
fn depth_copy(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
copy_target(
device,
"Native shader pack opaque depth",
size,
wgpu::TextureFormat::Depth32Float,
)
}
/// A colour texture of `size` and `format` the world's colour is copied into.
fn color_copy(device: &wgpu::Device, size: [u32; 2], format: wgpu::TextureFormat) -> wgpu::Texture {
copy_target(device, "Native shader pack opaque colour", size, format)
}
fn copy_target(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::Texture {
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,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// The uniform `frame` at binding 0, read by `visibility`.
@@ -54,6 +121,24 @@ fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
}
}
/// The size of the water mirror for a world of `size`: half of it.
pub fn reflection_size(size: [u32; 2]) -> [u32; 2] {
size.map(|s| s.div_ceil(2).max(1))
}
/// Seconds between frames past which the last one is forgotten (`NativeFrame::write`).
const MAX_FRAME_GAP: f32 = 0.5;
/// `value` moved toward `target` over `dt` seconds by a half-life in seconds; a NaN `value`
/// (the first frame) starts at `target`.
fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
if value.is_nan() {
target
} else {
target + (value - target) * 0.5f32.powf(dt / half_life)
}
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -64,32 +149,202 @@ impl NativeFrame {
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
device,
"Native shader pack reflection blank",
[1, 1],
wgpu::TextureFormat::Rgba8Unorm,
)
.create_view(&Default::default());
let reflection_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack reflection sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
);
Self {
buffer,
layout,
group,
opaque_depth,
opaque_color,
reflection: blank.clone(),
blank,
reflection_sampler,
reflects: false,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
}
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
})
}
/// Keeps the opaque copies the size of the world's depth `depth` and, where the world's
/// colour `color` can be copied (the scaled target, not the window's own), its size and
/// format; the group is made again when they change.
pub fn fit_opaque(
&mut self,
device: &wgpu::Device,
depth: &wgpu::Texture,
color: Option<&wgpu::Texture>,
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
let color_fits = [self.opaque_color.width(), self.opaque_color.height()] == color_size
&& self.opaque_color.format() == format;
let depth_fits = [self.opaque_depth.width(), self.opaque_depth.height()] == size;
if depth_fits && color_fits {
return;
}
if !color_fits {
self.opaque_color = color_copy(device, color_size, format);
}
if !depth_fits {
self.opaque_depth = depth_copy(device, size);
}
self.regroup(device);
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
let view = view.unwrap_or(&self.blank);
if *view != self.reflection {
self.reflection = view.clone();
self.regroup(device);
}
}
fn regroup(&mut self, device: &wgpu::Device) {
self.group = Self::group(
device,
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
);
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let inputs = frame_inputs(frame, size, seconds, self.frames);
let now = std::time::Instant::now();
let since = 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, self.frames);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
}
let view_proj = (Mat4::from_cols_array_2d(&inputs.projection)
* Mat4::from_cols_array_2d(&inputs.view))
.to_cols_array_2d();
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.
match self.last {
Some((previous, camera, time)) if (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();
}
_ => {
self.eye = [f32::NAN; 4];
self.history_reset = true;
}
}
self.last = Some((view_proj, frame.camera, now));
// The eye's light and medium eased by half-lives in seconds, from where they are at
// first.
let targets = [
(inputs.eye_light[0], 2.0),
(inputs.eye_light[1], 2.0),
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = inputs.delta_time;
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
}
let [block, sky, water, lava] = self.eye;
inputs.eye_light_smooth = [block, sky];
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));
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
/// skipped for a frame, so the first frame it does render still sees black history.
pub(super) fn take_history_reset(&mut self) -> bool {
let reset = self.history_reset;
self.history_reset = false;
reset
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
@@ -151,6 +406,7 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -218,7 +474,10 @@ impl NativePost {
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
history: None,
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
format,
})
}
@@ -233,6 +492,7 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
@@ -248,8 +508,10 @@ impl NativePost {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
// COPY_SRC: the last pass's input is kept as next frame's `history`.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
@@ -261,15 +523,28 @@ impl NativePost {
target("Native shader pack B"),
],
));
self.history = Some(history_target(device, "Native shader pack history", size));
self.history_valid = false;
self.groups = None;
}
let blank_history = history_reset || !self.history_valid;
if self
.groups
.as_ref()
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
})
{
return;
}
let history = if blank_history {
&self.history_blank
} else {
self.history
.as_ref()
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
@@ -305,11 +580,15 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -321,13 +600,14 @@ impl NativePost {
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size);
self.prepare(device, world, size, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
let Some((_, _, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
@@ -354,9 +634,47 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
self.history_valid = true;
}
}
}
/// A target of `size` the post stage's `history` is copied into.
fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::Texture {
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::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// `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 {
let half_life = if rain > wetness { 30.0 } else { 10.0 };
rain + (wetness - rain) * 0.5f32.powf(dt / half_life)
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
@@ -416,14 +734,42 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
},
frame_index: index,
hide_gui: u32::from(pack.hide_gui),
precipitation: pack.biome_precipitation.clamp(0, 2) as u32,
temperature: pack.temperature,
downfall: pack.rainfall,
wetness: pack.rain,
// Filled by `NativeFrame::write`, which remembers the frames before.
previous_view_projection: m(Mat4::IDENTITY),
camera_motion: [0.0; 3],
delta_time: 0.0,
eye_light_smooth: [level(pack.eye_light[0]), level(pack.eye_light[1])],
in_water: f32::from(u8::from(pack.eye_in as u32 == 1)),
in_lava: f32::from(u8::from(pack.eye_in as u32 == 2)),
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
}
}
#[cfg(test)]
mod tests {
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
assert_eq!(eased(f32::NAN, 0.25, 2.0, 0.0), 0.25);
assert_eq!(eased(1.0, 0.0, 2.0, 2.0), 0.5);
assert_eq!(eased(1.0, 0.0, 2.0, 0.0), 1.0);
}
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
#[test]
fn wetness_rises_slower_than_it_dries() {
assert_eq!(wetness(0.0, 1.0, 30.0), 0.5);
assert_eq!(wetness(1.0, 0.0, 10.0), 0.5);
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -521,7 +867,15 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
true,
None,
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
@@ -552,4 +906,111 @@ mod tests {
"{pixel:?}"
);
}
/// `history` is black on the first frame, then what the pass before the last wrote the
/// frame before.
#[test]
fn the_last_pass_reads_last_frames_input_as_history() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1
id = \"t\"
name = \"T\"
version = \"1\"
api = \"0.1\"
[[post]]
file = \"a.wgsl\"
[[post]]
file = \"b.wgsl\"
";
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, 1.0); }
";
let b = "fn post(input: PostInput) -> vec4<f32> { return textureLoad(history, vec2<i32>(input.pixel), 0); }
";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
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 view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut frame_pixel = |history_reset| {
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
history_reset,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
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();
buffer.unmap();
pixel
};
assert_eq!(
frame_pixel(true)[..3],
[0, 0, 0],
"black on the first frame"
);
let second = frame_pixel(false);
assert!(
second[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{second:?}"
);
assert_eq!(frame_pixel(true)[..3], [0, 0, 0], "black after a frame gap");
let fourth = frame_pixel(false);
assert!(
fourth[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{fourth:?}"
);
}
}
@@ -128,6 +128,9 @@ struct Graph {
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
/// the camera's.
entered: Vec<u8>,
/// Whether any section in the searched top layer was reached. Sections
/// above a clamped box use this conservative sky-band answer.
sky_reached: bool,
/// Per section corner, during a full search: `SEES`, or how far the line
/// from it towards the camera is known to stay in reached sections (the
/// first sample that was not). Sections are only ever added during a
@@ -156,8 +159,22 @@ impl Graph {
)
}
fn contains(&self, key: SectionKey) -> bool {
self.index(key)
.is_some_and(|i| self.directions[i] & REACHED != 0)
if let Some(i) = self.index(key) {
self.directions[i] & REACHED != 0
} else if (0..=self.sections).contains(&key.1)
&& key.1 >= self.y0 + self.height
&& (key.0 - self.from.0).abs() <= self.radius
&& (key.2 - self.from.2).abs() <= self.radius
{
// Everything above the highest non-air section is open sky. A
// full search can reach that layer from a neighbouring column,
// so checking only this column's top cell can hide an upper
// section that is visible through the open layer. Once any top
// cell is reached, conservatively keep the whole sky band visible.
self.sky_reached
} else {
false
}
}
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
fn search(
@@ -174,13 +191,37 @@ impl Graph {
self.min_y = columns.min_y;
self.sections = columns.sections;
self.y0 = -1;
self.height = columns.sections + 2;
if from.1 >= columns.sections {
self.height = columns.sections + 2;
} else {
let mut max_non_air_y = 0;
for dz in -radius..=radius {
for dx in -radius..=radius {
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
continue;
};
for (y, &v) in column.iter().enumerate().rev() {
let y = y as i32;
if y <= max_non_air_y {
break;
}
if v != ALL_VISIBLE {
max_non_air_y = y;
break;
}
}
}
}
let top_y = max_non_air_y.max(from.1);
self.height = (top_y + 3).max(3).min(columns.sections + 2);
}
let side = (2 * radius + 1) as usize;
let size = side * side * self.height as usize;
self.directions.clear();
self.directions.resize(size, 0);
self.entered.clear();
self.entered.resize(size, 0);
self.sky_reached = false;
self.rays.clear();
self.rays
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
@@ -194,7 +235,8 @@ impl Graph {
let base = self
.index((from.0 + dx, 0, from.2 + dz))
.expect("column inside the searched box");
vis[base..base + column.len()].copy_from_slice(column);
let len = (column.len() as i32).min(self.height - 1) as usize;
vis[base..base + len].copy_from_slice(&column[..len]);
}
}
queue.clear();
@@ -279,6 +321,9 @@ impl Graph {
while let Some(index) = queue.pop_front() {
let i = index as usize;
let key = self.key(i);
if key.1 == self.y0 + self.height - 1 {
self.sky_reached = true;
}
let directions = self.directions[i];
let entered = self.entered[i];
let exits = if entered == 0 {
@@ -1082,6 +1127,64 @@ mod tests {
);
}
/// A clamped search may conservatively show sections above its searched
/// box, but it must never hide one that the full-height search reaches.
#[test]
fn vertical_clamping_only_adds_overdraw() {
let mut compared = 0;
let mut overdraw = 0;
let mut clamped = 0;
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
let mut o = Occlusion::new(0, 24);
random_world(&mut o, seed, count, spread);
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
o.settle(camera, radius);
let graph = o.graph.as_ref().expect("searched");
clamped += usize::from(graph.height < o.columns.sections + 2);
let full = reference(&o.columns, camera, radius, true);
let from = o.columns.section_of(camera);
let side = 2 * radius + 1;
let height = o.columns.sections + 2;
let full_contains = |key: SectionKey| {
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
return false;
}
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
};
for x in from.0 - radius..=from.0 + radius {
for z in from.2 - radius..=from.2 + radius {
for y in -1..=o.columns.sections {
let key = (x, y, z);
let expected = full_contains(key);
let actual = o.contains(key);
compared += 1;
assert!(
!expected || actual,
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
graph.y0,
graph.height
);
overdraw += usize::from(actual && !expected);
if actual && !expected {
assert!(
y >= graph.y0 + graph.height,
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
graph.y0,
graph.height
);
}
}
}
}
}
}
assert!(clamped > 0, "the comparison exercised a clamped search");
assert!(compared > 50_000, "compared {compared} sections");
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
}
/// A line checked again from the sample it failed at, after more
/// sections were reached, ends where checking it from its corner does.
#[test]
@@ -1226,4 +1329,26 @@ mod tests {
assert_eq!(graph.entered, local.entered);
}
}
#[test]
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
let mut o = Occlusion::new(0, 24);
o.set((0, 5, 0), 0); // Wall at section 5
o.settle(at((0, 2, 0)), 4);
let graph = o.graph.as_ref().expect("searched");
assert_eq!(graph.height, 5 + 3);
assert!(o.contains((0, 2, 0)), "camera section is reached");
assert!(o.contains((1, 20, 1)), "open air column is reached");
// An enclosed cave cannot reach the sky
let mut cave = Occlusion::new(0, 24);
for (dx, dy, dz) in STEPS {
cave.set((dx, 1 + dy, dz), 0);
}
cave.settle(at((0, 1, 0)), 4);
assert!(
!cave.contains((0, 10, 0)),
"enclosed cave does not see the sky"
);
}
}
@@ -188,7 +188,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("entity shadows", "shadow", None, shadow_bias),
kind(
"entity shadows",
"shadow",
Some(wgpu::Face::Back),
shadow_bias,
),
),
swirl: set(
&entity_module,
@@ -73,6 +73,8 @@ impl DrawUniforms {
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
// `blockEntityId` outside block entities, as Iris has it.
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -106,7 +106,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let 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\
@@ -115,7 +115,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.to_owned();
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
@@ -123,7 +123,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV2", "ivec2(0, 240)"),
("vaUV2", "ivec2(240, 240)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
@@ -349,10 +349,8 @@ impl PackEntities {
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
.iter()
.enumerate()
{
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
@@ -411,12 +409,9 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter().step_by(4).all(|v| {
!matches!(
v.layer & !super::entity_batch::OIT_ENTITY,
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
)
})
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// The programs of entity runs, in the order they draw.
@@ -27,12 +27,11 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
inputs
}
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
/// `MC_HAND_DEPTH`.
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
/// The hand's projection in GL's conventions: `hud_fov` degrees, depth scaled by
/// `MC_HAND_DEPTH` (the view bob is in `gbufferModelView`).
pub fn projection(hud_fov: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
* bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
@@ -153,7 +152,7 @@ mod tests {
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
let p = projection(70.0, 16.0 / 9.0, 512.0);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
@@ -0,0 +1,238 @@
//! A shader pack's own images (`image.<name>`) and storage buffers (`bufferObject.<n>`) on the
//! GPU: created at the render size they depend on, kept across frames, images cleared before a
//! frame when the pack asks.
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
pub(super) format: wgpu::TextureFormat,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) view: Option<wgpu::TextureView>,
/// The render size the image was made for (relative images follow it).
made_for: [u32; 2],
}
impl PackImage {
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false })
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
},
def,
view: None,
made_for: [0; 2],
})
.collect()
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(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, _) => 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),
(Kind::Unorm, _) => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
}
}
/// 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);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
"{format:?} needs {missing:?}, which this GPU lacks"
));
}
let limits = device.limits();
let largest = if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
};
let [width, height, depth] = def.extent([1, 1]);
let relative = matches!(def.size, horizon_shaders::ImageSize::Relative(_));
if !relative && width.max(height).max(depth) > largest {
return Err(format!(
"{width}x{height}x{depth} is larger than this GPU's {largest}"
));
}
Ok(())
}
/// Makes the images that are missing or sized for another render size; whether it made any.
pub(super) fn resize_images(
device: &wgpu::Device,
images: &mut [PackImage],
size: [u32; 2],
) -> bool {
let largest = device.limits().max_texture_dimension_2d;
let mut made = false;
for image in images {
let relative = matches!(image.def.size, horizon_shaders::ImageSize::Relative(_));
if image.view.is_some() && (!relative || image.made_for == size) {
continue;
}
made = true;
// A relative image larger than the render size stops at the device's limit.
let [width, height, depth] = image.def.extent(size);
let [width, height] = [width.min(largest), height.min(largest)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&image.def.name),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
image.view = Some(texture.create_view(&Default::default()));
image.made_for = size;
}
made
}
/// Clears the images the pack clears before each frame.
pub(super) fn clear(encoder: &mut wgpu::CommandEncoder, images: &[PackImage]) {
for view in images
.iter()
.filter(|i| i.def.clear)
.filter_map(|i| i.view.as_ref())
{
encoder.clear_texture(view.texture(), &Default::default());
}
}
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
pub(super) fn new(device: &wgpu::Device, objects: BTreeMap<u32, BufferObject>) -> Self {
let entries: Vec<_> = objects
.keys()
.map(|&binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack buffers"),
entries: &entries,
});
Self {
layout,
objects,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
}
}
/// Makes the buffers (again, when one depends on the render size and it changed): their
/// contents start zeroed and persist across frames.
pub(super) fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
let relative = self
.objects
.values()
.any(|o| matches!(o, BufferObject::PerPixel { .. }));
if self.group.is_some() && (!relative || self.made_for == size) {
return;
}
let limits = device.limits();
let largest = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size)
& !3;
self.buffers = self
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
.objects
.keys()
.zip(&self.buffers)
.map(|(&binding, b)| wgpu::BindGroupEntry {
binding,
resource: b.as_entire_binding(),
})
.collect();
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack buffers"),
layout: &self.layout,
entries: &entries,
}));
self.made_for = size;
}
/// Whether the pack declares buffer `binding`.
pub(super) fn has(&self, binding: u32) -> bool {
self.objects.contains_key(&binding)
}
}
File diff suppressed because it is too large. Load diff
@@ -82,9 +82,9 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}}\n \
hz_Vertex = vec4(hz_p, 1.0);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n"
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
);
horizon_shaders::GbuffersInputs {
declarations,
@@ -109,7 +109,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
.into(),
body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
@@ -0,0 +1,339 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
View(wgpu::TextureView),
Lightmap,
Atlas,
}
pub(super) struct PackTexture {
pub(super) stage: String,
pub(super) name: String,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
pub(super) image: Image,
}
/// Uploads `textures`, with a note for each the game can't provide.
pub(super) fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Vec<CustomTexture>,
notes: &mut Vec<String>,
) -> Vec<PackTexture> {
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
continue;
}
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureViewDimension::D1,
TextureDim::D2 => wgpu::TextureViewDimension::D2,
TextureDim::D3 => wgpu::TextureViewDimension::D3,
};
(
Image::View(view),
dimension,
sample_type,
texture.blur,
texture.clamp,
)
}
TextureSource::Resource(location) => {
let path = location.strip_prefix("minecraft:").unwrap_or(&location);
let image = if path.starts_with("dynamic/lightmap") {
Image::Lightmap
} else if path.starts_with("textures/atlas/blocks") || path == "dynamic/atlas" {
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
));
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
}
};
out.push(PackTexture {
stage: t.stage,
name: t.name,
dimension,
sample_type,
blur,
clamp,
image,
});
}
out
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
Group::ShadowComp => "shadowcomp",
}
}
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
} else if ty.contains("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
})
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
.iter()
.find(|t| t.stage == "noise" && t.dimension == wgpu::TextureViewDimension::D2)
.and_then(|t| match &t.image {
Image::View(v) => Some(v),
_ => None,
})
}
/// The wgpu format a texture of `texture.format` is stored in: sampled, never rendered to, so
/// any format whose texels hold the values.
fn format(texture: &Texture) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let f = texture.format;
let pick = |r, rg, rgba| match texture.components() {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
(Kind::Snorm, 8) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
// 16-bit and packed normalized formats, half floats and the small floats: halves hold
// them closely enough.
_ => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
}
}
/// `texture`'s texels as the bytes of `format`, row after row.
fn bytes(texture: &Texture, format: wgpu::TextureFormat) -> Vec<u8> {
match (&texture.texels, format) {
(Texels::Float(v), f) if f.block_copy_size(None) == Some(texture.components() as u32) => {
if matches!(
f,
wgpu::TextureFormat::R8Snorm
| wgpu::TextureFormat::Rg8Snorm
| wgpu::TextureFormat::Rgba8Snorm
) {
v.iter()
.map(|x| (x.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8)
.collect()
} else {
v.iter()
.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect()
}
}
(Texels::Float(v), f)
if f.block_copy_size(None) == Some(4 * texture.components() as u32) =>
{
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
(Texels::Float(v), _) => v
.iter()
.flat_map(|&x| half::f16::from_f32(x).to_le_bytes())
.collect(),
(Texels::Int(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
(Texels::Uint(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
}
}
fn create(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &Texture,
features: wgpu::Features,
) -> Result<(wgpu::TextureView, wgpu::TextureSampleType), String> {
let format = format(texture);
let bytes = bytes(texture, format);
let [width, height, depth] = texture.size;
let limits = device.limits();
let largest = match texture.dim {
TextureDim::D1 => limits.max_texture_dimension_1d,
TextureDim::D2 => limits.max_texture_dimension_2d,
TextureDim::D3 => limits.max_texture_dimension_3d,
};
if [width, height, depth].iter().any(|&n| n > largest) {
return Err(format!(
"{width}x{height}x{depth} is larger than this device's {largest}"
));
}
let row = width * format.block_copy_size(None).unwrap_or(4);
if bytes.len() as u64 != u64::from(row) * u64::from(height) * u64::from(depth) {
return Err(format!(
"{} bytes of texels for {width}x{height}x{depth}",
bytes.len()
));
}
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureDimension::D1,
TextureDim::D2 => wgpu::TextureDimension::D2,
TextureDim::D3 => wgpu::TextureDimension::D3,
};
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack custom texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
tex.as_image_copy(),
&bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(height),
},
size,
);
let sample_type = format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false });
Ok((tex.create_view(&Default::default()), sample_type))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shaders::Format;
fn lightmap(stage: &str, name: &str, dimension: wgpu::TextureViewDimension) -> PackTexture {
PackTexture {
stage: stage.into(),
name: name.into(),
dimension,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
image: Image::Lightmap,
}
}
fn texture(format: &str, texels: Texels) -> Texture {
Texture {
dim: TextureDim::D2,
size: [1, 1, 1],
format: Format::parse(format).unwrap(),
texels,
blur: false,
clamp: false,
}
}
#[test]
fn samplers_find_their_stage_name_and_dimension() {
use wgpu::TextureViewDimension as D;
let textures = [
lightmap("composite", "colortex4", D::D2),
lightmap("composite", "noisetex", D::D3),
lightmap("gbuffers", "gaux2", D::D2),
lightmap("composite", "noisetex", D::D1),
];
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), Some(0));
assert_eq!(
find(&textures, Group::Composite, "sampler3D", "noisetex"),
Some(1)
);
assert_eq!(
find(&textures, Group::Composite, "sampler1D", "noisetex"),
Some(3)
);
assert_eq!(
find(&textures, Group::Composite, "sampler2D", "noisetex"),
None
);
assert_eq!(
find(&textures, Group::Shadow, "sampler2D", "colortex5"),
Some(2)
);
assert_eq!(
find(&textures, Group::Deferred, "sampler2D", "colortex4"),
None
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
assert_eq!(format(&t), wgpu::TextureFormat::Rgba8Unorm);
assert_eq!(bytes(&t, format(&t)), [0, 128, 255, 255]);
let t = texture("RG8_SNORM", Texels::Float(vec![-1.0, 0.5]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg8Snorm);
assert_eq!(bytes(&t, format(&t)), [0x81, 64]);
let t = texture("R32F", Texels::Float(vec![1.0]));
assert_eq!(bytes(&t, format(&t)), 1.0f32.to_le_bytes());
let t = texture("R16F", Texels::Float(vec![1.0]));
assert_eq!(format(&t), wgpu::TextureFormat::R16Float);
assert_eq!(bytes(&t, format(&t)), [0x00, 0x3c]);
let t = texture("RG16I", Texels::Int(vec![-2, 3]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg16Sint);
assert_eq!(bytes(&t, format(&t)), [0xfe, 0xff, 3, 0]);
let t = texture("R8UI", Texels::Uint(vec![7]));
assert_eq!(format(&t), wgpu::TextureFormat::R8Uint);
assert_eq!(bytes(&t, format(&t)), [7]);
}
}
@@ -30,6 +30,8 @@ pub struct PackInputs {
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
/// Whether the biome at the camera is known (its chunk is loaded).
pub biome_known: bool,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
@@ -282,14 +284,15 @@ impl FrameUniforms {
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
// 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.
let view = frame.bob * frame.spin * crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
) * frame.bob
* frame.spin;
);
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
@@ -2,7 +2,9 @@
//! per-frame culling walk: a column whose box misses the frustum or lies past
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
use super::{Frustum, SectionKey};
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
@@ -226,7 +228,7 @@ fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
frustum: &impl Cull,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
@@ -258,7 +260,7 @@ impl<T> Resident<T> {
&self,
camera: DVec3,
radius: i32,
frustum: &Frustum,
frustum: &impl Cull,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec<Visible>,
@@ -335,8 +335,11 @@ impl Post {
view_formats: &[],
})
};
let sampled =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
// COPY_SRC: a native pack's material stage reads copies of them (`opaque_depth`,
// `opaque_color`).
let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC;
let color = make_tex("Scaled world", size, self.format, sampled)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling and TAA read it.
+11 -6
View File
@@ -334,7 +334,7 @@ pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
}
/// A LOD distance as far terrain uses it on this adapter.
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
let distance = distance.min(1024);
let distance = distance.min(crate::lod::MAX_CHUNKS);
if lod_capped(info) {
distance.min(INTEGRATED_LOD_DISTANCE)
} else {
@@ -472,7 +472,9 @@ impl Graphics {
let number = |d: u32| value.parse().unwrap_or(d);
match key.trim() {
"preset" => g.preset = Preset::parse(value),
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
"lodDistance" => {
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
@@ -499,7 +501,7 @@ impl Graphics {
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(80, 480),
n => n.clamp(10, 480),
}
}
"hdrPeak" => {
@@ -717,7 +719,7 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -774,12 +776,15 @@ mod tests {
#[test]
fn lod_distance_is_opt_in_and_bounded() {
assert_eq!(Graphics::default().lod_distance, 0);
for distance in [0, 64, 128, 256, 512, 1024] {
for distance in [0, 64, 128, 256, 512, 1024, 65_536, crate::lod::MAX_CHUNKS] {
let g = Graphics::parse(&format!("lodDistance:{distance}\n"));
assert_eq!(g.lod_distance, distance);
assert_eq!(Graphics::parse(&g.serialize()), g);
}
assert_eq!(Graphics::parse("lodDistance:10000\n").lod_distance, 1024);
assert_eq!(
Graphics::parse("lodDistance:99999999\n").lod_distance,
crate::lod::MAX_CHUNKS
);
assert_eq!(Graphics::parse("lodDistance:-1\n").lod_distance, 0);
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
}
@@ -147,6 +147,34 @@ impl Style {
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
+596 -27
View File
@@ -46,6 +46,8 @@ pub enum H {
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
/// Starts HDR calibration (`crate::hdr_calibration`).
HdrCalibrate,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
@@ -136,6 +138,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrCalibrate,
None,
"Sets the three below full screen, one test picture at a time.",
),
h(
H::HdrBlackLevel,
None,
@@ -339,6 +346,28 @@ pub fn rows(level: Level) -> Vec<Row> {
}
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
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
0,
64,
128,
256,
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
131072,
262144,
524288,
1048576,
2097152,
crate::lod::MAX_CHUNKS,
];
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
/// Upscaling is only there while TAA is, which does it.
@@ -362,6 +391,46 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
rows
}
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
fn setup(s: &Settings) -> Setup {
setup_of(&s.horizon, crate::graphics::reported().is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
}
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
match (item, setup(s)) {
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
_ => text,
}
}
fn on_off(on: bool) -> String {
lang::get(if on { "On" } else { "Off" })
}
@@ -384,13 +453,17 @@ impl H {
.unwrap_or(UPLOAD_STEPS.len() - 1);
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
}
H::LodDistance => {
let i = LOD_STEPS
.iter()
.position(|&d| d >= g.lod_distance)
.unwrap_or(LOD_STEPS.len() - 1);
Some(i as f32 / (LOD_STEPS.len() - 1) as f32)
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
// Auto, then 80..480 by 10.
H::HdrPaperWhite => Some(match g.hdr_paper_white {
0 => 0.0,
n => (n as f32 - 70.0) / 410.0,
}),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
@@ -401,6 +474,19 @@ impl H {
_ => None,
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
H::ChunkUpdates => Some(64),
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
}
}
fn label(self, s: &Settings) -> Text {
let g = &s.horizon;
match self {
@@ -454,6 +540,11 @@ impl H {
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
@@ -499,14 +590,12 @@ impl H {
g.upload_budget_kib =
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
}
H::LodDistance => {
g.lod_distance = LOD_STEPS[(v * (LOD_STEPS.len() - 1) as f32).round() as usize]
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::HdrPaperWhite => {
g.hdr_paper_white = match (v * 41.0).round() as u32 {
0 => 0,
step => 70 + step * 10,
}
}
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
0 => 0,
@@ -532,9 +621,6 @@ impl H {
}
let g = &mut s.horizon;
match self {
H::LodDistance => {
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
}
H::FarDetail => {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
@@ -604,7 +690,11 @@ impl Item {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
_ => None,
}
@@ -700,6 +790,39 @@ impl Item {
s.horizon.preset = Preset::Custom;
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
Item::Vanilla(o) => o.slider_steps(),
Item::Horizon(h) => h.slider_steps(),
}
}
pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
if !self.available() || self.blocked(s).is_some() {
return;
}
let Some(pos) = self.slider(s) else {
return;
};
let next_pos = if let Some(steps) = self.slider_steps() {
let steps = steps as f32;
let current = (pos * steps).round();
let next = if forward {
current + 1.0
} else {
current - 1.0
};
(next / steps).clamp(0.0, 1.0)
} else {
let delta = 1.0 / (width - 8.0).max(1.0);
if forward {
(pos + delta).min(1.0)
} else {
(pos - delta).max(0.0)
}
};
self.set_slider(s, next_pos);
self.touched(s);
}
}
/// What the options screens must do after an interaction here.
@@ -713,11 +836,13 @@ pub enum Action {
OpenHdr,
/// Wait for a key or mouse button to bind to this name.
BindKey(&'static str),
/// Start HDR calibration.
Calibrate,
Done,
}
#[derive(Clone, Copy)]
enum Target {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Item(Item),
Button(Action),
}
@@ -735,6 +860,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
pub focus: Option<Target>,
pub can_change_value: bool,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
@@ -743,6 +870,8 @@ impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = ui::Scroll::default();
self.dragging = None;
self.focus = None;
self.can_change_value = false;
}
/// The scrollbar's column: right of the descriptions.
fn scrollbar_x(w: f32) -> f32 {
@@ -860,8 +989,10 @@ impl GraphicsUi {
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
let is_focused = self.focus == Some(wd.target);
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom)))
|| is_focused;
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
@@ -882,13 +1013,15 @@ impl GraphicsUi {
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
Some(value) => STYLE.slider_ext(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
value,
hover || self.dragging == Some(item),
is_focused,
self.can_change_value && is_focused,
live,
self.dragging == Some(item),
),
@@ -907,7 +1040,7 @@ impl GraphicsUi {
wd.x + wd.w + STYLE.gap,
wd.y + 1.0,
cost,
text,
description(item, s, text),
);
}
Target::Button(action) => {
@@ -993,13 +1126,26 @@ impl GraphicsUi {
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
continue;
}
self.focus = Some(wd.target);
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
Target::Button(action) => {
self.can_change_value = false;
vec![Action::ButtonClick, action]
}
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
self.can_change_value = false;
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
s.horizon.hdr_peak = 0;
s.horizon.hdr_paper_white = 0;
vec![Action::ButtonClick, Action::Changed]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) => {
vec![Action::ButtonClick, Action::Calibrate]
}
Target::Item(Item::Horizon(H::HdrKey)) => {
vec![
Action::ButtonClick,
@@ -1008,10 +1154,12 @@ impl GraphicsUi {
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = true;
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
self.can_change_value = false;
item.cycle(s, right, framebuffer);
vec![Action::ButtonClick, Action::Changed]
}
@@ -1031,6 +1179,246 @@ impl GraphicsUi {
self.scroll.clamp(top, bottom, content);
self.scroll.wheel(amount, 18.0, top, bottom, content);
}
/// Mouse wheel over a control cycles it (cycle button). Sliders and disabled
/// rows fall through to scroll the page, matching Vanilla list behavior.
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle item is keyboard-focused (`self.focus`), the wheel steps/cycles it
/// regardless of cursor position. Vanilla 26.3 does not intercept the
/// wheel for sliders at all.
pub fn wheel(
&mut self,
level: Level,
s: &mut Settings,
cursor: (f32, f32),
(w, h): (f32, f32),
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Action> {
// Horizon improvement: focused slider/cycle intercepts wheel first.
if amount != 0.0
&& self.can_change_value
&& let Some(Target::Item(item)) = self.focus
&& item.available()
&& item.blocked(s).is_none()
{
if item.slider(s).is_some() {
item.step_slider(s, amount > 0.0, w);
return vec![Action::SliderDrag, Action::Changed];
} else if !matches!(item, Item::Horizon(H::HdrSettings)) {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
}
let (widgets, _, _) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let hovered = widgets.iter().find(|wd| {
ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list
|| (cursor.1 >= top
&& cursor.1 < bottom
&& wd.y >= top
&& wd.y + 20.0 <= bottom))
});
if let Some(wd) = hovered
&& let Target::Item(item) = wd.target
&& item.available()
&& item.blocked(s).is_none()
&& item.slider(s).is_none()
&& !matches!(item, Item::Horizon(H::HdrSettings))
{
if amount != 0.0 {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
return Vec::new();
}
self.scroll(level, s, h, amount);
Vec::new()
}
/// Keyboard navigation and value adjustments.
pub fn key_modifiers(
&mut self,
level: Level,
s: &mut Settings,
key: winit::keyboard::KeyCode,
shift: bool,
(w, h): (f32, f32),
framebuffer: [i32; 2],
) -> (Vec<Action>, bool) {
use winit::keyboard::KeyCode as K;
let (widgets, _, content) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let focusables: Vec<(Target, [f32; 4], bool)> = widgets
.iter()
.filter_map(|wd| match wd.target {
Target::Item(item) if item.available() && item.blocked(s).is_none() => {
Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list))
}
Target::Button(_) => Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list)),
_ => None,
})
.collect();
if focusables.is_empty() {
return (Vec::new(), false);
}
// 1. Tab / Shift+Tab navigation
if key == K::Tab {
let cur = self
.focus
.and_then(|f| focusables.iter().position(|&(t, ..)| t == f));
let next_idx = match cur {
Some(i) => {
if shift {
if i == 0 { focusables.len() - 1 } else { i - 1 }
} else {
(i + 1) % focusables.len()
}
}
None => {
if shift {
focusables.len() - 1
} else {
0
}
}
};
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
let is_slider = matches!(target, Target::Item(item) if item.slider(s).is_some());
self.can_change_value = is_slider;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
// 2. Left / Right Arrow keys
if matches!(key, K::ArrowLeft | K::ArrowRight) {
let forward = key == K::ArrowRight;
if let Some(Target::Item(item)) = self.focus
&& item.slider(s).is_some()
&& self.can_change_value
{
let width = widgets
.iter()
.find(|wd| wd.target == Target::Item(item))
.map_or(STYLE.control_width, |wd| wd.w);
item.step_slider(s, forward, width);
return (vec![Action::SliderDrag], true);
}
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if forward {
crate::gamepad::Button::DPadRight
} else {
crate::gamepad::Button::DPadLeft
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
}
return (Vec::new(), false);
}
// 3. Up / Down Arrow keys
if matches!(key, K::ArrowUp | K::ArrowDown) {
let down = key == K::ArrowDown;
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if down {
crate::gamepad::Button::DPadDown
} else {
crate::gamepad::Button::DPadUp
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
} else if !focusables.is_empty() {
let first = if down { 0 } else { focusables.len() - 1 };
let (target, [_, wy, _, wh], is_list) = focusables[first];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
return (Vec::new(), false);
}
// 4. Space / Enter / NumpadEnter
if matches!(key, K::Space | K::Enter | K::NumpadEnter)
&& let Some(target) = self.focus
{
match target {
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
return (Vec::new(), true);
} else if item == Item::Horizon(H::HdrSettings) {
return (vec![Action::ButtonClick, Action::OpenHdr], true);
} else {
item.cycle(s, shift, framebuffer);
return (vec![Action::ButtonClick, Action::Changed], true);
}
}
Target::Button(action) => {
return (vec![Action::ButtonClick, action], true);
}
}
}
// 5. Escape: the screen closes on the first press, as `Screen.keyPressed` handles it
// before the focused slider.
if key == K::Escape {
self.can_change_value = false;
return (Vec::new(), false);
}
(Vec::new(), false)
}
}
#[cfg(test)]
@@ -1257,9 +1645,19 @@ mod tests {
}
}
#[test]
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
#[test]
fn every_advanced_setting_has_a_description() {
for level in [Level::Simple, Level::Advanced] {
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for row in rows(level) {
if let Row::Setting(s) = row {
assert!(!s.text.is_empty(), "{:?}", s.item);
@@ -1270,11 +1668,12 @@ mod tests {
}
#[test]
fn lod_distance_cycles_in_both_levels_without_changing_near_distance() {
fn lod_distance_slides_in_both_levels_without_changing_near_distance() {
let mut s = Settings::default();
let item = Item::Horizon(H::LodDistance);
let near = s.render_distance;
assert_eq!(item.label(&s).plain(), "LOD Distance: Off");
assert_eq!(item.slider(&s), Some(0.0));
for level in [Level::Simple, Level::Advanced] {
let items: Vec<_> = rows(level)
.into_iter()
@@ -1289,14 +1688,29 @@ mod tests {
.unwrap();
assert_eq!(items[render + 1], item);
}
for distance in [64, 128, 256, 512, 1024, 0] {
item.cycle(&mut s, false, [854, 480]);
// Every stop is reachable and reads back as the same position.
for (i, &distance) in LOD_STEPS.iter().enumerate() {
let at = i as f32 / (LOD_STEPS.len() - 1) as f32;
item.set_slider(&mut s, at);
assert_eq!(s.horizon.lod_distance, distance);
assert_eq!(item.slider(&s), Some(at));
assert_eq!(s.render_distance, near);
}
item.cycle(&mut s, true, [854, 480]);
// The ends are off and the whole world, which the far terrain plans.
item.set_slider(&mut s, 0.0);
assert_eq!(s.horizon.lod_distance, 0);
item.set_slider(&mut s, 1.0);
assert_eq!(s.horizon.lod_distance, crate::lod::MAX_CHUNKS);
assert_eq!(
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// 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::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
@@ -1401,4 +1815,159 @@ mod tests {
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
}
#[test]
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 600.0);
let fb = [854, 480];
s.horizon.chunk_updates = 10;
s.horizon.resolution_scale = 100;
// 1. Tab navigates down to Chunk Updates slider (in Level::Advanced)
let mut found_chunk_updates = false;
for _ in 0..15 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ChunkUpdates))) {
found_chunk_updates = true;
break;
}
}
assert!(found_chunk_updates);
assert!(
ui.can_change_value,
"Tab onto slider activates can_change_value"
);
// 2. Right Arrow steps chunk updates from 10 to 11
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert!(handled);
assert_eq!(actions, vec![Action::SliderDrag]);
assert_eq!(s.horizon.chunk_updates, 11);
// 3. Left Arrow steps back to 10
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.chunk_updates, 10);
// 4. Space toggles can_change_value to false
ui.key_modifiers(Level::Advanced, &mut s, K::Space, false, size, fb);
assert!(!ui.can_change_value);
// 5. Space toggles can_change_value back to true
ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, fb);
assert!(ui.can_change_value);
// 6. Escape is left to the screen, which closes, even while a slider changes values
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::Escape, false, size, fb);
assert!(!handled);
assert!(actions.is_empty());
assert!(!ui.can_change_value);
// 8. Tab to ResolutionScale (steps by 5%)
let mut found_res = false;
for _ in 0..20 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ResolutionScale))) {
found_res = true;
break;
}
}
assert!(found_res);
assert!(ui.can_change_value);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 105);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 100);
}
#[test]
fn graphics_cycle_wheel_cycles_and_slider_falls_through_to_scroll() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 300.0);
let fb = [854, 480];
// 1. Wheel on a Cycle button: Far Detail cycles mode
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let far = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::FarDetail))))
.unwrap();
let cursor = (far.x + 5.0, far.y + 5.0);
s.horizon.far_detail = FarDetail::Full;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
s.horizon.chunk_updates = 16;
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let cu = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::ChunkUpdates))))
.unwrap();
let cursor = (cu.x + 5.0, cu.y + 5.0);
let scroll_before = ui.scroll.amount;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
assert!(actions.is_empty());
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.scroll.amount > scroll_before);
}
#[test]
fn graphics_step_slider_matches_set_slider_for_all_options() {
let options = [
H::ChunkUpdates,
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
H::HdrUiBrightness,
];
for h in options {
let item = Item::Horizon(h);
let steps = h.slider_steps().expect("steps defined");
for i in 0..steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, true, 200.0);
let next_pos = (i + 1) as f32 / steps as f32;
h.set_slider(&mut s2, next_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step forward from {i}"
);
}
for i in 1..=steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, false, 200.0);
let prev_pos = (i - 1) as f32 / steps as f32;
h.set_slider(&mut s2, prev_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step backward from {i}"
);
}
}
}
}
+58
View File
@@ -26,8 +26,14 @@ struct Params {
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
@@ -81,6 +87,48 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
}
// Whether `p` falls on the logo drawn `size` pixels wide around `centre`.
fn on_logo(p: vec2<f32>, centre: vec2<f32>, size: f32) -> bool {
let uv = (p - centre) / size + 0.5;
if any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) {
return false;
}
let texels = vec2<f32>(textureDimensions(logo));
return textureLoad(logo, vec2<i32>(uv * texels), 0).r > 0.5;
}
// A calibration step's test picture at `p`, in nits, the way display calibration
// tools draw them: nothing in it is bright over a large area, so the display
// doesn't dim itself while it is judged.
fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
let top = size.y * params.calibration.z;
let centre = vec2(size.x, top) / 2.0;
let v = params.calibration.y;
switch u32(params.calibration.x) {
// Black level: the logo at the slider on 0. Lowered until it disappears,
// it sits on the darkest the display shows.
case 1u: {
return select(0.0, v, on_logo(p, centre, top * 0.4));
}
// Peak: a tenth of the screen at 10 000 nits, which the display clips at
// its brightest, the logo inside at the slider. Raised until it
// disappears, it sits on that brightest.
case 2u: {
let side = sqrt(0.1 * size.x * size.y);
if all(abs(p - centre) < vec2(side / 2.0)) {
return select(10000.0, v, on_logo(p, centre, side * 0.7));
}
return 0.0;
}
// Paper white: a white card at the slider, on a dim grey, to match a
// sheet of paper held beside the screen.
default: {
let card = vec2(top * 0.9, top * 0.6);
return select(3.0, v, all(abs(p - centre) < card / 2.0));
}
}
}
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
@@ -93,6 +141,16 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
let size = vec2<f32>(textureDimensions(frame));
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
let nits = vec3(calibration_nits(pos.xy, size));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
let f = textureLoad(frame, at, 0);
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
@@ -0,0 +1,311 @@
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
//! display calibration tools do it. HDR output draws each step's test picture straight in
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
//! shapes the game's picture, so what the player sees is the display alone:
//!
//! - Black Level: Horizon's logo at the slider's brightness on 0 nits; lowered until it
//! disappears, it sits on the darkest the display shows.
//! - Peak Brightness: a patch of a tenth of the screen at 10 000 nits (the display clips
//! it at its brightest), the logo inside at the slider; raised until it disappears, it
//! sits on that brightest. Small, so the display doesn't dim a bright full screen; the
//! HDR metadata declares PQ's 10 000 nits meanwhile, so a compositor that clips at the
//! declared peak (KWin) lets the patch through.
//! - Paper White: a white card at the slider, matched to a sheet of white paper held
//! beside the screen in the light the player plays in: white is where the eyes have
//! adapted to, lower in a dark room than by day (as ITU-R BT.2408's reference white).
//!
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
settings::Settings,
text::Text,
ui,
};
use winit::keyboard::KeyCode;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
Black,
Peak,
PaperWhite,
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
Nothing,
Click,
Changed,
Save,
Cancel,
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 2],
dragging: bool,
}
/// Height of the band of controls at the bottom, in GUI pixels.
const BAND: f32 = 84.0;
impl Step {
/// Range and step of the slider, in nits: the settings' own.
fn range(self) -> (f32, f32, f32) {
match self {
Step::Black => (0.0, 1.0, 0.01),
Step::Peak => (200.0, 2000.0, 50.0),
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
Step::PaperWhite => {
"Make the card a white that's comfortable to look at for a long time, in the light you play in."
}
}
}
/// `hdr::Calibration::step`'s number.
pub fn number(self) -> u32 {
match self {
Step::Black => 1,
Step::Peak => 2,
Step::PaperWhite => 3,
}
}
}
struct Layout {
top: f32,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
let cx = (w / 2.0).floor();
let by = top + 58.0;
Layout {
top,
slider: (cx - 100.0, top + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
}
}
fn over(cursor: (f32, f32), (x, y, w): (f32, f32, f32)) -> bool {
ui::hovered(cursor, x, y, w, 20.0)
}
impl Calibration {
pub fn new(s: &Settings) -> Self {
let g = &s.horizon;
Self {
step: 0,
values: [
g.hdr_black_level as f32 / 100.0,
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
dragging: false,
}
}
fn current(&self) -> Step {
STEPS[self.step]
}
fn set(&mut self, v: f32) {
let (min, max, step) = self.current().range();
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
(
self.current(),
self.values[self.step],
layout(0.0, h).top / h.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = layout(w, h);
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
let (min, max, _) = step.range();
let (x, y, sw) = l.slider;
ui::slider(
batch,
atlas,
x,
y,
sw,
&self.label(),
(self.values[self.step] - min) / (max - min),
over(cursor, l.slider) || self.dragging,
true,
self.dragging,
);
let last = self.step + 1 == STEPS.len();
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
batch,
atlas,
rect.0,
rect.1,
rect.2,
20.0,
&Text::literal(label),
active,
hover,
);
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
return Outcome::Nothing;
}
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
return Outcome::Changed;
}
if over(cursor, l.slider) {
self.dragging = true;
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
return Outcome::Changed;
}
if over(cursor, l.cancel) {
return Outcome::Cancel;
}
if over(cursor, l.back) && self.step > 0 {
self.step -= 1;
return Outcome::Click;
}
if over(cursor, l.next) {
return self.next();
}
Outcome::Nothing
}
fn next(&mut self) -> Outcome {
if self.step + 1 == STEPS.len() {
return Outcome::Save;
}
self.step += 1;
Outcome::Click
}
/// The slider let go: true if it was held (it clicks on release, as sliders do).
pub fn release(&mut self) -> bool {
std::mem::take(&mut self.dragging)
}
/// Left and Right step the slider, Enter goes on, Escape cancels.
pub fn key(&mut self, key: KeyCode) -> Outcome {
let (_, _, step) = self.current().range();
match key {
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
self.set(self.values[self.step] - step);
Outcome::Changed
}
KeyCode::ArrowRight | KeyCode::ArrowUp => {
self.set(self.values[self.step] + step);
Outcome::Changed
}
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
KeyCode::Escape => Outcome::Cancel,
_ => Outcome::Nothing,
}
}
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
Some(auto) if auto == self.values[i] => 0,
_ => self.values[i].round() as u32,
});
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
s.horizon.hdr_black_level = 5;
let before = s.horizon.clone();
let mut c = Calibration::new(&s);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
}
}
+244 -33
View File
@@ -10,6 +10,8 @@ use std::{
};
use wgpu::util::DeviceExt;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
/// default resources' texture of that name, whatever the resolution of the
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
@@ -78,6 +80,9 @@ pub struct HudAtlas {
pub glint: (Vec<u8>, u32, u32),
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -295,7 +300,18 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
animated,
)
}
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
pub const MISSING_SPRITE: &str = "horizon/missing";
impl HudAtlas {
/// A sprite, or the missing one when the atlas has no such sprite (an
/// effect without its icon), as a stitched atlas answers.
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
self.sprites
.get(name)
.or_else(|| self.sprites.get(MISSING_SPRITE))
.copied()
}
/// 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;
@@ -330,7 +346,7 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
Self::pack(
let mut atlas = Self::pack(
root,
defaults,
&[
@@ -347,7 +363,9 @@ impl HudAtlas {
"map",
],
true,
)
)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
@@ -408,6 +426,61 @@ impl HudAtlas {
atlas.pixels = Vec::new();
Ok(atlas)
}
/// The boot screen's atlas, before any pack loads: the theme's run-time
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
/// transparent black. Nothing from a pack: the boot screen draws with
/// the theme's bundled faces, so the font here has no sheets to read.
pub fn boot() -> Self {
let (width, height) = (1024, 1024);
let text_rows = 512;
let glyph_top = text_rows + 4;
let mut pixels = vec![0u8; (width * height * 4) as usize];
// The white sprite the fills and the font's underline pass sample.
for row in 0..4 {
let at = ((text_rows + row) * width * 4) as usize;
pixels[at..at + 16].fill(255);
}
let rect = |u0: u32, v0: u32, u1: u32, v1: u32, w: u32, h: u32| Rect {
u0: u0 as f32 / width as f32,
v0: v0 as f32 / height as f32,
u1: u1 as f32 / width as f32,
v1: v1 as f32 / height as f32,
w,
h,
};
let mut sprites = HashMap::new();
sprites.insert(
"horizon/white".into(),
rect(0, text_rows, 4, glyph_top, 4, 4),
);
sprites.insert(
"horizon/ui-text".into(),
rect(0, 0, width, text_rows, width, text_rows),
);
let white = sprites["horizon/white"];
Self {
font: crate::font::Font::load(
Path::new("horizon-boot"),
&sprites,
&pixels,
(width, height),
(glyph_top, height - glyph_top),
white,
),
pixels,
width,
height,
sprites,
translucent: HashSet::new(),
flags: HashMap::new(),
object_skins: HashMap::new(),
sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
@@ -455,13 +528,17 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
if folders.contains(&"entity") {
for namespace in horizon_items::content_namespaces::namespaces(root) {
walk(
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
&mut files,
);
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&mut files,
);
}
}
}
let assets = root.join("assets");
@@ -488,7 +565,9 @@ impl HudAtlas {
format!("{namespace}:{rest}")
}
};
let image = decode(path).ok()?;
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
@@ -564,6 +643,15 @@ impl HudAtlas {
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
.flat_map(|i| match (i / 16 < 8) ^ (i % 16 < 8) {
true => [248, 0, 248, 255],
false => [0, 0, 0, 255],
})
.collect();
images.push((MISSING_SPRITE.into(), 16, 16, missing));
}
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
// border wrapped from their own edges keeps the filter off their
@@ -669,6 +757,7 @@ impl HudAtlas {
sprite_animations,
glint,
glint_armor,
text_shader: None,
font,
white,
})
@@ -694,6 +783,9 @@ pub struct HudVertex {
/// The `GLINT_TEXTURING` UV into the repeating glint texture, unused
/// unless `uv.x >= 2`.
pub glint_uv: [f32; 2],
/// 1 for the GUI's text (`RenderPipelines.GUI_TEXT`), which a resource
/// pack's text shader draws when it has one.
pub text: u32,
}
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
@@ -750,6 +842,9 @@ pub struct HudBatch {
pub end_portal: EndPortalFill,
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
pub glint_alpha: f32,
/// A resource pack text shader's `GameTime`: the day's fraction
/// (`gameTime % 24000` and the partial tick, over 24000).
pub game_time: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
@@ -804,6 +899,7 @@ impl HudBatch {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
}
@@ -881,6 +977,7 @@ impl HudBatch {
color,
depth: 0.0,
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -900,6 +997,7 @@ impl HudBatch {
color,
depth: 0.0,
glint_uv: glint_uv[i],
text: 0,
});
}
}
@@ -911,6 +1009,7 @@ impl HudBatch {
color,
depth: p[i][2],
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -1051,6 +1150,10 @@ impl HudBatch {
[uv(cx0, cy0), uv(cx1, cy0), uv(cx1, cy1), uv(cx0, cy1)],
tl.color,
);
let end = self.vertices.len();
for v in &mut self.vertices[end - 6..] {
v.text = tl.text;
}
}
moved.push(self.vertices.len() as u32);
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
@@ -1110,6 +1213,7 @@ impl HudBatch {
color: c[i],
depth: 0.0,
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -1338,6 +1442,7 @@ impl HudBatch {
}
}
};
let glyph = self.vertices.len();
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
@@ -1355,6 +1460,11 @@ impl HudBatch {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
if !world {
for v in &mut self.vertices[glyph..] {
v.text = 1;
}
}
}
}
cursor - x
@@ -1490,6 +1600,10 @@ pub struct HudRenderer {
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
format: wgpu::TextureFormat,
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
}
impl HudRenderer {
pub fn new(
@@ -1716,8 +1830,21 @@ impl HudRenderer {
uniform,
buffer,
capacity,
format,
atlas_view: view.clone(),
pack_text: None,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
});
}
pub fn animate_sprites(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
@@ -1771,6 +1898,38 @@ impl HudRenderer {
*previous = state;
}
}
/// Stage `patches` into the shared atlas texture.
pub fn upload_patches(
&self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
patches: &[crate::font::Patch],
) {
if let Some(texture) = &self.atlas_texture {
for p in patches {
if p.x.saturating_add(p.width) <= texture.width()
&& p.y.saturating_add(p.height) <= texture.height()
{
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
}
}
/// Stage the batch's atlas patches, vertices and uniform: before the
/// frame's first pass, so the copies share one command buffer.
pub fn prepare(
@@ -1781,26 +1940,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
) {
if let Some(texture) = &self.atlas_texture {
for p in &batch.patches {
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
self.upload_patches(uploads, encoder, &batch.patches);
if batch.vertices.is_empty() || size.contains(&0) {
return;
}
@@ -1834,6 +1974,9 @@ impl HudRenderer {
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
}
}
/// Draw the batch `prepare` staged.
pub fn draw(
@@ -1915,15 +2058,31 @@ impl HudRenderer {
pass.set_scissor_rect(x, y, w, h);
}
for (range, blend) in &runs[at..end] {
pass.set_pipeline(match blend {
let pipeline = match blend {
Blend::Alpha => &self.pipeline,
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
});
pass.draw(range.clone(), 0..1);
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
if text {
pack_text.draw(&mut pass, part);
pass.set_bind_group(0, &self.group, &[]);
} else {
pass.set_pipeline(pipeline);
pass.draw(part, 0..1);
}
}
}
_ => {
pass.set_pipeline(pipeline);
pass.draw(range.clone(), 0..1);
}
}
}
at = end;
}
@@ -2055,6 +2214,57 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn content_mod_effect_icons_pack_under_their_namespace() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-effect-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let icons = root.join("assets/gems/textures/mob_effect");
fs::create_dir_all(&icons).unwrap();
write_strip(&icons.join("glow.png"), 18, 18, &[[0, 0, 200, 255]], None);
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["gems"]"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites[horizon_client_core::potions::sprite("gems:glow").as_str()];
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// The HUD and the inventory draw it from the effect's id, as vanilla's.
let effect = crate::effects::EffectInstance::new("gems:glow", 600, 0);
let icon = crate::hud_overlays::EffectIcon::from(&effect);
let draws = crate::hud_overlays::effects(320, false, &[icon]);
let mut batch = HudBatch::new([320, 240]);
crate::hud_layout::paint(&mut batch, &atlas, &draws);
assert!(batch.vertices.iter().any(|v| v.uv == [rect.u0, rect.v0]));
let panels = crate::inventory_effects::panels(
320,
[72, 37],
176,
&[effect],
20.0,
&|_| None,
&|_| 60,
);
batch.vertices.clear();
crate::inventory_effects::paint(&mut batch, &atlas, &panels, [0, 0]);
assert!(batch.vertices.iter().any(|v| v.uv == [rect.u0, rect.v0]));
// An effect without an icon draws the missing sprite.
let missing = atlas.sprites[MISSING_SPRITE];
assert_eq!(texel_at(&atlas, missing, 0, 0), [0, 0, 0, 255]);
assert_eq!(texel_at(&atlas, missing, 8, 0), [248, 0, 248, 255]);
let effect = crate::effects::EffectInstance::new("gems:none", 600, 0);
let draws = crate::hud_overlays::effects(320, false, &[(&effect).into()]);
batch.vertices.clear();
crate::hud_layout::paint(&mut batch, &atlas, &draws);
assert!(
batch
.vertices
.iter()
.any(|v| v.uv == [missing.u0, missing.v0])
);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
let root = animated_root();
let atlas = HudAtlas::build(&root).unwrap();
@@ -2649,6 +2859,7 @@ mod tests {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
};
@@ -0,0 +1,325 @@
//! A resource pack's `core/text` shader on the GUI's text (`RenderPipelines.GUI_TEXT`, the shader
//! with `IS_GUI`): servers' HUDs that their pack's vertex shader moves to the screen's corners,
//! and text effects in its fragment shader. Without one the text draws natively.
use std::path::Path;
use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
/// left; vanilla's quads go top left, bottom left, bottom right, top right, which is what
/// `gl_VertexID % 4` tells a pack's shader. The draw passes its first vertex as its instance.
const GLUE: Glue = Glue {
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
prelude: "layout(location = 0) in vec2 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 2) in vec4 hz_color;\n\
const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\
int hz_vertex_id() {\n\
int i = int(gl_VertexIndex) - int(gl_InstanceIndex);\n\
return i / 6 * 4 + hz_corner[i % 6];\n\
}",
setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;",
// GL's clip depth runs from -w to w, wgpu's from 0 to w; GUI text has no depth test.
finish: "gl_Position.z = 0.5 * gl_Position.w;",
vertex_id: "hz_vertex_id()",
};
const VERTEX: &str = "assets/minecraft/shaders/core/text.vsh";
const FRAGMENT: &str = "assets/minecraft/shaders/core/text.fsh";
/// The packs' text shader over `defaults` (the game's own resources, which have vanilla's and
/// its includes), when a pack replaces either stage.
pub(super) fn load(root: &Path, defaults: &Path) -> Option<Arc<CoreProgram>> {
if root == defaults {
return None;
}
let read = |base: &Path, path: &str| std::fs::read_to_string(base.join(path)).ok();
let own =
|path: &str| read(root, path).filter(|text| read(defaults, path).as_ref() != Some(text));
let (own_vertex, own_fragment) = (own(VERTEX), own(FRAGMENT));
if own_vertex.is_none() && own_fragment.is_none() {
return None;
}
let vertex = own_vertex.or_else(|| read(defaults, VERTEX))?;
let fragment = own_fragment.or_else(|| read(defaults, FRAGMENT))?;
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match horizon_shaders::translate_core(&vertex, &fragment, &["IS_GUI"], &include, &GLUE) {
Ok(program) => {
log::info!("the resource pack's text shader draws the GUI's text");
Some(Arc::new(program))
}
Err(e) => {
log::warn!("the resource pack's text shader is not used: {e}");
None
}
}
}
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// The pipeline and its resources.
pub(super) struct PackText {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl PackText {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
atlas: &wgpu::TextureView,
program: &CoreProgram,
) -> Option<Self> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = |label, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("Resource pack text (vertex)", &program.vertex);
let fragment = module("Resource pack text (fragment)", &program.fragment);
let visibility = wgpu::ShaderStages::VERTEX_FRAGMENT;
let mut entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
});
entries.push(wgpu::BindGroupLayoutEntry {
binding: binding + 1,
visibility,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
});
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack text"),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack text uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// The font textures' nearest filtering; every sampler a pack names reads the atlas.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Resource pack text"),
..Default::default()
});
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
});
resources.push(wgpu::BindGroupEntry {
binding: binding + 1,
resource: wgpu::BindingResource::Sampler(&sampler),
});
}
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack text"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack text"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Resource pack text"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
// BlendFunction.TRANSLUCENT.
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
log::warn!("the resource pack's text shader is not used: {e}");
return None;
}
Some(Self {
pipeline,
group,
uniform,
})
}
/// The frame's blocks, as `GuiRenderer` sets them for the GUI.
pub(super) fn prepare(
&self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
/// pipeline and bind group again after.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(range.clone(), range.start..range.start + 1);
}
}
/// The std140 blocks: `Globals` (`GlobalSettingsUniform.update`: the camera at the origin,
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
data[globals + 7] = batch.game_time;
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
data[projection + 5] = -2.0 / h;
data[projection + 10] = 2.0 / (near - far);
data[projection + 12] = -1.0;
data[projection + 13] = 1.0;
data[projection + 14] = (far + near) / (near - far);
data[projection + 15] = 1.0;
let transforms = 128;
for (matrix, z) in [(transforms, -11000.0), (transforms + 16, 0.0)] {
data[matrix] = 1.0;
data[matrix + 5] = 1.0;
data[matrix + 10] = 1.0;
data[matrix + 14] = z;
data[matrix + 15] = 1.0;
}
data[transforms + 32..transforms + 36].fill(1.0);
bytemuck::cast_slice(&data).to_vec()
}
/// `range` split into runs of GUI text and of the rest, in order.
pub(super) fn text_runs(
vertices: &[HudVertex],
range: std::ops::Range<u32>,
) -> Vec<(std::ops::Range<u32>, bool)> {
let mut runs: Vec<(std::ops::Range<u32>, bool)> = Vec::new();
for i in range {
let text = vertices.get(i as usize).is_some_and(|v| v.text != 0);
match runs.last_mut() {
Some((run, t)) if *t == text => run.end = i + 1,
_ => runs.push((i..i + 1, text)),
}
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
};
// ProjMat * ModelViewMat * (x, y, 0, 1) for the GUI's corners: 480 x 270 GUI pixels.
for ([x, y], [cx, cy]) in [([0.0, 0.0], [-1.0, 1.0]), ([480.0, 270.0], [1.0, -1.0])] {
let clip = apply(64, apply(128, [x, y, 0.0, 1.0]));
assert!(
(clip[0] - cx).abs() < 1e-5 && (clip[1] - cy).abs() < 1e-5,
"{clip:?}"
);
// At GL's far plane, as vanilla's GUI is.
assert!((clip[2] - 1.0).abs() < 1e-4, "{clip:?}");
}
// A pack finds the GUI scale as ScreenSize.x * ProjMat[0][0] / 2.
assert!((data[8] * data[64] / 2.0 - 4.0).abs() < 1e-4);
}
#[test]
fn text_splits_from_the_rest() {
let vertex = |text| HudVertex {
pos: [0.0; 2],
uv: [0.0; 2],
color: 0,
depth: 0.0,
glint_uv: [0.0; 2],
text,
};
let vertices: Vec<HudVertex> = [0, 0, 1, 1, 1, 0].into_iter().map(vertex).collect();
assert_eq!(
text_runs(&vertices, 1..6),
[(1..2, false), (2..5, true), (5..6, false)]
);
}
}
+14 -7
View File
@@ -817,16 +817,23 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
color,
blend,
} => {
// Effect sprites belong to the mob_effect atlas directory;
// the other blitSprite ids refer to GUI sprites.
// Effect sprites belong to the mob_effect atlas directory (a
// content mod's under its namespace); the other blitSprite
// ids refer to GUI sprites.
name.clear();
if sprite.starts_with("mob_effect/") {
name.push_str(sprite);
} else {
let effect = sprite.starts_with("mob_effect/")
|| sprite
.split_once(':')
.is_some_and(|(_, path)| path.starts_with("mob_effect/"));
if !effect {
name.push_str("gui/sprites/");
name.push_str(sprite);
}
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
name.push_str(sprite);
let found = match effect {
true => atlas.sprite_or_missing(&name),
false => atlas.sprites.get(name.as_str()).copied(),
};
let Some(mut r) = found else {
continue;
};
if let Some([sw, sh, u, v]) = *region {
@@ -179,7 +179,7 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
blend: crate::hud_layout::Blend::Textured,
});
out.push(GuiDraw::Sprite {
sprite: Cow::Owned(format!("mob_effect/{}", e.id)),
sprite: Cow::Owned(horizon_client_core::potions::sprite(&e.id)),
x: x + 3,
y: y + 3,
w: 18,
@@ -151,17 +151,11 @@ pub fn paint(
);
}
icon.clear();
icon.push_str("mob_effect/");
icon.push_str(&panel.icon.id);
batch.sprite_scaled(
atlas,
&icon,
(panel.x + 7) as f32,
(panel.y + 7) as f32,
18.0,
18.0,
u32::MAX,
);
horizon_client_core::potions::push_sprite(&mut icon, &panel.icon.id);
if let Some(r) = atlas.sprite_or_missing(&icon) {
let (x, y) = ((panel.x + 7) as f32, (panel.y + 7) as f32);
batch.rect_uv(x, y, 18.0, 18.0, r, u32::MAX);
}
if (text_width <= 0 || clipped)
&& cursor[0] >= panel.x
&& cursor[0] <= panel.x + panel.width
+3
View File
@@ -35,6 +35,7 @@ pub mod gfx;
pub mod graphics;
pub mod graphics_style;
pub mod graphics_ui;
pub mod hdr_calibration;
pub mod hud;
pub mod hud_layout;
pub mod hud_overlays;
@@ -53,6 +54,7 @@ pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
pub mod native_emissive;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
@@ -80,6 +82,7 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_profiles;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+58 -4
View File
@@ -40,8 +40,17 @@ fn sampling_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
pub(crate) const MAX_LEVEL: u8 = 10;
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
/// Tiles this far from the camera (1024 chunks, as far as far terrain went
/// before it covered the world) are planned with `WORLD_SPLIT`.
const WORLD_START: i32 = 1024 * 16;
/// How many tile widths away from the camera a tile of the world-wide terrain
/// splits (`Plan::visit`: cells then span about 1/48 of the distance).
const WORLD_SPLIT: i32 = 3;
/// The farthest `Plan` reaches, in chunks.
pub const MAX_CHUNKS: u32 = 1 << 22;
/// The coarsest tile level whose chunks are generated in full (trees,
/// structures) rather than sampled: cells of up to 4 blocks, out to about
/// 1024 blocks at Full far detail.
@@ -417,7 +426,7 @@ impl Plan {
elevation: i32,
reduced: bool,
) -> Self {
let range = distance.min(1024) as i32 * 16;
let range = distance.min(MAX_CHUNKS) as i32 * 16;
let mut level = 0;
while (16 << level) < range {
level += 1;
@@ -454,7 +463,16 @@ impl Plan {
// Keep distant cells small on screen; finest tiles retain individual
// blocks. Reduced keeps them twice as large (a quarter of the cells).
let split = if previous.split.contains(&key) { 10 } else { 8 };
let threshold = key.size() * if reduced { split / 2 } else { split };
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
// the whole world is 22 levels, not all of them worth full cells.
let threshold = key.size()
* if key.distance(camera) >= WORLD_START {
WORLD_SPLIT
} else if reduced {
split / 2
} else {
split
};
if key.level > 0
&& (key.distance(camera) < detail * 16 + 16
|| key.distance(camera).max(self.elevation) < threshold)
@@ -1744,6 +1762,42 @@ mod tests {
);
}
#[test]
fn world_wide_plans_stay_small_and_cover_every_level() {
// About 10 000 nodes at a corner of the world, 9 000 at its centre.
const WORLD_PLAN_NODES: usize = 16_384;
for (camera, elevation) in [
([0, 0], 0),
([115, 135], 49_000_000),
([-30_000_000, 29_999_000], 90),
] {
let plan =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
let reduced =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
assert!(
plan.nodes.len() < WORLD_PLAN_NODES,
"{camera:?} y={elevation}: {} nodes",
plan.nodes.len()
);
assert!(
reduced.nodes.len() <= plan.nodes.len(),
"{camera:?} y={elevation}: reduced has {} nodes, full {}",
reduced.nodes.len(),
plan.nodes.len()
);
// Every level from the finest planned one up to the roots has tiles.
let finest = plan.nodes.iter().map(|k| k.level).min().unwrap();
for level in finest..=MAX_LEVEL {
assert!(
plan.nodes.iter().any(|k| k.level == level),
"{camera:?} y={elevation}: no level-{level} tile"
);
}
}
}
#[test]
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
let ground = Plan::new([0, 0], 12, 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
+164 -8
View File
@@ -2,7 +2,7 @@
use super::{Column, Span, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
streaming::WorkerContext,
};
use horizon_assets::model::Direction;
@@ -253,6 +253,13 @@ type Faces = BTreeMap<FaceKey, Vec<Rect>>;
fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, material: Material) {
for (overlay, paint) in material.faces.into_iter().enumerate() {
let Some(paint) = paint else { continue };
// The same fluid flags the section mesher sets, so the material stage's water
// reaches the distant tiles too and the LOD boundary keeps one look.
let color = if material.fluid != 0 {
fluid_face(paint.color, direction as usize)
} else {
paint.color
};
faces
.entry(FaceKey {
direction,
@@ -260,7 +267,7 @@ fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, mat
overlay: overlay as u8,
layer: paint.layer,
light: paint.light,
color: paint.color,
color,
translucent: paint.translucent,
})
.or_default()
@@ -304,6 +311,34 @@ fn mesh_columns_with_neighbors(
});
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
}
/// From this tile level up a cell is 64 blocks wide or more: the strata
/// inside the ground cannot be told apart, so a column is its surface.
const HEIGHTMAP_LEVEL: u8 = 6;
/// `column` as a heightmap: its top solid span reaching down to the bottom of
/// the world (so the walls toward lower neighbours are one face of the
/// surface's material, merged along the cliff), and the fluid over it.
fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
let Some((top, below)) = column.spans.split_last() else {
return column.clone();
};
let ground = |span: &Span| Span {
min: crate::chunk::MIN_Y as i16,
..*span
};
let mut spans = Vec::with_capacity(2);
match below.last() {
Some(under) if fluid(top) => {
spans.push(ground(under));
spans.push(*top);
}
_ if fluid(top) => spans.push(*top),
_ => spans.push(ground(top)),
}
Column {
surface: column.surface,
spans,
}
}
fn mesh_columns_with_edges(
key: TileKey,
columns: &[Column],
@@ -314,6 +349,28 @@ fn mesh_columns_with_edges(
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert_eq!(columns.len(), CELLS * CELLS);
let heightmap_columns;
let heightmap_neighbors;
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
let [ox, _, oz] = key.origin();
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
heightmap_columns = columns
.iter()
.map(|column| heightmap(column, &mut fluid))
.collect::<Vec<_>>();
heightmap_neighbors = neighbors.each_ref().map(|side| {
side.iter()
.map(|edge| EdgeColumn {
start: edge.start,
end: edge.end,
column: heightmap(&edge.column, &mut fluid),
})
.collect::<Vec<_>>()
});
(&heightmap_columns[..], &heightmap_neighbors)
} else {
(columns, neighbors)
};
let mut count = CELLS * CELLS;
let edge_offsets = std::array::from_fn::<_, 4, _>(|side| {
let start = count;
@@ -336,7 +393,10 @@ fn mesh_columns_with_edges(
step = step.min(other.size());
}
}
step
// The cells of the coarsest tiles are thousands of blocks wide: a
// few steps each keep the work bounded, the cut around finer terrain
// coarse for as long as such a tile stands in for its children.
step.max(stride / MAX_STEPS)
};
let filled = fill_buried_gaps(
columns,
@@ -944,20 +1004,35 @@ fn merge(rects: &mut Vec<Rect>) {
/// The widest face `emit` writes, in blocks: its UVs, counted from the face's
/// corner, must stay within `TerrainVertex::tiled`'s range.
const MAX_FACE: f32 = 1024.0;
/// A face that would be more than this many `MAX_FACE` pieces is one quad
/// whose UVs are scaled to fit: a cell of the coarsest tiles is thousands to
/// millions of blocks wide, and the texture repeats within it are far below a
/// pixel.
const MAX_PIECES: f32 = 4.0;
/// Steps along a cell of a tile cut around finer terrain, at most.
const MAX_STEPS: i32 = 32;
fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
let pieces = ((rect.u1 - rect.u0) / MAX_FACE).ceil() * ((rect.v1 - rect.v0) / MAX_FACE).ceil();
if pieces > MAX_PIECES {
let extent = (rect.u1 - rect.u0).max(rect.v1 - rect.v0);
emit_quad(mesh, key, rect, (MAX_FACE / extent).min(1.0));
return;
}
let mut u0 = rect.u0;
while u0 < rect.u1 {
let u1 = rect.u1.min(u0 + MAX_FACE);
let mut v0 = rect.v0;
while v0 < rect.v1 {
let v1 = rect.v1.min(v0 + MAX_FACE);
emit_quad(mesh, key, Rect { u0, v0, u1, v1 });
emit_quad(mesh, key, Rect { u0, v0, u1, v1 }, 1.0);
v0 = v1;
}
u0 = u1;
}
}
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
/// too wide for them.
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
let Rect { u0, v0, u1, v1 } = rect;
let p = f32::from_bits(key.plane);
let mut points = match key.direction {
@@ -978,8 +1053,8 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
};
// Tile origins are integer multiples of a block, so local coordinates
// retain the world texture phase without distant-coordinate precision loss;
// a whole number of blocks off each UV keeps it (the sampler repeats) and
// counts it from the face's corner.
// a whole number of blocks off each UV keeps it (terrain.wgsl's `tile_uv`
// repeats the sprite per block) and counts it from the face's corner.
let uvs = points.map(|pos| match key.direction {
Direction::Up | Direction::Down => [pos[0], -pos[2]],
Direction::North | Direction::South => [pos[0], -pos[1]],
@@ -994,7 +1069,10 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
out.extend(points.into_iter().zip(uvs).map(|(pos, uv)| {
TerrainVertex::tiled(
pos,
[uv[0] - corner[0], uv[1] - corner[1]],
[
(uv[0] - corner[0]) * uv_scale,
(uv[1] - corner[1]) * uv_scale,
],
key.layer,
key.color,
key.light,
@@ -1093,6 +1171,84 @@ mod tests {
}
assert_eq!(area, 2500.0 * 15.5);
}
#[test]
fn far_columns_are_heightmaps_of_their_surface_and_water() {
let fluid = |s: &Span| s.state.0 == 2;
let strata = Column {
surface: None,
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
};
let flat = heightmap(&strata, fluid);
assert_eq!(flat.spans, [span(-64, 64, 4)]);
let sea = Column {
surface: None,
spans: vec![span(0, 50, 1), span(50, 62, 2)],
};
let flat = heightmap(&sea, fluid);
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
assert!(heightmap(&columns()[0], fluid).spans.is_empty());
// A rolling landscape of strata: far tiles need a fraction of the quads.
let rolling: Vec<Column> = (0..CELLS * CELLS)
.map(|i| {
let top = 64 + (i * 7 % 5) as i16;
Column {
surface: None,
spans: vec![
span(0, top - 4, 1),
span(top - 4, top - 1, 3),
span(top - 1, top, 4),
],
}
})
.collect();
let quads = |level| {
let key = TileKey { x: 0, z: 0, level };
let mesh = mesh_columns(key, &rolling, &FxHashSet::default(), None, material);
mesh.solid.len() / 4
};
let (voxel, height) = (quads(HEIGHTMAP_LEVEL - 1), quads(HEIGHTMAP_LEVEL));
assert!(height * 3 < voxel * 2, "{height} quads against {voxel}");
}
#[test]
fn world_sized_faces_are_one_quad_with_uvs_that_fit() {
let mut mesh = SectionMesh::default();
let key = FaceKey {
direction: Direction::Up,
plane: 64.0f32.to_bits(),
overlay: 0,
layer: 1,
light: 0,
color: 0,
translucent: false,
};
let size = 4_194_304.0;
emit(
&mut mesh,
key,
Rect {
u0: 0.0,
v0: 0.0,
u1: size,
v1: size / 2.0,
},
);
assert_eq!(mesh.solid.len(), 4);
let corners: Vec<[f32; 3]> = mesh.solid.iter().map(|v| v.pos).collect();
assert!(
corners.iter().any(|p| p[0] == size && p[2] == size / 2.0),
"{corners:?}"
);
let top = mesh
.solid
.iter()
.map(tiled_uv)
.fold(0.0f32, |m, uv| m.max(uv[0]).max(uv[1]));
assert!(top <= MAX_FACE + 1.0, "UV reaches {top}");
assert!(
top > MAX_FACE / 4.0,
"the texture still repeats across the face"
);
}
fn normal(q: &[TerrainVertex; 4]) -> glam::Vec3 {
let p = |i: usize| glam::Vec3::from(q[i].pos);
(p(1) - p(0)).cross(p(2) - p(0))
+52 -4
View File
@@ -147,6 +147,36 @@ mod vertex_tests {
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
#[test]
fn fluid_faces_keep_the_colour_and_the_emission() {
let lit = super::emitting(super::pack_rgb([0x12, 0x34, 0x56]), super::INCANDESCENT);
assert_eq!(super::fluid_face(lit, 0), 0x8D56_3412);
assert_eq!(super::fluid_face(lit, 5), 0xDD56_3412);
// Another face replaces the first.
assert_eq!(super::fluid_face(super::fluid_face(lit, 5), 1), 0x9D56_3412);
}
/// What `gfx/native_material.rs` reads back from the colour's alpha byte: the face of a
/// fluid quad, and the HDR tier.
fn decoded(color: u32) -> (Option<u32>, u32) {
let flags = color >> 24;
let high = flags >> 4;
let fluid = high >= 8 && (high & 7) < 6;
(fluid.then_some(high & 7), 15 - (flags & 15))
}
#[test]
fn the_material_stage_reads_back_what_the_mesher_encodes() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
for tier in 0..=super::VERY_BRIGHT {
let lit = super::emitting(rgb, tier);
assert_eq!(decoded(lit), (None, u32::from(tier)));
for face in 0..6 {
assert_eq!(
decoded(super::fluid_face(lit, face)),
(Some(face as u32), u32::from(tier))
);
}
}
}
}
#[cfg(test)]
@@ -794,7 +824,8 @@ mod reuse_tests;
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
/// byte's other four bits stay set.
/// byte's high nibble carries the fluid face flags (`fluid_face`); the shaders ignore the
/// vertex alpha, so both travel to the material stage untouched.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
@@ -807,6 +838,17 @@ pub const VERY_BRIGHT: u8 = 4;
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
/// The high nibble of `TerrainVertex::color`'s alpha byte: 8 plus the face (`Direction`
/// as usize) on a fluid quad, 0xF on every other. `terrain.wgsl` reads only the low nibble
/// (the emission), so only the material stage of Horizon's own packs reads it
/// (`gfx/native_material.rs`); whether the fluid is water or lava follows from the pass
/// that draws it.
pub const FLUID_FACE_BITS: u32 = 0xF000_0000;
/// `color` marked as a fluid quad of face `face` (`Direction` as usize, 0 to 5).
pub fn fluid_face(color: u32, face: usize) -> u32 {
debug_assert!(face < 6);
color & !FLUID_FACE_BITS | 0x8000_0000 | (face as u32) << 28
}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
@@ -953,6 +995,8 @@ fn pack_coords(coords: u32) -> u16 {
pub struct Mesher<'a> {
/// CardinalLighting.NETHER: brighter up/down faces.
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1027,8 +1071,8 @@ impl Mesher<'_> {
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let nether = self.nether_shading;
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
let shade = (self.nether_shading, self.shader_pack_lighting);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
}
if !state.model {
continue;
@@ -1127,7 +1171,11 @@ impl Mesher<'_> {
let q = &face.quad;
let tier = state.tier.max(hdr_tier("", q.light_emission));
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = lighting::cardinal(self.nether_shading, shade_face);
let directional = if self.shader_pack_lighting {
1.0
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
let info = &self.tables.info;
@@ -3,7 +3,8 @@
//! beside glass and leaves, back faces, cardinal shading and max(self, above)
//! light.
use super::{
Info, MeshTables, Padded, SectionMesh, TerrainVertex, lighting, pack_coords, pack_rgb, pi,
Info, MeshTables, Padded, SectionMesh, TerrainVertex, fluid_face, lighting, pack_coords,
pack_rgb, pi,
};
use horizon_core::{
geometry::{Axis, Direction, VoxelShape, trig},
@@ -188,15 +189,24 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
pub(super) fn tesselate(
tables: &MeshTables,
nether: bool,
(nether, shader_pack_lighting): (bool, bool),
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
[x, y, z]: [i32; 3],
) {
use horizon_assets::model::Direction as D;
let cardinal = |d: D| {
if shader_pack_lighting {
1.0
} else {
lighting::cardinal(nether, d)
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
@@ -310,14 +320,19 @@ pub(super) fn tesselate(
} else {
&mut out.solid
};
let mut quad =
|v: [([f32; 3], [f32; 2]); 4], layer: u16, color: u32, light: u16, back: bool| {
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
layer: u16,
color: u32,
light: u16,
back: bool,
face: usize| {
let color = fluid_face(color, face);
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
let bottom = if render_down { EPSILON } else { 0.0 };
if render_up && !occluded_by(Direction::Up, nw.min(sw).min(se.min(ne)), up) {
@@ -363,9 +378,10 @@ pub(super) fn tesselate(
([fx + 1.0, fy + ne, fz], uv[3]),
],
layer,
color(lighting::cardinal(nether, D::Up)),
color(cardinal(D::Up)),
light([x, y, z]),
backward,
Direction::Up as usize,
);
}
if render_down {
@@ -378,13 +394,14 @@ pub(super) fn tesselate(
([fx, y0, fz + 1.0], [0.0, 1.0]),
],
still,
color(lighting::cardinal(nether, D::Down)),
color(cardinal(D::Down)),
light([x, y - 1, z]),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let up_shade = lighting::cardinal(nether, D::Up);
let up_shade = cardinal(D::Up);
for (i, d) in [
Direction::North,
Direction::South,
@@ -419,9 +436,9 @@ pub(super) fn tesselate(
_ => (flow, false),
};
let side = if matches!(d, Direction::North | Direction::South) {
lighting::cardinal(nether, D::North)
cardinal(D::North)
} else {
lighting::cardinal(nether, D::West)
cardinal(D::West)
};
let (v0, v1) = ((1.0 - h0) * 0.5, (1.0 - h1) * 0.5);
quad(
@@ -435,6 +452,7 @@ pub(super) fn tesselate(
color(up_shade * side),
side_light,
!is_overlay,
d as usize,
);
}
}
@@ -46,6 +46,56 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_shader_pack_lights_the_faces_itself() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
chunk.set(8, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let colours = |shader_pack_lighting| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
.solid
.iter()
.map(|v| v.color)
.collect();
colours.sort_unstable();
colours.dedup();
colours
};
assert_eq!(colours(false).len(), 4);
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -96,6 +146,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -74,7 +74,13 @@ impl Sides {
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
self.visible_from(eye, eye[1], draw);
}
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
/// its water mirror's eye below the plane, which differ only in y.
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
@@ -84,6 +90,7 @@ impl Sides {
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
@@ -312,6 +319,24 @@ mod tests {
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
}
#[test]
fn an_eye_and_its_mirror_below_see_the_faces_of_both() {
let mut solid = cube([0.0; 3], 0);
let sides = sort(&mut solid);
let both = |eye: [f32; 3], low: f32| {
let mut out = Vec::new();
sides.visible_from(eye, low, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
assert_eq!(both([8.0, 8.0, 8.0], -8.0), [(0, 3), (5, 1)]);
// A mirror above the block's bottom adds nothing.
assert_eq!(both([8.0, 8.0, 8.0], 0.5), [(0, 3)]);
// The sides depend on x and z alone.
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
@@ -0,0 +1,61 @@
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
pub const MAX: usize = 32;
/// Load only bounded, explicitly named emissive textures from the active pack.
pub fn load(root: &Path, sprites: &mut Sprites) {
let mut names: Vec<_> = crate::atlas_sources::list_textures(root)
.into_iter()
.filter(|name| {
name.split_once(':')
.is_some_and(|(_, p)| p.starts_with("emissive/"))
})
.take(MAX)
.collect();
names.sort();
for name in names {
let file = crate::atlas_sources::texture_path(root, &name, "textures");
if std::fs::metadata(file).is_ok_and(|m| m.len() <= 1 << 20) {
let _ = sprites.insert(root, &name);
}
}
}
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
pub fn mappings<'a>(
layers: &BTreeMap<String, u32>,
requests: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Vec<[u32; 4]> {
let mut pairs = BTreeMap::new();
for (base, overlay) in requests.into_iter().take(MAX) {
if let (Some(&b), Some(&o)) = (layers.get(base), layers.get(overlay)) {
pairs.entry(b).or_insert(o);
}
}
pairs.into_iter().map(|(b, o)| [b, o, 0, 0]).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_existing_pairs_are_mapped() {
let layers = [
("minecraft:block/ore".into(), 3),
("mine:emissive/ore".into(), 7),
]
.into();
assert_eq!(
mappings(
&layers,
[
("minecraft:block/ore", "mine:emissive/ore"),
("missing", "mine:emissive/ore")
]
),
vec![[3, 7, 0, 0]]
);
}
}
File diff suppressed because it is too large. Load diff
+57 -2
View File
@@ -960,6 +960,30 @@ impl Selectable {
compatible: true,
}
}
/// A server's downloaded pack (`DownloadedPackSource.DOWNLOADED_PACK_SELECTION`):
/// required, fixed at the top, so the pack screen cannot remove or reorder it.
pub fn downloaded(id: &str, compatible: bool) -> Self {
Self {
id: id.to_owned(),
required: true,
position: Position::Top,
fixed: true,
compatible,
}
}
}
/// Ids from `DownloadedPackSource` (`server/…`); they must not go into
/// `options.txt` `resourcePacks`.
pub fn is_downloaded_pack_id(id: &str) -> bool {
id.starts_with("server/")
}
/// The player's own selection: drop every downloaded server/world pack id.
pub fn player_pack_ids(ids: impl IntoIterator<Item = String>) -> Vec<String> {
ids.into_iter()
.filter(|id| !is_downloaded_pack_id(id))
.collect()
}
/// `Pack.Position.insert`: below or above the packs fixed at that end.
@@ -1093,16 +1117,47 @@ impl SelectionModel {
ok
}
/// `commit`: the repository's selection, bottom first, as options.txt
/// stores it.
/// stores it (downloaded `server/…` packs stay out of that file).
pub fn commit(&self) -> Vec<String> {
let ids: Vec<String> = self.selected.iter().rev().map(|p| p.id.clone()).collect();
self.rebuild(&ids).into_iter().map(|p| p.id).collect()
player_pack_ids(self.rebuild(&ids).into_iter().map(|p| p.id))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `DownloadedPackSource.DOWNLOADED_PACK_SELECTION`: the pack is selected,
/// fixed at the top, cannot be removed, and stays out of options.txt.
#[test]
fn a_downloaded_server_pack_is_fixed_selected_and_not_committed() {
let server = "server/00000000000000000000000000000001/hash.zip";
let available = vec![
Selectable::optional("file/Mine", true),
Selectable::downloaded(server, true),
Selectable::vanilla(),
];
let model = SelectionModel::new(available, &["file/Mine".into()]);
assert_eq!(
model
.selected
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
[server, "file/Mine", "vanilla"]
);
assert!(!model.can_unselect(true, 0));
// A neighbour cannot move up past the fixed server pack.
assert!(!model.can_move_up(true, 1));
assert_eq!(
model.commit(),
vec!["vanilla".to_owned(), "file/Mine".to_owned()]
);
assert!(is_downloaded_pack_id(server));
assert!(!is_downloaded_pack_id("file/Mine"));
}
#[test]
fn mcmeta_with_utf8_bom_is_read() {
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
@@ -1,6 +1,7 @@
//! Overlay reuse validated by source topology and filesystem stamps.
//! Published output is an owned cache artifact; hits do not traverse its tree.
use super::{Pack, Source, build_overlay_uncached};
use crate::zip::Zip;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
@@ -77,6 +78,24 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
/// The largest zip hashed by its bytes: vanilla's limit on a server pack.
const WHOLE_ZIP: u64 = 250 << 20;
/// A zip up to `whole` by its bytes, which also catches an edit its checksums
/// don't show (a pack may leave them empty, nothing reads them). A larger one
/// would take minutes on every check, so by its stamp and its directory: what a
/// local pack changes when it is replaced, and the directory reads in a moment.
fn zip_content(hash: &mut Sha256, path: &Path, whole: u64) -> io::Result<()> {
let meta = fs::metadata(path)?;
match Zip::open(path) {
Ok(zip) if meta.len() > whole => {
hash.update(meta.len().to_le_bytes());
field(hash, format!("{:?}", meta.modified()?).as_bytes());
hash.update(zip.directory());
Ok(())
}
_ => content(hash, path),
}
}
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
@@ -250,8 +269,8 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
// time rebuilt the whole overlay each time.
zip_content(&mut hash, path, WHOLE_ZIP)?;
}
}
}
@@ -632,6 +651,36 @@ mod tests {
assert!(!marker.exists());
}
#[test]
fn a_large_zip_is_stamped_by_its_directory_and_time() {
let f = Fixture::new();
let path = f.0.join("large.zip");
let at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1 << 30);
let write = |name: &str, first: u8, modified| {
let mut body = vec![0u8; 1 << 18];
body[0] = first;
fs::write(&path, archive(&[(name, body)], false)).unwrap();
let file = fs::File::options().write(true).open(&path).unwrap();
file.set_modified(modified).unwrap();
};
let signature = |whole| {
let mut hash = Sha256::new();
zip_content(&mut hash, &path, whole).unwrap();
finish(hash)
};
write("a", 0, at);
let (bytes, directory) = (signature(u64::MAX), signature(0));
// Above the limit the entries' bytes go unread; at or under it they count.
write("a", 1, at);
assert_eq!(signature(0), directory);
assert_ne!(signature(u64::MAX), bytes);
write("b", 0, at);
assert_ne!(signature(0), directory);
write("a", 0, at + std::time::Duration::from_secs(60));
assert_ne!(signature(0), directory);
assert_eq!(signature(u64::MAX), bytes);
}
#[test]
fn zip_content_and_failed_build_invalidate() {
let f = Fixture::new();
@@ -658,15 +707,16 @@ mod tests {
f.write("pack.zip", &archive(&files(b"bbb"), false));
f.rebuild_matches(std::slice::from_ref(&pack));
assert_eq!(fs::read(f.0.join("cached").join(name)).unwrap(), b"bbb");
// The ZIP opens and enumerates, then its unsupported method fails after
// mirror has created directories. No reusable marker survives.
// A central directory whose second name runs past the archive fails
// the build (an unreadable entry alone only costs that file). No
// reusable marker survives.
let mut broken = archive(&files(b"ccc"), false);
let central = broken
.windows(4)
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
.unwrap();
let second = central + 46 + "pack.mcmeta".len();
broken[second + 10..second + 12].copy_from_slice(&99u16.to_le_bytes());
broken[second + 28..second + 30].copy_from_slice(&u16::MAX.to_le_bytes());
f.write("pack.zip", &broken);
let out = f.0.join("cached");
assert!(
+46 -5
View File
@@ -1075,6 +1075,8 @@ pub struct ParticleEngine {
}
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
const SPORE_BLOSSOM_LIMIT: usize = 1000;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
@@ -1480,11 +1482,27 @@ impl ParticleEngine {
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned.
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
@@ -4124,8 +4142,8 @@ impl ParticleEngine {
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
u32::from(hdr_tier(p.ty)) << 16
},
0
} | u32::from(hdr_tier(p.ty)) << 16,
color,
light,
@@ -4136,8 +4154,8 @@ impl ParticleEngine {
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
/// glowing drops and fireworks, not their smoke.
fn hdr_tier(ty: ParticleType) -> u8 {
use ParticleType as P;
match ty {
@@ -4154,6 +4172,8 @@ fn hdr_tier(ty: ParticleType) -> u8 {
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
crate::mesher::LOW
}
// FireworkParticles: the sparks and the flash of the burst.
P::Firework | P::Flash => crate::mesher::BRIGHT,
_ => 0,
}
}
@@ -4344,6 +4364,27 @@ mod tests {
assert!(tall.iter().all(|p| p[1] < 1.0));
}
#[test]
fn spore_blossom_air_stops_at_its_particle_limit() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let spore = ParticleOptions::simple(ParticleType::SporeBlossomAir);
for i in 0..1001 {
engine.create(&spore, [f64::from(i % 16), 70.0, 0.0], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1000);
assert!(
engine
.pending
.iter()
.all(|p| p.ty == ParticleType::SporeBlossomAir)
);
// A type without a `ParticleLimit` is not capped.
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..10 {
engine.create(&smoke, [0.0; 3], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1010);
}
#[test]
fn particle_names_round_trip() {
for &ty in ParticleType::ALL {
assert_eq!(
@@ -5,6 +5,7 @@
//! outline, nether portal swirl). Positions are clip-space quads; textures
//! come from the shared 2D atlas and may repeat.
use crate::{
effects::EffectInstance,
hud::{Rect, TextureFlags},
hud_layout::GuiDraw,
};
@@ -271,6 +272,28 @@ impl ScreenEffects {
}
}
/// The nausea effect's blend-out advance: over its last `NAUSEA_BLEND_OUT`
/// ticks it has no effect (`MobEffects.NAUSEA`, `endsWithin(60)`).
const NAUSEA_BLEND_OUT: i32 = 60;
/// `MobEffectInstance.BlendState.hasEffect`: still past the blend-out point,
/// so the effect counts as on (`!endsWithin(60)` for `MobEffects.NAUSEA`).
pub fn nausea_has_effect(e: &EffectInstance) -> bool {
!e.ends_within(NAUSEA_BLEND_OUT)
}
/// A new nausea instance's `BlendState`: from nothing, or, when the server
/// asked for no blending, at once (`MobEffectInstance.skipBlending`'s
/// `BlendState.setImmediate`).
pub fn nausea_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32) {
let factor = if blend || !nausea_has_effect(e) {
0.0
} else {
1.0
};
(factor, factor)
}
#[cfg(test)]
mod tests {
use super::*;
+2 -1
View File
@@ -621,7 +621,7 @@ impl Settings {
/// These settings with one `options.txt` value changed (`horizon-ctl
/// options-set`, as `OptionsFile::set_text` reads it). Horizon's own
/// files (graphics, controller, extras, the mod list) aren't in
/// `options.txt`, so they stay as they are.
/// `options.txt`, so they stay as they are, as do the monitor's video modes.
pub fn with_option(&self, key: &str, value: &str) -> Result<Self, String> {
let mut file = self.to_file();
file.set_text(key, value)?;
@@ -630,6 +630,7 @@ impl Settings {
controller: self.controller.clone(),
extras: self.extras.clone(),
mods: self.mods.clone(),
video_modes: self.video_modes.clone(),
..Self::from_file(file)
})
}
+74
View File
@@ -97,6 +97,50 @@ pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
let (value, signature) = parse_profile(profile).textures?;
unpack(&value, signature, &TEXTURE_URIS).skin
}
/// The textures of a Minecraft services `/minecraft/profile` answer: the
/// `ACTIVE` skin and cape (`MinecraftProfileResponse`). Unsigned, like the
/// local player's own textures, and only URLs under the allowed domains count.
pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
let active = |key: &str| {
profile
.get(key)?
.as_array()?
.iter()
.find(|t| t.get("state").and_then(|s| s.as_str()) == Some("ACTIVE"))
};
let skin_entry = active("skins");
let skin = skin_entry
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
.map(str::to_owned);
let slim = skin_entry
.and_then(|t| t.get("variant"))
.and_then(|v| v.as_str())
.is_some_and(|v| v.eq_ignore_ascii_case("slim"));
let cape = active("capes")
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
.map(str::to_owned);
Textures {
skin,
slim,
cape,
elytra: None,
signed: false,
}
}
/// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`):
/// the same `properties` list, held as a compound. A name-only profile (a
/// string tag) has no properties and no skin URL.
@@ -1033,6 +1077,36 @@ mod tests {
assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into())));
}
#[test]
fn services_profile_answers_give_the_active_skin_and_cape() {
let profile = serde_json::json!({
"skins": [
{"state": "INACTIVE", "url": "https://textures.minecraft.net/texture/old"},
{"state": "ACTIVE", "url": "https://textures.minecraft.net/texture/skin1", "variant": "CLASSIC"}
],
"capes": [{"state": "ACTIVE", "url": "http://textures.minecraft.net/texture/cape1"}]
});
let t = super::services_profile_textures(&profile);
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(
t.cape.as_deref(),
Some("http://textures.minecraft.net/texture/cape1")
);
assert!(!t.signed);
let elsewhere = serde_json::json!({"skins": [{"state": "ACTIVE", "url": "https://evil.example/texture/x"}]});
assert_eq!(
super::services_profile_textures(&elsewhere),
super::Textures::default()
);
assert_eq!(
super::services_profile_textures(&serde_json::json!({})),
super::Textures::default()
);
}
/// `getByName` and `getProfileById` answer from the services the
/// discovery document names; a profile resolves through both.
#[test]
@@ -0,0 +1,625 @@
//! Splitscreen's profile page (Horizon's Splitscreen setting): a joining player picks a profile,
//! or makes one, before entering P1's world, and a player in it switches to another from its
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
//! (kept in the game folder), with its own options. The
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
//! can't play (`refused`). Built from vanilla's widgets, as Horizon's own pages are.
use crate::{
gamepad::{Button as PadButton, GlyphStyle},
graphics_style::STYLE,
hud::{HudAtlas, HudBatch, rgba},
lang,
text::Text,
ui,
};
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
const SHOWN: usize = 4;
pub mod store;
pub const NAME_MAX: usize = 16;
/// A player name a server takes, and so a profile's folder name: 3 to 16 letters, digits or
/// underscores.
pub fn valid_name(name: &str) -> bool {
(3..=NAME_MAX).contains(&name.len())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// `text` in lines no wider than `max` (by `width`), broken between words.
pub fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let longer = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if width(&longer) > max && !line.is_empty() {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = longer;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
#[derive(Default)]
pub struct SplitProfiles {
/// The game filled the page since it opened.
pub filled: bool,
/// The profiles; those playing now (none can be picked twice); and the one this player plays
/// as (switching; none while joining).
pub names: Vec<String>,
pub taken: Vec<String>,
pub current: Option<String>,
/// Why the last pick can't play (an online server, an expired sign-in), until the next.
pub refused: Option<String>,
/// Where this player's view is now, as its bumpers name it ("Window 1 (2 of 2)"): the game
/// sets it every frame the page is open.
pub place: Option<String>,
/// A profile's name, or `None` for Cancel. Taken once.
choice: Option<Option<String>>,
page: usize,
/// The offline profile whose bin button was pressed: the confirmation page.
forgetting: Option<String>,
forgotten: Option<String>,
/// New Profile...: the name being typed.
new: Option<ui::EditBox>,
}
/// What a press did, for the options screen.
pub enum Click {
Nothing,
Pressed,
/// Cancel: the page closes.
Done,
}
#[derive(Clone, Copy, PartialEq)]
enum Target {
Profile(usize),
New,
Cancel,
Create,
Back,
Remove(usize),
Forget,
Keep,
Previous,
Next,
}
/// A button: where, what it says, whether it can be pressed, what it does.
type Button = ([f32; 4], String, bool, Target);
/// The name field's top on the New Profile page.
fn field_top(h: f32) -> f32 {
((h.ceil() - 234.0) / 2.0).floor() + 39.0
}
impl SplitProfiles {
/// The page shown: the last one there is once the list has shrunk (a profile forgotten).
fn page(&self) -> usize {
self.page.min(self.names.len().saturating_sub(1) / SHOWN)
}
/// Opened: empty until the game fills it.
pub fn reset(&mut self) {
*self = Self::default();
}
/// The joining player's pick (`Some(name)`), or Cancel (`None`). Taken once.
pub fn take_choice(&mut self) -> Option<Option<String>> {
self.choice.take()
}
pub fn take_forgotten(&mut self) -> Option<String> {
self.forgotten.take()
}
fn editable(&self, name: &str) -> bool {
!self.taken.contains(&name.to_owned())
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
let x = (w / 2.0).floor() - 100.0;
let button = |y: f32, label: String, enabled: bool, target: Target| {
([x, y, 200.0, 20.0], label, enabled, target)
};
if let Some(name) = &self.forgetting {
return vec![
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
button(
144.0,
lang::get_or("gui.cancel", "Cancel"),
true,
Target::Keep,
),
];
}
if let Some(field) = &self.new {
let name = field.value.trim();
// A new name: not one on the list already.
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
let top = field_top(h);
return vec![
button(top + 25.0, "Create".into(), ok, Target::Create),
button(
top + 50.0,
lang::get_or("gui.back", "Back"),
true,
Target::Back,
),
];
}
let mut buttons = Vec::new();
for (i, name) in self
.names
.iter()
.enumerate()
.skip(self.page() * SHOWN)
.take(SHOWN)
{
let mut label = name.clone();
if self.current.as_ref() == Some(name) {
label.push_str(" - you");
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
[x, y, 176.0, 20.0],
label,
!self.taken.contains(name),
Target::Profile(i),
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
format!("Forget {name}"),
self.editable(name),
Target::Remove(i),
));
}
let mut y = 52.0 + self.names.len().min(SHOWN) as f32 * 24.0;
if self.names.len() > SHOWN {
buttons.push((
[x, y, 98.0, 20.0],
"Previous".into(),
self.page() > 0,
Target::Previous,
));
buttons.push((
[x + 102.0, y, 98.0, 20.0],
"Next".into(),
(self.page() + 1) * SHOWN < self.names.len(),
Target::Next,
));
y += 24.0;
}
buttons.push(button(y, "New Profile...".into(), true, Target::New));
let cancel = lang::get_or("gui.cancel", "Cancel");
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
buttons
}
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
style: GlyphStyle,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let grey = rgba(160, 160, 160, 255);
let line = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
ui::centered_text(batch, atlas, &Text::literal(text), 0.0, y, w, color);
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
line(batch, name, 52.0, u32::MAX);
line(batch, "It leaves this instance's list.", 76.0, grey);
line(batch, "World progress and settings are kept.", 90.0, grey);
if let Some(why) = &self.refused {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(batch, atlas, &Text::literal("New Profile"));
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
let label = "Name (3 to 16 letters, digits, _)";
batch.text(atlas, label, x, top - 14.0, grey, true);
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
field.draw(batch, atlas, None);
} else {
let title = match self.current {
Some(_) => "Switch profile",
None => "Who's playing?",
};
STYLE.title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
line(batch, "Offline profiles belong to this instance", y, grey);
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let why = self.refused.as_deref().unwrap_or_default();
for (k, text) in wrap(why, (w - 24.0).min(300.0), width).iter().enumerate() {
line(
batch,
text,
y + 14.0 + 10.0 * k as f32,
rgba(255, 85, 85, 255),
);
}
}
let mut tip = None;
for ([x, y, bw, bh], label, enabled, target) in &buttons {
let hover = ui::hovered(cursor, *x, *y, *bw, *bh);
let remove = matches!(target, Target::Remove(_));
let mut text = Text::literal(if remove { "" } else { label });
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
}
ui::button(batch, atlas, *x, *y, *bw, *bh, &text, *enabled, hover);
if remove {
crate::widgets::record(label, [*x, *y, *bw, *bh], *enabled);
let color = if *enabled { u32::MAX } else { grey };
// A bin glyph, independent of font coverage, with an accessible widget label.
for (row, bits) in [
0b0011100u8,
0b1111111,
0b0101010,
0b0101010,
0b0101010,
0b0111110,
]
.iter()
.enumerate()
{
for col in 0..7 {
if bits & (1 << (6 - col)) != 0 {
batch.fill(
atlas,
x + 6.0 + col as f32,
y + 7.0 + row as f32,
1.0,
1.0,
color,
);
}
}
}
if hover {
tip = Some(label.clone());
}
}
}
if let Some(tip) = tip {
ui::tooltip(batch, atlas, &[Text::literal(tip)], cursor);
}
}
/// Under the title: the bumpers as keys, as the prompts over a player's view draw them (the
/// controller's own symbols, in its colours), then where they put the view.
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let words = match &self.place {
Some(place) => format!("Move where you play: {place}"),
None => "Move where you play".to_owned(),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
for b in keys {
let [r, g, bl] = b.color(style);
let kw = key_width(b);
// A light edge around the dark key: it stands out on the menu's dark background.
batch.fill(
atlas,
x - 2.0,
y - 3.0,
kw + 2.0,
13.0,
rgba(170, 170, 170, 255),
);
batch.fill(atlas, x - 1.0, y - 2.0, kw, 11.0, rgba(40, 40, 40, 255));
batch.text(
atlas,
b.label(style),
x + 1.0,
y,
rgba(r, g, bl, 255),
false,
);
x += kw + 4.0;
}
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
}
/// A press at `(x, y)` on a screen `w` x `h`.
pub fn click(&mut self, atlas: &HudAtlas, (x, y): (f32, f32), (w, h): (f32, f32)) -> Click {
let hit = self
.buttons(w, h)
.into_iter()
.find(|([bx, by, bw, bh], _, enabled, _)| {
*enabled && ui::hovered((x, y), *bx, *by, *bw, *bh)
});
let Some((_, _, _, target)) = hit else {
// The name field takes the click on it, and the focus.
if let Some(field) = &mut self.new {
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
if field.focused {
field.click(atlas, x, false, false);
}
}
return Click::Nothing;
};
match target {
Target::Profile(i) => {
self.refused = None;
self.choice = self.names.get(i).cloned().map(Some);
}
Target::Remove(i) => {
self.forgetting = self.names.get(i).cloned();
self.refused = None;
}
Target::Keep => self.forgetting = None,
Target::Forget => self.forgotten = self.forgetting.take(),
Target::New => {
let mut field = ui::EditBox::new(0.0, 0.0, 200.0, 20.0);
field.max_length = NAME_MAX;
self.new = Some(field);
}
Target::Previous => self.page = self.page().saturating_sub(1),
Target::Next => self.page = self.page() + 1,
Target::Create => self.create(),
Target::Back => self.new = None,
Target::Cancel => {
self.choice = Some(None);
return Click::Done;
}
}
Click::Pressed
}
/// Create (or Enter on the name): the new profile plays, if the name will do.
fn create(&mut self) {
let can = self
.buttons(0.0, 0.0)
.iter()
.any(|b| b.3 == Target::Create && b.2);
if let (true, Some(field)) = (can, &self.new) {
self.refused = None;
self.choice = Some(Some(field.value.trim().to_owned()));
self.new = None;
}
}
pub fn type_text(&mut self, text: &str) {
if let Some(field) = self.new.as_mut().filter(|f| f.focused) {
field.insert_text(text);
}
}
/// A key: true when the page took it. On New Profile Escape goes back to the list and Enter
/// creates; on the list Escape is the options screen's (it closes the page) and counts as Cancel.
pub fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> bool {
use winit::keyboard::KeyCode as K;
if self.forgetting.is_some() {
if key == K::Escape {
self.forgetting = None;
}
return true;
}
let Some(field) = &mut self.new else {
// The options screen closes the page: the game hears it as Cancel.
if key == K::Escape {
self.choice = Some(None);
}
return false;
};
match key {
K::Escape => self.new = None,
K::Enter | K::NumpadEnter => self.create(),
_ if field.focused => {
if !field.key(key, false, false)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
field.insert_text(&typed);
}
}
_ => {}
}
true
}
/// Ctrl+A/C/V/X in the name field; false when it isn't shown with the focus.
pub fn shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
match self.new.as_mut().filter(|f| f.focused) {
Some(field) => {
field.shortcut(shortcut, clipboard);
true
}
None => false,
}
}
/// For the control socket: the list (once filled), or the new name being typed.
pub fn state(&self) -> serde_json::Value {
if let Some(name) = &self.forgetting {
return serde_json::json!({"mode": "splitscreen-forget-profile", "profile": name});
}
match &self.new {
Some(field) => serde_json::json!({
"mode": "splitscreen-new-profile",
"fields": { "name": field.value },
}),
// Until the game fills the list: the page is not ready to pick from.
None if !self.filled => serde_json::json!({ "mode": "splitscreen-profiles-loading" }),
None => serde_json::json!({
"mode": "splitscreen-profiles",
"profiles": self.names,
"taken": self.taken,
"refused": self.refused,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_notice_wraps_to_the_view_between_words() {
// Each character 6 wide: no line over 60.
let width = |s: &str| 6.0 * s.len() as f32;
let lines = wrap("This server needs each player's own account", 60.0, width);
assert!(lines.iter().all(|l| width(l) <= 60.0));
assert_eq!(
lines.join(" "),
"This server needs each player's own account"
);
// A word longer than the line keeps a line of its own.
assert_eq!(
wrap("a verylongwordindeed b", 30.0, width),
["a", "verylongwordindeed", "b"]
);
assert!(wrap("", 60.0, width).is_empty());
}
#[test]
fn forgetting_the_last_page_s_only_profile_shows_the_page_before() {
let atlas = atlas();
let mut p = SplitProfiles {
names: (0..5).map(|i| format!("Player{i}")).collect(),
..Default::default()
};
press(&mut p, &atlas, "Next");
// Player4 forgotten: the game fills the list again with four names.
p.names.pop();
assert!(p.buttons(427.0, 240.0).iter().any(|b| b.1 == "Player3"));
}
#[test]
fn every_profile_can_be_picked_across_pages() {
let atlas = atlas();
let mut p = SplitProfiles {
names: (0..11).map(|i| format!("Player{i}")).collect(),
..Default::default()
};
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Player10");
assert_eq!(p.take_choice(), Some(Some("Player10".into())));
press(&mut p, &atlas, "Previous");
assert!(p.buttons(427.0, 240.0).iter().any(|b| b.1 == "Player4"));
}
#[test]
fn profile_names_are_player_names() {
assert!(valid_name("Steve_2"));
assert!(!valid_name(""));
assert!(!valid_name("ab"));
assert!(!valid_name("has space"));
assert!(!valid_name("seventeen_chars_x"));
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn page() -> SplitProfiles {
SplitProfiles {
filled: true,
names: vec!["Ann".into(), "Bea".into()],
taken: vec!["Ann".into()],
current: Some("Ann".into()),
..Default::default()
}
}
fn press(page: &mut SplitProfiles, atlas: &HudAtlas, label: &str) -> Click {
let ([x, y, _, _], ..) = page
.buttons(427.0, 240.0)
.into_iter()
.find(|b| b.1 == label)
.unwrap_or_else(|| panic!("no {label}"));
page.click(atlas, (x + 1.0, y + 1.0), (427.0, 240.0))
}
#[test]
fn the_bin_asks_then_forgets_a_free_offline_profile() {
let atlas = atlas();
let mut p = page();
// Ann plays: her bin is greyed.
let buttons = p.buttons(427.0, 240.0);
assert!(buttons.iter().any(|b| b.1 == "Forget Ann" && !b.2));
press(&mut p, &atlas, "Forget Bea");
assert_eq!(p.state()["mode"], "splitscreen-forget-profile");
press(&mut p, &atlas, "Cancel");
assert_eq!(p.take_forgotten(), None);
press(&mut p, &atlas, "Forget Bea");
press(&mut p, &atlas, "Forget");
assert_eq!(p.take_forgotten(), Some("Bea".into()));
assert_ne!(p.state()["mode"], "splitscreen-forget-profile");
}
#[test]
fn the_one_playing_now_is_marked_and_cant_be_picked() {
let p = page();
let buttons = p.buttons(427.0, 240.0);
let you = buttons.iter().find(|b| b.1 == "Ann - you").unwrap();
assert!(!you.2);
assert!(buttons.iter().any(|b| b.1 == "Bea" && b.2));
}
#[test]
fn a_pick_clears_the_last_refusal_and_cancel_closes() {
let atlas = atlas();
let mut p = page();
p.refused = Some("This server is in online mode".into());
press(&mut p, &atlas, "Bea");
assert_eq!(
(p.take_choice(), p.refused.as_ref()),
(Some(Some("Bea".into())), None)
);
assert!(p.take_choice().is_none());
assert!(matches!(press(&mut p, &atlas, "Cancel"), Click::Done));
assert_eq!(p.take_choice(), Some(None));
}
#[test]
fn a_new_name_must_be_new_and_valid() {
let atlas = atlas();
let mut p = page();
press(&mut p, &atlas, "New Profile...");
let create = |p: &SplitProfiles| p.buttons(427.0, 240.0)[0].2;
p.type_text("bea");
assert!(!create(&p), "on the list already, whatever the case");
p.key(winit::keyboard::KeyCode::Backspace, None);
p.type_text("x");
assert!(create(&p));
p.key(winit::keyboard::KeyCode::Enter, None);
assert_eq!(p.take_choice(), Some(Some("bex".into())));
// Escape on New Profile goes back to the list, not out of the page.
press(&mut p, &atlas, "New Profile...");
assert!(p.key(winit::keyboard::KeyCode::Escape, None));
assert!(!p.key(winit::keyboard::KeyCode::Escape, None));
assert_eq!(
p.take_choice(),
Some(None),
"Escape on the list cancels, as Cancel does"
);
}
}
@@ -0,0 +1,90 @@
//! Instance-local picker entries. Hiding an entry never removes settings or world saves.
use std::{fs, io, path::Path};
const HIDDEN: &str = ".hidden";
pub fn names(root: &Path) -> io::Result<Vec<String>> {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut names = Vec::new();
for entry in entries {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if entry.file_type()?.is_dir()
&& super::valid_name(&name)
&& !entry.path().join(HIDDEN).exists()
{
names.push(name);
}
}
names.sort_by_key(|n| n.to_lowercase());
Ok(names)
}
fn valid(name: &str) -> io::Result<()> {
if super::valid_name(name) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid offline profile name",
))
}
}
pub fn hide(root: &Path, name: &str) -> io::Result<()> {
valid(name)?;
fs::write(root.join(name).join(HIDDEN), b"")
}
/// Restore the exact name: case changes would select another offline UUID.
pub fn restore(root: &Path, name: &str) -> io::Result<()> {
valid(name)?;
fs::create_dir_all(root)?;
for entry in fs::read_dir(root)? {
let entry = entry?;
let old = entry.file_name();
let Some(old) = old.to_str() else { continue };
if entry.file_type()?.is_dir() && old != name && old.eq_ignore_ascii_case(name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("Use the existing spelling: {old}"),
));
}
}
fs::create_dir_all(root.join(name))?;
match fs::remove_file(root.join(name).join(HIDDEN)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hide_and_restore_preserve_settings_and_stay_in_one_instance() {
let root = std::env::temp_dir().join(format!("hz-profile-store-{}", std::process::id()));
let (a, b) = (root.join("a"), root.join("b"));
restore(&a, "jordank").unwrap();
let options = a.join("jordank/options.txt");
fs::write(&options, "guiScale:2\n").unwrap();
assert_eq!(names(&a).unwrap(), ["jordank"]);
assert!(names(&b).unwrap().is_empty());
hide(&a, "jordank").unwrap();
assert!(names(&a).unwrap().is_empty());
assert!(restore(&a, "JordanK").is_err());
restore(&a, "jordank").unwrap();
assert_eq!(fs::read_to_string(options).unwrap(), "guiScale:2\n");
assert_eq!(names(&a).unwrap(), ["jordank"]);
assert!(hide(&a, "../bad").is_err());
fs::remove_dir_all(root).unwrap();
}
}
+19 -1
View File
@@ -170,6 +170,7 @@ impl<G: ChunkGenerator> Saves for G {
#[derive(Clone, Copy)]
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
zoom: BiomeZoom,
blend_radius: i32,
smooth_lighting: bool,
@@ -310,6 +311,9 @@ impl Mailbox {
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
@@ -372,6 +376,9 @@ pub struct Streaming {
/// Dimension flags: sky light, Nether face shading.
pub sky_light: bool,
pub nether_shading: bool,
/// A shader pack lights the faces: sections mesh without face shading
/// (`Mesher::shader_pack_lighting`).
pub shader_pack_lighting: bool,
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
pub biome_zoom: BiomeZoom,
pub biome_blend: i32,
@@ -652,7 +659,11 @@ impl Streaming {
generator.decorate(&mut worker, &mut region);
Done::Decorated(x, z, region)
}
Job::Finish(proto) => Done::Generated(generator.finish(proto)),
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
@@ -680,6 +691,7 @@ impl Streaming {
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
@@ -729,6 +741,7 @@ impl Streaming {
protos: Default::default(),
light_views: Default::default(),
decorated: Default::default(),
dropped_new: Default::default(),
decorating: 0,
saving: 0,
decoration_order: DecorationOrder::default(),
@@ -754,6 +767,7 @@ impl Streaming {
loading: false,
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
biome_zoom: BiomeZoom::new(0),
biome_blend: 2,
smooth_lighting: true,
@@ -937,6 +951,7 @@ impl Streaming {
// not give it back.
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
if chunk.fresh {
self.dropped_new.insert(key);
self.saves.save_finished(chunk);
}
return;
@@ -960,6 +975,8 @@ impl Streaming {
// A chunk saved with its light needs no light job; its unlit
// neighbours still light themselves from it.
let loaded = chunk.light_loaded;
let new = self.dropped_new.remove(&key) || chunk.generated;
world.loaded_chunks.push((key.0, key.1, new));
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
@@ -1548,6 +1565,7 @@ impl Streaming {
self.sequence,
MeshSettings {
nether: self.nether_shading,
shader_pack_lighting: self.shader_pack_lighting,
zoom: self.biome_zoom,
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
+56 -17
View File
@@ -24,6 +24,10 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
@@ -118,24 +122,23 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
}
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
let mode = u32(globals.filtering.x);
if mode != 0u {
// Texture Filtering Anisotropic or RGSS: terrain.fsh's two sampling paths.
let du = dpdx(uv);
let dv = dpdy(uv);
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if mode == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
fn tile_uv(uv: vec2<f32>) -> vec2<f32> {
return uv - max(ceil(uv) - 1.0, vec2(0.0));
}
fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
// terrain.fsh's two sampling paths: sampleRGSS for RGSS, sampleNearest otherwise.
// The gradients come from the unwrapped UV, so the mip level holds across block seams.
let du = dpdx(tiled);
let dv = dpdy(tiled);
let uv = tile_uv(tiled);
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
}
if SPRITE_ARRAYS == 1u {
return textureSample(sprites, sprite_sampler, uv, i32(layer));
}
// The array is chosen per fragment: sample with the derivatives taken here.
return sprite_grad(uv, layer, dpdx(uv), dpdy(uv));
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
}
struct TerrainOut {
@@ -152,6 +155,9 @@ struct TerrainOut {
@location(7) local: vec3<f32>,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
// The camera-relative height, for the water mirror's cut at the plane.
@location(9) height: f32,
@location(10) @interpolate(flat) emissive_layer: u32,
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -222,6 +228,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
let uv = vec2(block, sky) * 16.0;
return textureSampleLevel(lightmap, lightmap_sampler,
@@ -283,9 +298,20 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.local = position;
out.uv = uv;
out.layer = layer;
out.emissive_layer = 0xffffffffu;
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
out.emissive_layer = globals.emissive_layers[i].y;
break;
}
}
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
}
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.height = pos.y;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
out.fade = fade_code(w);
@@ -305,6 +331,15 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
if in.height < globals.fog_ends.w { discard; }
return solid_color(in);
}
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -317,6 +352,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
if in.emissive_layer != 0xffffffffu {
let mask = sprite(in.uv, in.emissive_layer);
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// Water, glass and other see-through faces draw whole as they fade in, the
+53 -13
View File
@@ -307,6 +307,13 @@ pub fn text_scaled(
pub fn hovered(cursor: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
cursor.0 >= x && cursor.0 < x + w && cursor.1 >= y && cursor.1 < y + h
}
/// `TextAlignment.CENTER.calculateLeft` at the box's integer centre:
/// `x + w / 2 - width / 2`. Flooring `(w - width) / 2` sits odd-width labels
/// one pixel left of vanilla when `w` is even.
fn centered_left(x: f32, w: f32, width: f32) -> f32 {
x + (w / 2.0).floor() - (width / 2.0).floor()
}
/// Centered text with a shadow, scrolled when wider than `w` (AbstractWidget.renderScrollingString).
pub fn centered_text(
batch: &mut HudBatch,
@@ -320,14 +327,7 @@ pub fn centered_text(
let text = text.resolved();
let width = atlas.font.width_text(&text);
if width <= w {
batch.text_styled(
atlas,
&text,
x + ((w - width) / 2.0).floor(),
y,
color,
true,
);
batch.text_styled(atlas, &text, centered_left(x, w, width), y, color, true);
} else {
// Clip by characters (vanilla scrolls; we show the part that fits).
let mut shown = Text::empty();
@@ -391,6 +391,27 @@ pub fn slider(
hover: bool,
active: bool,
dragging: bool,
) {
slider_ext(
batch, atlas, x, y, w, label, value, hover, false, false, active, dragging,
);
}
/// `AbstractSliderButton.extractWidgetRenderState`: track, 8px handle at `value` (0..1),
/// with support for keyboard focus and `canChangeValue`.
pub fn slider_ext(
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
w: f32,
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -402,7 +423,7 @@ pub fn slider(
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
let (track, handle) = slider_sprites(hover, active, dragging);
let (track, handle) = slider_sprites_ext(hover, active, focused, can_change_value, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
nine_slice(
batch,
@@ -421,15 +442,26 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
/// Track and handle sprites: the track outline shows only while the slider is
/// dragged, hovering highlights just the handle.
#[cfg(test)]
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
let track = if dragging {
slider_sprites_ext(hover, active, false, false, dragging)
}
/// Track and handle sprites: the track outline shows while dragged or focused with
/// keyboard navigation (!can_change_value), hovering or editing highlights the handle.
fn slider_sprites_ext(
hover: bool,
active: bool,
focused: bool,
can_change_value: bool,
dragging: bool,
) -> (&'static str, &'static str) {
let track = if dragging || (active && focused && !can_change_value) {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let handle = if hover && active {
let handle = if active && (hover || can_change_value || dragging) {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
@@ -1354,6 +1386,14 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn centered_label_matches_vanilla_integer_center() {
// Even box, odd text: (w - width) / 2 floors one pixel left of w/2 - width/2.
assert_eq!(centered_left(0.0, 200.0, 9.0), 96.0);
assert_eq!(centered_left(0.0, 200.0, 8.0), 96.0);
assert_eq!(centered_left(10.0, 196.0, 9.0), 104.0);
assert_eq!(centered_left(2.0, 196.0, 7.0), 97.0);
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
+87
View File
@@ -70,6 +70,9 @@ pub struct World {
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks that just came into this level from a worker, and whether each
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
@@ -109,6 +112,10 @@ pub struct World {
/// The moving-piston block entities of the level, by position, copied
/// from the block ticks after each tick: collision and rendering read them.
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
/// The `ShulkerBoxBlockEntity` lid state of the level, by position, copied
/// from the block entities after each tick: the collision queries read it
/// (`ShulkerBoxBlockEntity.getBoundingBox`), as `moving_pistons` for pistons.
pub shulkers: FxHashMap<[i32; 3], crate::block_entity_anim::Shulker>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Weather {
@@ -188,6 +195,7 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
@@ -203,6 +211,7 @@ impl World {
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
shulkers: FxHashMap::default(),
pois: Default::default(),
}
}
@@ -429,6 +438,84 @@ impl World {
pub fn loaded(&self, x: i32, z: i32) -> bool {
self.chunks.contains_key(&(x >> 4, z >> 4))
}
/// `LockCode`: the `lock` a container block entity refuses to open
/// without, as the tag it was loaded or set with carries it (Horizon's
/// model holds no field for it).
pub fn block_lock(&self, key: (i32, i32, i32)) -> Option<&horizon_world::nbt::Tag> {
self.chunks
.get(&(key.0 >> 4, key.2 >> 4))?
.saved
.as_ref()?
.block_entity_sources
.iter()
.find(|(p, _)| *p == key)?
.1
.get("lock")
}
/// `LockCode.addToTag` for a tag a command gives a container (`setblock`,
/// `data merge`): the tag it was set with keeps the lock, so `data get`
/// shows it, a player's open checks it and the save writes it.
pub fn set_block_lock(&mut self, key: (i32, i32, i32), tag: &horizon_world::nbt::Tag) {
use horizon_world::nbt::Tag;
let lock = tag.get("lock").and_then(|lock| match lock {
Tag::Compound(_) => Some(lock.clone()),
_ => None,
});
self.with_chunk_extras(key, |extras| {
let at = extras
.block_entity_sources
.iter()
.position(|(p, _)| *p == key);
match at {
Some(i) => {
if let Tag::Compound(source) = &mut extras.block_entity_sources[i].1 {
match lock {
Some(lock) => {
source.insert("lock".into(), lock);
}
None => {
source.remove(&"lock".into());
}
}
}
}
// A container set at runtime has no source yet: the tag the
// command gave is the one it was set with, `id` and position
// included (the save checks the id before merging).
None if lock.is_some() => extras.block_entity_sources.push((key, tag.clone())),
None => {}
}
});
}
/// `LevelChunk.removeBlockEntity`: the tag a block entity was loaded or set
/// with goes with it, so a container placed there later starts unlocked.
pub fn forget_block_entity_source(&mut self, key: (i32, i32, i32)) {
self.with_chunk_extras(key, |extras| {
extras.block_entity_sources.retain(|(p, _)| *p != key);
});
}
/// Run `f` on the keep-as-loaded data of the chunk holding `key`, put back
/// for the next save (`block_entity_sources` is where Horizon restores the
/// block entity fields its model does not hold).
fn with_chunk_extras(
&mut self,
key: (i32, i32, i32),
f: impl FnOnce(&mut crate::chunk::ChunkExtras),
) {
let chunk_key = (key.0 >> 4, key.2 >> 4);
let Some(chunk) = self.chunks.get_mut(&chunk_key) else {
return;
};
let chunk = Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(Arc::unwrap_or_clone)
.unwrap_or_default();
f(&mut extras);
chunk.saved = Some(Arc::new(extras));
self.modified.insert(chunk_key);
}
}
/// The game rules and difficulty a player's health reads (`Player.isInvulnerableTo`,
+181 -29
View File
@@ -1,10 +1,11 @@
//! Read-only ZIP archives held in memory (resource packs, data packs and the
//! unifont glyph archive), on the `zip` crate. Like Java's `ZipFile` (what
//! vanilla's `FilePackResources` reads with), entries aren't CRC-checked.
//! Read-only ZIP archives (resource packs, data packs and the unifont glyph
//! archive), on the `zip` crate. Like Java's `ZipFile` (what vanilla's
//! `FilePackResources` reads with), the file stays open and only what is asked
//! for is read, and entries aren't CRC-checked.
use std::{
collections::BTreeMap,
fs,
io::{self, Cursor, Read},
io::{self, Read, Seek, SeekFrom},
path::Path,
sync::Arc,
};
@@ -17,7 +18,7 @@ const MAX_ENTRY: usize = 1 << 30;
/// One archive with its entries indexed by name (`\` read as `/`), files
/// and folders apart.
pub struct Zip {
archive: ZipArchive<Cursor<Arc<[u8]>>>,
archive: ZipArchive<Reader>,
entries: BTreeMap<String, usize>,
/// Folder entries (`name/`) by their name without the slash.
folders: BTreeMap<String, usize>,
@@ -27,20 +28,112 @@ pub struct Zip {
fn invalid(e: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
}
/// `file`'s bytes at `at`, which any number of readers may do at once: the
/// file has no cursor for them to share.
fn read_at(file: &fs::File, out: &mut [u8], at: u64) -> io::Result<usize> {
#[cfg(unix)]
return std::os::unix::fs::FileExt::read_at(file, out, at);
#[cfg(windows)]
return std::os::windows::fs::FileExt::seek_read(file, out, at);
}
/// The bytes of `file` from `start` to `end`.
fn read_range(file: &fs::File, start: u64, end: u64) -> io::Result<Vec<u8>> {
let mut out = vec![0; (end - start) as usize];
let mut done = 0;
while done < out.len() {
match read_at(file, &mut out[done..], start + done as u64)? {
0 => return Err(io::ErrorKind::UnexpectedEof.into()),
read => done += read,
}
}
Ok(out)
}
/// The archive as the `zip` crate sees it: the file's bytes, with its tail
/// (the central directory on, END record normalized) from memory. A clone
/// shares both and has its own position.
#[derive(Clone)]
struct Reader {
/// What comes before the tail; none when the tail is the whole archive.
file: Option<Arc<fs::File>>,
tail: Arc<[u8]>,
tail_at: u64,
position: u64,
}
impl Read for Reader {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
let read = match self.position.checked_sub(self.tail_at) {
Some(into) => {
let rest = self.tail.get(into as usize..).unwrap_or_default();
let read = rest.len().min(out.len());
out[..read].copy_from_slice(&rest[..read]);
read
}
None => {
let before = (self.tail_at - self.position).min(out.len() as u64) as usize;
match &self.file {
Some(file) => read_at(file, &mut out[..before], self.position)?,
None => 0,
}
}
};
self.position += read as u64;
Ok(read)
}
}
impl Seek for Reader {
fn seek(&mut self, to: SeekFrom) -> io::Result<u64> {
let position = match to {
SeekFrom::Start(at) => Some(at),
SeekFrom::End(by) => (self.tail_at + self.tail.len() as u64).checked_add_signed(by),
SeekFrom::Current(by) => self.position.checked_add_signed(by),
};
self.position = position.ok_or_else(|| invalid("seek before the archive's start"))?;
Ok(self.position)
}
}
impl Zip {
/// Reads the central directory, not the entries: opening a pack of many
/// gigabytes for its `pack.mcmeta` costs what opening a small one does.
pub fn open(path: &Path) -> io::Result<Self> {
Self::from_bytes(fs::read(path)?)
let file = fs::File::open(path)?;
let len = file.metadata()?.len();
let mut tail_at = len.saturating_sub((END_HEADER + u16::MAX as usize) as u64);
let mut tail = read_range(&file, tail_at, len)?;
if let Some(end) = find_end(&tail) {
let directory = u32_at(&tail, end + 12);
let start = (tail_at + end as u64).saturating_sub(directory.into());
// All ones says the real length is in the Zip64 record instead.
if directory != u32::MAX && start < tail_at {
tail_at = start;
tail = read_range(&file, tail_at, len)?;
}
}
normalize_end(&mut tail);
Self::new(Reader {
file: Some(Arc::new(file)),
tail: Arc::from(tail),
tail_at,
position: 0,
})
}
pub fn from_bytes(mut data: Vec<u8>) -> io::Result<Self> {
normalize_end(&mut data);
let len = data.len();
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
Self::new(Reader {
file: None,
tail: Arc::from(data),
tail_at: 0,
position: 0,
})
}
fn new(reader: Reader) -> io::Result<Self> {
let len = (reader.tail_at + reader.tail.len() as u64) as usize;
let archive = ZipArchive::new(reader).map_err(invalid)?;
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
for i in 0..archive.len() {
let Some(name) = archive.name_for_index(i) else {
continue;
};
let name = name.replace('\\', "/");
let name = name.map_err(invalid)?.replace('\\', "/");
match name.strip_suffix('/') {
Some(folder) => folders.insert(folder.to_owned(), i),
None => entries.insert(name, i),
@@ -53,6 +146,11 @@ impl Zip {
len,
})
}
/// The central directory on, as read at opening: every entry's name, sizes
/// and checksum, so what changes with the archive's content.
pub fn directory(&self) -> Arc<[u8]> {
self.archive.clone().into_inner().tail
}
/// The files, as `FilePackResources.listResources` lists them (folder
/// entries skipped).
pub fn names(&self) -> impl Iterator<Item = &str> {
@@ -74,7 +172,8 @@ impl Zip {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the bytes and the central directory: reading needs `&mut`.
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
@@ -110,6 +209,26 @@ impl Zip {
/// The 22 fixed bytes of the end-of-central-directory record.
const END_HEADER: usize = 22;
fn u16_at(data: &[u8], at: usize) -> u16 {
u16::from_le_bytes(data[at..at + 2].try_into().unwrap())
}
fn u32_at(data: &[u8], at: usize) -> u32 {
u32::from_le_bytes(data[at..at + 4].try_into().unwrap())
}
/// Where the END record starts. Its comment can hide the signature: scan back
/// through the last 64 KiB, like every zip reader.
fn find_end(data: &[u8]) -> Option<usize> {
if data.len() < END_HEADER {
return None;
}
let start = data.len().saturating_sub(END_HEADER + u16::MAX as usize);
(start..=data.len() - END_HEADER).rev().find(|&pos| {
data[pos..pos + 4] == [0x50, 0x4b, 0x05, 0x06]
&& pos + END_HEADER + u16_at(data, pos + 20) as usize <= data.len()
})
}
/// Java's `ZipFile` ignores the END record's disk numbers and file counts,
/// reading the central directory from `cenpos` to `cenpos + cenlen` whatever
/// they say; the `zip` crate insists the record agrees with the directory.
@@ -119,24 +238,7 @@ const END_HEADER: usize = 22;
/// directory entries and normalize the record so it does not look multi-disk
/// or empty.
fn normalize_end(data: &mut [u8]) {
if data.len() < END_HEADER {
return;
}
let u16_at = |data: &[u8], at: usize| u16::from_le_bytes(data[at..at + 2].try_into().unwrap());
let u32_at = |data: &[u8], at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap());
// The END record's comment can hide the signature: scan back through the
// last 64 KiB, like every zip reader.
let start = data.len().saturating_sub(END_HEADER + u16::MAX as usize);
let mut end = None;
for pos in (start..=data.len().saturating_sub(END_HEADER)).rev() {
if data[pos..pos + 4] == [0x50, 0x4b, 0x05, 0x06]
&& pos + END_HEADER + u16_at(data, pos + 20) as usize <= data.len()
{
end = Some(pos);
break;
}
}
let Some(end) = end else { return };
let Some(end) = find_end(data) else { return };
// The directory's real position and length, as Java derives them; the
// recorded offset is relative to the archive's start.
let cenlen = u32_at(data, end + 12) as usize;
@@ -163,6 +265,56 @@ fn normalize_end(data: &mut [u8]) {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
/// `bytes` as a file and opened, the file removed again where the system lets it.
fn opened(test: &str, bytes: &[u8]) -> io::Result<Zip> {
let path =
std::env::temp_dir().join(format!("horizon-zip-{}-{test}.zip", std::process::id()));
fs::write(&path, bytes)?;
let zip = Zip::open(&path);
let _ = fs::remove_file(&path);
zip
}
#[test]
fn opening_a_file_reads_its_directory_and_not_its_entries() {
let body: Vec<u8> = (0..1 << 20).map(|i: u32| (i ^ i >> 8) as u8).collect();
let bytes = archive_of(&[
("big.bin", &body, false),
("pack.mcmeta", b"{\"pack\":{}}", false),
("a/b.txt", b"hello hello hello hello", true),
]);
let zip = opened("lazy", &bytes).unwrap();
assert!(zip.archive.clone().into_inner().tail.len() < 70_000);
assert_eq!(zip.read("big.bin").unwrap(), body);
assert_eq!(zip.read("pack.mcmeta").unwrap(), b"{\"pack\":{}}");
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
assert!(zip.read("missing").is_err());
}
#[test]
fn a_directory_longer_than_the_end_scan_is_read_whole() {
let names: Vec<String> = (0..2000).map(|i| format!("{:0>60}.txt", i)).collect();
let files: Vec<(&str, &[u8], bool)> = names
.iter()
.map(|n| (n.as_str(), &b"x"[..], false))
.collect();
let zip = opened("long-directory", &archive_of(&files)).unwrap();
assert_eq!(zip.names().count(), 2000);
assert_eq!(zip.read(&names[0]).unwrap(), b"x");
}
#[test]
fn a_file_is_opened_past_end_counts_java_ignores() {
let mut bytes = archive();
let end = bytes.len() - END_HEADER;
bytes[end + 4..end + 6].fill(0xFF);
bytes[end + 8..end + 12].fill(0);
let zip = opened("end-counts", &bytes).unwrap();
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
}
#[test]
fn a_file_that_is_no_archive_does_not_open() {
assert!(opened("empty", b"").is_err());
assert!(opened("garbage", b"PK not really").is_err());
}
/// A hand-built archive with one stored and one deflated entry.
fn archive() -> Vec<u8> {
archive_of(&[
@@ -374,7 +526,7 @@ mod tests {
.unwrap();
let entry = archive.by_index_raw(index).unwrap();
(
entry.data_start() as usize,
entry.data_start().unwrap() as usize,
entry.compressed_size() as usize,
)
};
+1 -1
View File
@@ -56,7 +56,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
@@ -33,12 +33,14 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
@@ -196,5 +198,8 @@
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.loading.opening_world": "Opening world",
"horizon.loading.cancelling_world": "Cancelling world"
}
@@ -11,5 +11,193 @@
"horizon.options.look_deadzone": "Zona muerta de vista",
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.menu_navigation": "Navegación de menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla"
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Retardo del chat",
"horizon.options.lightbar.health": "Salud",
"horizon.options.lightbar.dimension": "Dimensión",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Puntero",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
"horizon.options.search": "Buscar ajustes...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.controller": "Ajustes del mando",
"horizon.pages.controller_binds": "Asignaciones del mando",
"horizon.pages.extras": "Extras de Horizon",
"horizon.pages.settings": "Ajustes de Horizon",
"horizon.pages.agent": "Agente de IA",
"horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
"horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
"horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
"horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
"horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
"horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
"horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
"horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
"horizon.pages.open.graphics": "Gráficos...",
"horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
"horizon.pages.open.shader_packs": "Paquetes de shaders...",
"horizon.pages.open.controller": "Ajustes del mando...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
"horizon.pages.open.controller_binds": "Asignaciones del mando...",
"horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
"horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
"horizon.mods.stopped": " (Detenido)",
"horizon.mods.not_built": " (Sin compilar)",
"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",
"horizon.multiplayer.join_friend": "Unirse a un amigo",
"horizon.multiplayer.invite_code": "Código de invitación",
"horizon.multiplayer.relay": "Relay (opcional)",
"Preset": "Preajuste",
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lejano",
"Cave Culling": "Oclusión de cuevas",
"Entity Culling": "Oclusión de entidades",
"Chunk Updates": "Actualizaciones de chunks",
"Upload Budget": "Presupuesto de subida",
"Sky": "Cielo",
"Fog": "Niebla",
"Weather": "Clima",
"Animated Textures": "Texturas animadas",
"Present Mode": "Modo de presentación",
"Frame Pacing": "Ritmo de fotogramas",
"Worker Threads": "Hilos de trabajo",
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"HDR Settings...": "Ajustes HDR...",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
"UI Brightness": "Brillo de la interfaz",
"Battery Mode": "Modo batería",
"Graphics API": "API de gráficos",
"Performance Overlay": "Superposición de rendimiento",
"Full Detail Distance": "Distancia de detalle completo",
"On": "Activado",
"Off": "Desactivado",
"Unlimited": "Ilimitado",
"Auto": "Automático",
"Custom": "Personalizado",
"Performance": "Rendimiento",
"Balanced": "Equilibrado",
"Quality": "Calidad",
"Ultra": "Ultra",
"Ultra Performance": "Rendimiento ultra",
"Native": "Nativo",
"Full": "Completo",
"Reduced": "Reducido",
"Efficient": "Eficiente",
"Precise": "Preciso",
"Auto (VSync)": "Automático (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Inmediato",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS y gráfico",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Sin coste",
"Low cost": "Coste bajo",
"Medium cost": "Coste medio",
"High cost": "Coste alto",
"Saves time": "Ahorra tiempo",
"Rendering": "Renderizado",
"Display": "Pantalla",
"Preferences": "Preferencias",
"Overlay": "Superposición",
"HDR": "HDR",
"Graphics": "Gráficos",
"Graphics: Advanced": "Gráficos: avanzados",
"Graphics: HDR": "Gráficos: HDR",
"Advanced...": "Avanzados...",
"Simple...": "Simples...",
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
"%s chunks": "%s chunks",
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"%s nits": "%s nits",
"Auto (%s)": "Automático (%s)",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reducido dibuja el terreno lejano con menos detalle, para GPU a las que ralentiza.",
"Field of view. Wider shows more terrain at once.": "Campo de visión. Más amplio muestra más terreno a la vez.",
"Caps frames per second; a cap saves power and heat.": "Limita los fotogramas por segundo; un límite ahorra energía y calor.",
"Waits for the display's refresh: no tearing, a little latency.": "Espera al refresco de la pantalla: sin desgarro, algo de latencia.",
"Size of menus and the HUD.": "Tamaño de los menús y del HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ajusta juntas las opciones siguientes. Cambiar una lo pasa a Personalizado.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el fuego y la luz del sol brillan más que el blanco. Requiere una pantalla HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "El negro más oscuro de tu pantalla. 0 para OLED; las LCD brillan un poco.",
"How bright highlights get. Auto: the peak your system reports.": "Cuánto brillan los reflejos. Automático: el pico que informa tu sistema.",
"How bright white paper and menus look. Auto: your desktop's white.": "Qué tan brillante se ve el blanco del papel y los menús. Automático: el blanco de tu escritorio.",
"How bright the HUD and menus are, against paper white.": "Qué tan brillantes son el HUD y los menús respecto al blanco de papel.",
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
"Blends grass and water colours across biomes when building chunks.": "Mezcla los colores del césped y el agua entre biomas al construir chunks.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Capa de nubes: desactivada, plana (rápida) o 3D (detallada).",
"How far clouds reach, in chunks.": "Hasta dónde llegan las nubes, en chunks.",
"Draws the sky, sun, moon and stars.": "Dibuja el cielo, el sol, la luna y las estrellas.",
"Distance fog. Off shows the edge of the loaded world.": "Niebla de distancia. Desactivada muestra el borde del mundo cargado.",
"Draws rain and snow.": "Dibuja la lluvia y la nieve.",
"How far rain and snow fall around you.": "Hasta dónde caen la lluvia y la nieve a tu alrededor.",
"How many particles effects spawn.": "Cuántas partículas generan los efectos.",
"Round shadows under mobs and items.": "Sombras redondas bajo mobs y objetos.",
"Leaves with see-through gaps. Off draws them solid.": "Hojas con huecos transparentes. Desactivado las dibuja sólidas.",
"Sorts water and glass more exactly behind each other.": "Ordena el agua y el cristal con más precisión unos tras otros.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Qué reconstrucciones de chunks esperan al fotograma: ninguna, cercanas o la tuya.",
"Animates water, lava, fire and other moving textures.": "Anima el agua, la lava, el fuego y otras texturas en movimiento.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxeles del mundo por píxel de pantalla. Por debajo del 100 % es más rápido en la GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
"How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
"Fills the screen (borderless).": "Llena la pantalla (sin bordes).",
"Brightens dark places.": "Ilumina los lugares oscuros.",
"Lowers the frame rate when minimised, or idle too.": "Reduce los fotogramas por segundo al minimizar o al estar inactivo.",
"Sways the view while walking.": "Balancea la vista al caminar.",
"Fades newly loaded chunks in.": "Hace aparecer gradualmente los chunks recién cargados.",
"Darkens the screen's edges.": "Oscurece los bordes de la pantalla.",
"Shows when the world saves.": "Muestra cuándo se guarda el mundo.",
"Shows attack cooldown.": "Muestra el enfriamiento del ataque.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
"Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
}
Loaded 100 of 811 files, more files were not shown because too many files have changed in this diff. Show more