Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 b7b3c2cedf launcher: GitHub's mark on Sponsor on GitHub, Sculk's card on every page, no border
dezer-x's launcher feedback, points 12 and 13:
- Sponsor on GitHub carries GitHub's mark (github.svg in the glass icons, drawn for Horizon:
  filled, its cat cut out by a mask).
- Sculk's card stays at the sidebar's foot on every page, Servers too; its border is gone (it
  still lifts under the pointer).
- On Servers, Sculk's banner sits right under the server, where it shows without scrolling past
  the server's tabs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:09:00 +01:00
Ludovic Marie 4601a3ba9f Merge pull request #8802 from dontstopgorkingit/vec_it-game
🤖 Agent: union B6 (fur li lmo oc sah se szl vec: 16 PRs) stacked on #8610, ALL = 87 languages: tables complete at main's 1093 strings, controller labels written as commands fixed; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:54:54 +01:00
Ludovic Marie e82799f414 Merge pull request #8610 from dontstopgorkingit/fo_fo-game
🤖 Agent: union B5 (tt yi yo zu ba br cv fo: 15 PRs + #8600 closed) stacked on #8579: zu_za game table dropped (vanilla 26.3 ships no zu_za), Bashkir's two broken {done}/{total} placeholders fixed; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed).
2026-11-04 10:54:42 +01:00
Ludovic Marie 90a4324c48 Merge pull request #8579 from dontstopgorkingit/la_la-game
🤖 Agent: union B4 (gd lb ky kn mt eo haw ig la: 18 PRs) stacked on #8496: Esperanto now has a table, so the two launcher tests that used "eo" as a table-less language (i18n pick, settings parse) use "tlh"; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed).
2026-11-04 10:54:21 +01:00
Ludovic Marie b87821c99d Merge pull request #8496 from dontstopgorkingit/fy_nl-game
🤖 Agent: union B3 (ga eu gl fil ta mn lo so fy: 18 PRs) stacked on #8450: Lao's 323 U+200B removed, 56 Tamil entries with Horizon/Minecraft in Tamil script restored to Latin, missing strings filled; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:54:08 +01:00
Ludovic Marie b1f5b238b8 Merge pull request #8450 from dontstopgorkingit/cy_gb-game
🤖 Agent: union B2 (az uz kk ka hy bs mk sq cy: 18 PRs) stacked on #8425: missing launcher strings filled, Instance written as "example" in hy/mk fixed, product names restored; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:50:49 +01:00
Ludovic Marie f0fd35b635 Merge pull request #8425 from dontstopgorkingit/be_by-game
🤖 Agent: union B1 (ca et lv sl sr is af ms be: 18 PRs, octopus of their heads + fixes): 66/58 missing launcher strings filled per table, 152 Serbian entries had Horizon/Minecraft transliterated (Latin again), game sr table now serves vanilla's Cyrillic sr_sp and a Latin transliteration for sr_cs; desktop: launcher fmt, clippy -D warnings, cargo test + horizon-client-core fmt/clippy/test green (619 passed); verify + launcher green.
2026-11-04 10:50:41 +01:00
Ludovic Marie 33816de710 Merge pull request #8122 from TiggerTakkyTTT/fix/server-management-simulation-distance
🤖 Agent: Hub::set_simulation_distance broadcasts SetSimulationDistance and state.view.1 keeps the status reply in step; third of the management stack, after #8118.
2026-11-04 09:33:26 +01:00
Ludovic Marie 39c8cd6533 Merge pull request #8118 from TiggerTakkyTTT/fix/server-management-view-distance
🤖 Agent: max(2).min(view) replaces clamp(2, view), which panicked when a management value below 2 made the bounds cross; second of the management stack, after #8108.
2026-11-04 09:32:42 +01:00
Ludovic Marie a937e21cac Merge pull request #8108 from TiggerTakkyTTT/fix/server-management-spawn-protection
🤖 Agent: hub.spawn_protection is the radius the block checks read, so the live set applies at once; base of the management stack (#8118, #8122, #8128 carry it in that order).
2026-11-04 09:32:00 +01:00
Ludovic Marie 377db4c754 Merge pull request #8103 from CocofireHD/fix/avoid-entity-targeting
🤖 Agent: merged main; since #8046 can_target_player already refuses a player in Peaceful (spares_players), so I dropped the goal's own difficulty check and the Peaceful test still covers it.
2026-11-04 09:31:25 +01:00
Ludovic Marie bedf814d2f Merge pull request #9046 from horizon-rust/perf/collision-scan-caches
🤖 Agent: only may_reach_out answers are cached (8 sections); the chunk queries keep vanilla's one-column cache, and the block_collision_scan harness checks that query trace against the oracle. Collision samples −21 % in a profile; A/B in the noise.
2026-11-04 09:28:45 +01:00
Ludovic Marie cb2b398f6e Merge pull request #9041 from horizon-rust/perf/sculk-listeners-by-cell
🤖 Agent: small win (tick_sculk_entities −0.2 to −0.3 ms, 3/3 pairs). Risk point: listener order. The cell lookup sorts back by insertion rank, and a randomized test checks it against the old full-list filter through inserts, removals and retains. sculk, server_player_sculk and mob_ai pass.
2026-11-04 09:28:42 +01:00
Ludovic Marie 9813851777 Merge pull request #9040 from horizon-rust/perf/lib-inline-accessors
🤖 Agent: random_ticks −3.9 to −4.4 ms in 3/3 A/B pairs. Six #[inline] attributes, no body changed; random_tick, random_tick_sections, mob_ai, server_entities, natural_spawn and villager_farming match vanilla.
2026-11-04 09:28:38 +01:00
Ludovic MarieandClaude Opus 5.5 1afc192c4d Merge the B4 settings-test fix into the B6 union
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
Ludovic MarieandClaude Opus 5.5 2ba6a92b70 Merge the B4 settings-test fix into the B5 union
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
Ludovic MarieandClaude Opus 5.5 b1c0adffdc Launcher settings test: use a language without a table
Esperanto has a table now, so the settings test that reads an unknown saved language uses "tlh".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
Ludovic Marie 017c84f1fe Merge pull request #8719 from xxaxio/fix/8628-sniffer-digging
🤖 Agent: vanilla Sniffer/ClientPacketListener event 63 and SnifferSoundInstance checked; with the head merged into main, ambient_sound, server_entities, sound_engine, sound_event and dedicated_server pass on playhorizon.
2026-11-04 08:54:21 +01:00
Ludovic Marie f187b05511 Merge pull request #9035 from horizon-rust/fix/skin-layers-own-file
🤖 Agent: options failed on main (69ab59ef0) with 17524 differing cases from the extra threeDSkinLayers line. With this head it passes on playhorizon. The switch moves to horizon-extras.txt, and the old options.txt line is read once, so a player's setting is kept (unit test). fmt, clippy -D warnings and 3506 tests pass on the desktop.
2026-11-04 08:51:48 +01:00
Ludovic Marie 2a7ea4750e Merge pull request #9034 from horizon-rust/fix/armor-stand-speed-timing
🤖 Agent: command_exec failed on main (69ab59ef0) with one mismatch: a stand's summon-tick /data get printed attributes [movement_speed] where vanilla prints []. With this head it passes, 4374/4374 commands, on playhorizon. Vanilla only creates that instance in aiStep's removeFrost (AttributeMap.getInstance computeIfAbsent). fmt, clippy -D warnings and 2489 tests pass on the desktop.
2026-11-04 08:51:44 +01:00
Ludovic MarieandClaude Opus 5.5 3980c45b0f 3D skin layers: keep the switch in horizon-extras.txt, not options.txt
#8091 wrote threeDSkinLayers into options.txt, a key vanilla doesn't have,
so every options.txt Horizon saved was one line off vanilla's. It now lives
in horizon-extras.txt with Horizon's other switches. A threeDSkinLayers line
in options.txt is read once, while horizon-extras.txt has none of its own,
so a player who turned it on keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:38:53 +01:00
Ludovic Marie c61c2cce36 Merge pull request #8908 from Joansitoh/t3/sync-gateway-cooldown-beam
🤖 Agent: both the portal-tick trigger and an entity entering now go through triggerCooldown's block event 1 to the window, so the beam starts there; gateway_available keeps reading the server copy's cooldown.
2026-11-04 08:31:16 +01:00
Ludovic Marie f3f56ed758 Merge pull request #8903 from Joansitoh/fix/water-bottle-splash-particles
🤖 Agent: the packet is count 1, no spread, speed 1, so each client draws a Gaussian velocity, matching PotionItem.useOn's sendParticles(SPLASH, …, 1, 0, 0, 0, 1.0); the positions still come from the server's random.
2026-11-04 08:31:12 +01:00
Ludovic Marie 0c58d10960 Merge pull request #8902 from Joansitoh/fix/end-portal-travel-sound
🤖 Agent: hub.land already sent level event 1032 at BlockPos.ZERO; playing it as local ambience (pitch nextFloat()*0.4+0.8, volume 0.25), as LevelEventHandler does, is what makes it audible. Nothing else in Horizon plays block.portal.travel, so there is no double sound.
2026-11-04 08:31:08 +01:00
Ludovic Marie 87391f1f0f Merge pull request #8900 from Joansitoh/fix/decorated-pot-insert-fx
🤖 Agent: wobble is a Level.blockEvent (event 1, the WobbleStyle ordinal) and the dust is sendParticles(DUST_PLUME, 7), so both now reach the window; the server's own block_entity_event stays for its copy.
2026-11-04 08:31:03 +01:00
Ludovic Marie de61a53fff Merge pull request #8898 from Joansitoh/fix/cube-client-squish
🤖 Agent: oSquish is copied before the step toward targetSquish, then decreaseSquish (0.9 for magma cubes, 0.6 otherwise), the same order as AbstractCubeMob.tick, so the client's squish animates between packets.
2026-11-04 08:30:58 +01:00
Ludovic Marie 630e7e8ee2 Merge pull request #8880 from Joansitoh/fix/8865-minecart-tnt-event-70
🤖 Agent: event 70 only sets the client's fuse to 80 (MinecartTNT.handleEntityEvent -> primeFuse on a client), and the window counts it down with the smoke; the explosion stays the server's.
2026-11-04 08:30:53 +01:00
Ludovic Marie f9db8444d6 Merge pull request #8877 from Joansitoh/fix/fox-defending-aggro
🤖 Agent: the nextFloat() < 0.05 draw happens for every defending fox, silent or not, then Entity.playSound skips a silent one: the same random order as Fox.aiStep after super.aiStep.
2026-11-04 08:30:49 +01:00
Ludovic Marie f4ebaaf6b0 Merge pull request #8874 from Joansitoh/fix/primed-tnt-window-smoke
🤖 Agent: the smoke is added after the fuse decrement and the discard, at the moved position, as PrimedTnt.tick does on a client; the server's addParticle never reached the window.
2026-11-04 08:30:45 +01:00
Ludovic Marie 6143cc90a1 Merge pull request #8712 from Joansitoh/fix/8448-arrow-sound-feet
🤖 Agent: BowItem.releaseUsing and CrossbowItem.performShooting play at the shooter's getX/Y/Z, so the feet are vanilla; the eye-height source a few cm from the listener was what panned hard.
2026-11-04 08:30:42 +01:00
Ludovic Marie d522c111f3 Merge pull request #8710 from Joansitoh/fix/8594-nautilus-ambient
🤖 Agent: only the nautilus's breath (ambient) skips the creative/spectator gate. Vanilla's Dolphin's Grace also reaches creative players (DolphinSwimWithPlayerGoal uses forNonCombat targeting), but main already blocked that before this PR, so it is a separate gap.
2026-11-04 08:30:38 +01:00
Ludovic Marie becb9bdec1 Merge pull request #8649 from Joansitoh/fix/8573-health-boost-clamp
🤖 Agent: the remove-all/re-add batches (effects, equipment, enchantments) use set_modifier_unclamped and clamp once afterwards, so an unrelated effect refresh keeps health_boost hearts; LivingEntity.onAttributeUpdated clamps only on a real max_health drop.
2026-11-04 08:30:34 +01:00
Ludovic Marie bd6000145c Merge pull request #8643 from Joansitoh/fix/8521-space-focus
🤖 Agent: Tab on Create World sets focus=2, so has_caret_field() is false while a tabbed button is focused and Space still presses it; only a click into a field drops the button focus, as Screen.setFocused does.
2026-11-04 08:30:29 +01:00
Ludovic MarieandClaude Opus 5.5 6675ee77a8 Armor stands: movement_speed is saved from their first tick, not at summon
LivingEntity.aiStep's removeFrost makes the movement_speed instance through
getAttribute on the stand's first server tick; AttributeMap.load makes it for
a save that lists it. A /data get in the summon tick showed it since #8420.
A stand now records when that instance exists and saves it from then on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:28:36 +01:00
Ludovic Marie 9f0089dd8f Merge pull request #9026 from horizon-rust/perf/core-inline-small
🤖 Agent: −4.8 to −5.0 ms MSPT in 3/3 A/B pairs (fast profile, which native mod builds use). Only #[inline] attributes, no body changed; 10 harnesses incl. mob_ai, entity_move and collision pass.
2026-11-04 08:27:31 +01:00
Ludovic Marie a540150a5b Merge pull request #9025 from horizon-rust/perf/lan-speed-by-type
🤖 Agent: tick_lan −0.3 to −0.6 ms in 3/3 A/B pairs; the 9 attribute transcript harnesses (server_entities, mob_attributes, attribute_updates…) match vanilla. Risk point: the per-type cache holds only the type's default and synced id, never a mob's own modifiers, which still go through base_value_with per mob.
2026-11-04 08:27:28 +01:00
Ludovic Marie 69ab59ef00 Merge pull request #8736 from TheMrZZ/fix/noai-ravager-counters
🤖 Agent: the NoAI branch now runs ravager::ai_step after ageable_step, as Ravager.aiStep does outside serverAiStep. The corpus group pins the stun-to-roar boundary at tick 2 for both AI modes.
2026-11-04 08:17:53 +01:00
Ludovic Marie 7b224efb3c Merge pull request #8927 from TheMrZZ/fix/trident-stack-durability
🤖 Agent: release now rechecks nextDamageWillBreak after the wet/passenger test, as TridentItem.releaseUsing does, and the stack's max_damage component is sent over the network, so both peers admit the same throws.
2026-11-04 08:17:49 +01:00
Ludovic Marie 2ab18c4c6a Merge pull request #8904 from Joansitoh/fix/remote-dragon-death-effects
🤖 Agent: I collapsed the growl's if-let into a let chain, which was the clippy failure. The dead path follows EnderDragon.aiStep after tickDeath: growl (pitch, then nextInt(200)) before the per-tick EXPLOSION, both at the risen position.
2026-11-04 08:17:45 +01:00
Ludovic Marie 846cf334a3 Merge pull request #8884 from Joansitoh/fix/water-entry-splash
🤖 Agent: I added the missing RandomSource import and made water_entry pub, since only gameplay calls it and horizon-client-lib flagged it as dead code. The item splash draws from a random seeded per item (id, ticks), not the entity's own; that only affects particle spread.
2026-11-04 08:17:41 +01:00
Ludovic Marie 1b6abae1ce Merge pull request #8876 from Joansitoh/fix/rabbit-hop-sprint-dust
🤖 Agent: I moved the block lookup, clamp and velocity into one sprint_particle helper shared with sprint_dust, so the hop's crumb differs only in drawing from the rabbit's own random (x then z), as Entity.spawnSprintParticle does.
2026-11-04 08:17:36 +01:00
Ludovic Marie f63ed5dfdb Merge pull request #8856 from TheMrZZ/fix/h186-noai-allay-cooldown
🤖 Agent: Allay.updateDuplicationCooldown runs in aiStep, outside serverAiStep, so NoAI allays count down too; the normal path calls the same helper once, from allay::ai_step.
2026-11-04 08:17:31 +01:00
Ludovic Marie 61be71c323 Merge pull request #8796 from TheMrZZ/fix/h184-fish-color-bytes
🤖 Agent: DyeColor.byId uses ByIdMap's ZERO strategy, so any color byte above 15 reads as white; masking with & 15 had turned 17 into 1. The new dedicated-server harness builds its binary the way test_dedicated_server_parity does.
2026-11-04 08:17:27 +01:00
Ludovic MarieandClaude Opus 5.5 4d32656aa0 Collision scan: remember the last eight sections' reach
A box across a chunk border goes back and forth between two columns on
every row, so the single remembered may_reach_out answer was recomputed
(a palette walk) at each crossing. The scan now keeps the last eight
(chunk, section) answers; the cells read and the chunk queries are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:13:00 +01:00
Ludovic MarieandClaude Opus 5.5 e0f28aeaf0 Sculk: a game event finds its listeners by cell
Every game event (each mob's step is one) filtered the level's whole list
of sculk block entities by distance. The index now also keeps them by
32-block cell with their insertion rank, and an event reads the cells its
radius reaches (at most eight), sorted back by rank: the same listeners in
the same order as the filtered list. A test checks it against that filter
through inserts, removals and retains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:44:45 +01:00
Ludovic MarieandClaude Opus 5.5 95cd9455a0 Inline the hottest small accessors read across crates
Mob::species, alive and bounds (read by the game crate for every mob),
BlockFacts::get (an index into the state table), RandomTickMask::may_tick
(a bit test per drawn position) and World::block (a chunk lookup and a
palette read) ran as calls from the game crate in the fast profile that
native mod builds use. Marked #[inline]; bodies unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:23:01 +01:00
Kacper 429b0d0f25 Merge pull request #8693 from Akirabane/fix/7484-save-gaps
A player's save keeps their motion, food timer, freezing and previous game mode
2026-11-04 07:21:42 +01:00
Ludovic MarieandClaude Opus 5.5 6c40453478 Core: inline the small vector, box and position helpers
Without LTO across crates (the fast profile, which native mod builds use
as performance), a non-generic function from horizon-core is only inlined
into another crate when it is marked #[inline] or is a tiny leaf. Aabb::new,
Aabb::max, max_position, deflate, Vec3's arithmetic, BlockPos::offset and
chunk and PalettedContainer::is_global ran as calls on the server's hottest
paths (the collision scan, the snapshot, mob movement), each returning its
box or position through memory. They are marked #[inline]; their bodies are
unchanged, so every value is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 06:59:46 +01:00
Ludovic MarieandClaude Opus 5.5 f2033ffd0b Server: a snapshot looks a mob type's speed attribute up once
Every tick's entity snapshot looked movement speed up by name for every
mob: the catalog's id, the type's synced default (two string-keyed maps)
and the type's own default base (a SipHash of the attribute's name).
They depend only on the type, so a snapshot keeps them per type; each
mob's base still comes from its own finalized bases first. The synced
instances are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 06:43:32 +01:00
Ludovic Marie d8edfbcd0f Merge pull request #8989 from horizon-rust/perf/shape-moved-allocs
🤖 Agent: verify green; profile: allocator samples under do_move halved (178 -> 88), ~-0.3 ms/tick, spans below noise; pr_ready --quick passes; 18/18 collision/shape harnesses PASS incl. geometry, entity_move, mob_ai.
2026-11-04 06:28:53 +01:00
Ludovic Marie c9d734aabc Merge pull request #8988 from horizon-rust/perf/random-tick-mask-rc
🤖 Agent: verify green; A/B 3 pairs random_ticks -0.62/-0.40/-0.28 ms/tick; pr_ready --quick passes (2,910 tests); 4/4 harnesses PASS (random_tick_sections, random_tick, villager_farming, dedicated_server).
2026-11-04 06:28:49 +01:00
Ludovic MarieandClaude Opus 5.5 c8659d2757 Fix the splash's borrow of the remote state and the new item_areas test item
The window's item splashes ran while r.head_lerps was still borrowed; they
now run after it is released. Main's item_areas test builds an ItemEntity,
which needs was_touching_water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:55:53 +01:00
Ludovic MarieandClaude Opus 5.5 b5b62e3262 Merge main into the water-entry splash
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:55:32 +01:00
Ludovic MarieandClaude Opus 5.5 b39fc62dc4 Make ItemEntity::water_entry public: only gameplay calls it
horizon-client-lib builds without gameplay, so a pub(crate) water_entry and
its ItemWater were dead code there (clippy -D warnings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:48:59 +01:00
Ludovic MarieandClaude Opus 5.5 94cb4d95f9 Share Entity.spawnSprintParticle between sprinting players and a rabbit's hop
The hop's crumb reused a copy of sprint_dust's block lookup, clamp and
velocity; both now call one sprint_particle helper and differ only in the
random that places the crumb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 370af2fa60 Import RandomSource for the item splash's random draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:40:19 +01:00
Ludovic MarieandClaude Opus 5.5 d7fbec125d Collapse the dead dragon's growl check (clippy collapsible_if)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:39:15 +01:00
Ludovic Marie f40d4df781 Merge pull request #8962 from horizon-rust/perf/turtle-egg-search-palette
🤖 Agent: verify green; profile: AttackTurtleEgg 0.44 % -> 0.01 % of the tick (nextStartTick draw unchanged); pr_ready --quick passes (1,654 client-lib tests); 8/8 harnesses PASS on the wave-2 stack incl. mob_ai.
2026-11-04 05:17:11 +01:00
Ludovic Marie 97f6d296b6 Merge pull request #8961 from horizon-rust/perf/cart-tick-no-clone
🤖 Agent: verify green; A/B 3 pairs tick_carts -0.41 ms/tick; pr_ready --quick passes; 8/8 harnesses PASS on the wave-2 stack incl. vehicle_container.
2026-11-04 05:17:07 +01:00
Ludovic Marie baee70be80 Merge pull request #8960 from horizon-rust/perf/item-scans-cells
🤖 Agent: verify green; A/B 3 pairs creature -2.34 ms/tick at 6,200 mobs; pr_ready --quick passes; 8/8 harnesses PASS on the wave-2 stack (mob_ai, server_entities, dedicated_server, vehicle_container, dispenser, finalize_spawn, natural_spawn, entity_death_loot).
2026-11-04 05:17:03 +01:00
Ludovic MarieandClaude Opus 5.5 1191e6b721 Random ticks: a section's mask is shared without an atomic count
Every random tick handed out each ticking section's mask by cloning an
Arc twice (the cache, then the chunk's last answer) and dropping both
again. The mask is usually out of cache, and a locked increment on a
missed line stalls the loop: about 5 % of the server thread on a busy SMP
profile. The masks never leave the tick thread, so they are Rc now. The
draws, the masks and the ticks are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:14:49 +01:00
Ludovic Marie db7134ac66 Merge pull request #8787 from dontstopgorkingit/perf/atlas-memory
🤖 Agent: measured on the desktop with pack_load_bench, 5 alternating A/B rounds on main eaa7da4fb, medians: block sprites one by one 100 → 80 ms, prefetched 44 → 35 ms, from the disk cache 41 → 30 ms, CTM prefetched 84 → 64 ms; stages that don't insert sprites are unchanged. The still-texture key no longer clones each frame's pixels. One risk: two different textures sharing a 64-bit SipHash would share a layer, about 1e-12 at a few thousand sprites.
2026-11-04 05:02:47 +01:00
Ludovic Marie a8dd3a4106 Merge pull request #8572 from dontstopgorkingit/ci/concurrency-backlog
🤖 Agent: I kept only the concurrency groups on mod-registry and telemetry-server: a newer push cancels a superseded PR run, and main's runs all finish. I dropped the pr-labels and triage-command groups, where a newer run cancels a pending one even when its own job is skipped (a title-only edit, a plain comment), so labels or a /triage would be lost. I also dropped the cron move, which had no stated reason.
2026-11-04 04:59:06 +01:00
Ludovic Marie 79e09456c4 Merge pull request #8806 from dontstopgorkingit/docs/readme-refresh
🤖 Agent: the gap block is exactly what `tools/parity_gaps.py readme` prints today (all ten lists #3512-#3521 at 0 open), and "all 281 harnesses" was out of date (there are 324+), so dropping the count is right.
2026-11-04 04:59:01 +01:00
Ludovic Marie 3f6f3a6dc1 Merge pull request #8478 from dontstopgorkingit/docs/contributing-shaders
🤖 Agent: CONTRIBUTING still said shader packs were removed on 24 October. They are back as a clean-room rewrite (horizon-shaders) plus Horizon's own API. The known-issues bullet was already removed on main (279a04a8eb), so I took main's file to resolve the conflict.
2026-11-04 04:58:58 +01:00
Ludovic Marie 2e1e541e02 Merge pull request #8812 from dontstopgorkingit/docs/shader-api
🤖 Agent: main does have the shader API (`manifest.rs`: API = "0.1", with a generated reference), so "none yet / not started" was wrong. I fixed the intro, which said the API was built "from scratch on top of" the removed one, listed the real stages (vertex was missing), and noted that F3 already shows pass costs.
2026-11-04 04:58:54 +01:00
Ludovic Marie 0ffc3ec0bb Merge pull request #8805 from dontstopgorkingit/docs/refresh-hosting-docs
🤖 Agent: every flag added here matches horizon-server's usage text and parsers on main (`restore --dimension`, `update --archive/--version/--timeout`, `rollback --timeout`, `[updates] keep = 1`). I put back the command descriptions the edit dropped and pointed import-resources at step 2, where it is explained.
2026-11-04 04:58:50 +01:00
Ludovic Marie b31a68ea8d Merge pull request #8791 from dontstopgorkingit/worktree-agent-afb089f7829283ca5
🤖 Agent: the audio callback allocated a fresh `voices` Vec on every buffer, on the real-time thread; it now reuses one in `Mixer`, as `cameras` already did. Mixing order and gains are unchanged. I rustfmt-ed it.
2026-11-04 04:58:46 +01:00
Ludovic Marie d2e82c7339 Merge pull request #8756 from dontstopgorkingit/perf/mob-pathfinding
🤖 Agent: `swim_neighbors` built an `FxHashMap` (a heap allocation plus 6 inserts and 8 lookups) for every node a water mob's pathfinder expands. A `[Option<u32>; 6]` indexed in `Direction.values()` order does the same: I checked each diagonal pair against the old `(dx,dz)` keys, and the call order of `swim_accepted` is unchanged. I rustfmt-ed it and replaced the index loop with a zip (clippy).
2026-11-04 04:58:42 +01:00
Ludovic MarieandClaude Opus 5.5 cefa8659ec Shapes: a moved shape allocates its coordinates once
A moved VoxelShape cloned its three coordinate vectors (three allocations
plus their Arc) and its boxes vector (two more). Every block an entity's
movement meets is moved to its position, several per mob per tick. The
three axes now share one slice (axis i is values[starts[i]..starts[i+1]])
and the boxes are an Arc<[Aabb]>, so a moved shape makes three allocations
instead of seven. Every boundary gets the same addition as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:54:01 +01:00
Ludovic MarieandClaude Opus 5.5 f7311a0ec6 Translation fixes for fur, li, lmo, oc, sah, se, szl, vec
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:07 +01:00
Ludovic MarieandClaude Opus 5.5 8d840de551 Union of 16 translation PRs: Furlan, Limburgs, Lombard, Occitan, Саха тыла, Davvisámegiella, Ślōnskŏ, Vèneto
Merges the heads of #8755, #8757, #8761, #8764, #8767, #8768, #8770, #8771, #8777, #8781, #8792, #8793, #8794, #8797, #8800, #8802 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:05 +01:00
Ludovic MarieandClaude Opus 5.5 ca2f38dbbf Translation fixes for tt, yi, yo, zu, ba, br, cv, fo
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- Zulu: vanilla 26.3 ships no zu_za language, so the game table could never be picked:
  removed (the launcher table stays).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:04 +01:00
Ludovic MarieandClaude Opus 5.5 36feb04d68 Union of 16 translation PRs: Татарча, ייִדיש, Yorùbá, isiZulu, Башҡортса, Brezhoneg, Чӑвашла, Føroyskt
Merges the heads of #8580, #8581, #8585, #8587, #8596, #8597, #8599, #8600, #8601, #8603, #8605, #8606, #8607, #8608, #8609, #8610 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:02 +01:00
Ludovic MarieandClaude Opus 5.5 d901cbf6b2 Translation fixes for gd, lb, ky, kn, mt, eo, haw, ig, la
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- The launcher test used "eo" as a language without a table: it now uses "tlh".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:01 +01:00
Ludovic MarieandClaude Opus 5.5 317df1503e Union of 18 translation PRs: Gàidhlig, Lëtzebuergesch, Кыргызча, ಕನ್ನಡ, Malti, Esperanto, ʻŌlelo Hawaiʻi, Igbo, Latina
Merges the heads of #8528, #8529, #8530, #8532, #8533, #8535, #8537, #8538, #8540, #8544, #8550, #8551, #8556, #8560, #8564, #8568, #8574, #8579 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:59 +01:00
Ludovic MarieandClaude Opus 5.5 c635912558 Translation fixes for ga, eu, gl, fil, ta, mn, lo, so, fy
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:58 +01:00
Ludovic MarieandClaude Opus 5.5 e3159f5bb8 Union of 18 translation PRs: Gaeilge, Euskara, Galego, Filipino, தமிழ், Монгол, ລາວ, Soomaali, Frysk
Merges the heads of #8451, #8452, #8453, #8454, #8455, #8456, #8458, #8467, #8468, #8469, #8472, #8473, #8475, #8477, #8483, #8484, #8495, #8496 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:57 +01:00
Ludovic MarieandClaude Opus 5.5 4b3d97f569 Translation fixes for az, uz, kk, ka, hy, bs, mk, sq, cy
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:56 +01:00
Ludovic MarieandClaude Opus 5.5 e3a960c2e8 Union of 18 translation PRs: Azərbaycanca, Oʻzbekcha, Қазақша, ქართული, Հայերեն, Bosanski, Македонски, Shqip, Cymraeg
Merges the heads of #8427, #8428, #8431, #8432, #8434, #8435, #8436, #8437, #8439, #8440, #8441, #8442, #8444, #8445, #8446, #8447, #8449, #8450 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:55 +01:00
Ludovic MarieandClaude Opus 5.5 3419da9b42 Translation fixes for ca, et, lv, sl, sr, is, af, ms, be
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- Also removed stray U+200B from main's bg, fi, he launcher tables and the da_dk game table.
- Serbian: the Cyrillic game table is vanilla's sr_sp (Српски); sr_cs is vanilla's Latin
  Serbian, so it now gets the same table transliterated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:54 +01:00
Ludovic MarieandClaude Opus 5.5 eac75f8b3d Union of 18 translation PRs: Català, Eesti, Latviešu, Slovenščina, Српски, Íslenska, Afrikaans, Bahasa Melayu, Беларуская
Merges the heads of #8394, #8395, #8396, #8398, #8401, #8402, #8405, #8408, #8410, #8412, #8413, #8414, #8415, #8416, #8417, #8418, #8424, #8425 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:52 +01:00
Ludovic MarieandClaude Opus 5.5 3fef07f080 ci: no concurrency group on triage-command
Every comment on an issue starts a run (most skip their job), and a newer run cancels a pending one in the
same group: a /triage waiting behind another run would be dropped by the next plain comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:39 +01:00
Ludovic MarieandClaude Opus 5.5 e5953f9e97 ci: keep pr-labels and the claim cron as on main
A title-only edit's run skips its job, but as a newer run in the group it would still cancel the opened run's
labelling. The claim cron move had no reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:28 +01:00
Ludovic MarieandClaude Opus 5.5 48430a7647 shader docs: one consistent intro, the stages that exist, what is still missing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:21 +01:00
Ludovic MarieandClaude Opus 5.5 8a442b616b hosting: keep the command descriptions, point import-resources at step 2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:01 +01:00
Ludovic Marie 9048266788 Merge remote-tracking branch 'origin/main' into rv8-8478
# Conflicts:
#	docs/known-issues.md
2026-11-04 04:40:39 +01:00
Ludovic Marie eba1f44312 Merge remote-tracking branch 'origin/main' into rv8-8572 2026-11-04 04:40:19 +01:00
Ludovic Marie 847d64ca9f Merge remote-tracking branch 'origin/main' into rv8-8806 2026-11-04 04:40:18 +01:00
Ludovic Marie 02423ba72e Merge remote-tracking branch 'origin/main' into rv8-8812 2026-11-04 04:40:17 +01:00
Ludovic Marie a47ed5699e Merge remote-tracking branch 'origin/main' into rv8-8805 2026-11-04 04:40:16 +01:00
Ludovic MarieandClaude Opus 5.5 e124a775b1 path: fill the swim directions by zip, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:39:52 +01:00
Ludovic MarieandClaude Opus 5.5 bc29f62f9e audio: rustfmt the reused voices buffer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:39:32 +01:00
Ludovic Marie c351350bad Merge remote-tracking branch 'origin/main' into rv8-8756 2026-11-04 04:39:07 +01:00
Ludovic Marie 79fc2426f3 Merge remote-tracking branch 'origin/main' into rv8-8791 2026-11-04 04:39:06 +01:00
Ludovic MarieandClaude Opus 5.5 775bdc3c3e Mobs: a zombie's turtle egg search asks the section palettes first
RemoveBlockGoal.canUse walks every cell within 24 blocks and a few
heights of the zombie (about 17,000 of them) for a turtle egg, once
every 10 to 20 seconds per zombie: a server's thousands of zombies
walked over a hundred thousand cells a tick, nearly always to find
none. The walk is passed over when no section of the cells it reaches
has a turtle egg in its palette, which is when it would find nothing;
anywhere else it walks as before. The walk and its bounds are split
out of find_nearest, and a test checks every walked cell is in them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:52:20 +01:00
Ludovic MarieandClaude Opus 5.5 47a372677c Mobs: a looting reach with no dropped item near it skips the item scan
Mob.aiStep's looting block, the foxes' item search and the dolphins'
tested each mob's reach against every dropped item of the level, so a
server's thousands of mobs times its eggs and drops made a quadratic
scan every tick. The items are now kept in 8-block cells for each pass:
a reach that meets no occupied cell meets no item, and its scan (which
would find nothing) is skipped. A reach near an item scans as before,
in the same order with the same tests, and the cells are rebuilt as
soon as an item is spawned or taken during the pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:43:46 +01:00
Ludovic Marie eaa7da4fb1 Merge pull request #8684 from NikitaMGrimm/hdr-16-bit-frame
🤖 Agent: HDR off or Smooth Gradients off keeps main's frame format at every site (checked each one); format selection now in tested functions; fmt, clippy and the touched crates' tests pass on the desktop at b750f4d7e0.
2026-11-04 03:40:38 +01:00
Ludovic MarieandClaude Opus 5.5 a7e434b1ed Carts: a ticked cart takes its loot roll instead of copying it
tick_carts ticks a copy of each cart, and the copy carried the cart's
whole loot roll: a mineshaft chest cart holds it as an NBT compound,
cloned and dropped every tick (a tenth of cart ticking with a level's
mineshafts loaded). Only opening the cart, a command or a save reads
the roll, never a tick, so the copy takes it and the stored cart gets
it back with the rest of the tick's state. The stored cart keeps its
slots meanwhile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:40:27 +01:00
Ludovic MarieandClaude Opus 5.5 b750f4d7e0 HDR frame format: select it in tested functions, fix a misplaced doc comment
draws_wide_frame and frame_format take the 16-bit frame's choice out of
Renderer::new, with tests for every input that keeps the 8-bit frame.
wide_frame had slid between paper_white_nits and its doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:30:02 +01:00
Ludovic Marie 297d91b07e Merge remote-tracking branch 'origin/main' into rv-8684 2026-11-04 03:27:07 +01:00
Ludovic Marie 6d3ed570fb Merge pull request #8923 from horizon-rust/perf/village-sections-villager-slots
🤖 Agent: village_sections reads the snapshot's villager slots, same order and tests (a slot keeps its species all tick); creature span −0.9 to −6.4 ms in 3 paired runs, median MSPT flat; mob_ai, villager_farming, natural_spawn, server_entities pass on the 4-PR stack.
2026-11-04 03:17:09 +01:00
Ludovic Marie 16ca83f6f3 Merge pull request #8930 from horizon-rust/fix/dedicated-sigterm-save
🤖 Agent: queued: the dedicated engine now saves players and levels on SIGTERM/SIGINT/SIGHUP like vanilla's shutdown hook; the new dedicated_crash_save check fails on main and passes here (desktop).
2026-11-04 03:08:58 +01:00
Ludovic Marie 17b23db474 Merge pull request #8912 from horizon-rust/perf/cart-collisions-box-first
🤖 Agent: box test first (same entities, same order) + no entity boxes for a zero move (vanilla collide keeps zero at zero); paired A/B: tick_carts 34.1→3.9 and 51.4→6.3 ms; entity_collision/move, mob_ai, server_entities, dedicated_server pass on the 4-PR stack.
2026-11-04 03:07:19 +01:00
Ludovic Marie 3023ce1dc9 Merge pull request #8886 from horizon-rust/perf/spawn-costs-skip
🤖 Agent: spawn_cost is None for types no biome costs, so the skipped lookup cannot change a result; paired A/B: spawn_natural −2.0 to −2.2 ms per tick at 6,200 mobs; natural_spawn, spawn_rules, finalize_spawn, mob_ai, server_entities pass on the 4-PR stack.
2026-11-04 03:07:14 +01:00
Ludovic Marie 4916b004bf Merge pull request #8885 from horizon-rust/perf/mob-bounds-one-dims
🤖 Agent: same box, one dims() per box; paired A/B on the desktop: tick_carts −1.4 ms at ~5,800 mobs; mob_ai, entity_collision/move/obstruction, server_entities, dedicated_server pass on the 4-PR stack.
2026-11-04 03:07:10 +01:00
Florian ERNSTandCodex 4891301035 Use stack durability limits for tridents
Co-Authored-By: Codex <[email protected]>
2026-11-04 03:01:19 +01:00
Ludovic MarieandClaude Opus 5.5 02d36e14c4 Dedicated server: SIGTERM, SIGINT and SIGHUP save before it stops
The engine run as `horizon-client --dedicated` caught no stop signal: SIGTERM or Ctrl-C ended it at
once, losing every player inventory, ender chest and chunk changed since the last autosave (up to 5
minutes). Vanilla's shutdown hook halts the server and saves on these signals. Catch them for the
dedicated role too and end the tick loop on the first one, so the normal stop saves players and
levels; a second signal still ends a stuck save at once. A signal ignored from the start (nohup's
SIGHUP) stays ignored, as the JVM leaves it.

horizon-server already turned these signals into a console `stop`; this covers hosts that run the
engine directly (panels, systemd, docker without the supervisor).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:48:52 +01:00
eddcd2e4e8 Send the End gateway cooldown to the window so its beam draws.
EndGatewayBlock.entityInside starts that beam through triggerCooldown's block event. The server copy was the only one cooling down, so the window kept drawing nothing.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:25:27 +01:00
81eef66d1e fix: dying dragon on the window keeps its explosion and growl
EnderDragon.aiStep still growls and adds one EXPLOSION each tick after tickDeath has started the rise. The window returned before both.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:46 +01:00
9617eaa350 Play the end portal travel sound when level event 1032 arrives.
The traveling player was sent that event and never heard block.portal.travel.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:38 +01:00
a581a170ac Send a poured water bottle's splash particles to the window.
PotionItem.useOn sends five SPLASH particles with ServerLevel.sendParticles. Adding them on the server Game never reaches the client window.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:34 +01:00
323fde62a9 Merge upstream/main into the water-entry splash.
Main split item ticking into tick_move and tick_settle. The splash now runs on that path, before the move and again after the second fluid push.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:19 +01:00
29e02cb71b fix: a decorated pot's insert wobble and dust reach the window
Inserting a sherd broadcast the sound, but the block event and the dust plume stayed on the server. Vanilla wobbles through Level.blockEvent and sends the dust with ServerLevel.sendParticles.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:02 +01:00
Ludovic Marie 757ce1bd14 Merge pull request #8838 from contactjayclatty/fix/8823-pr-ready-crlf-shebangs
🤖 Agent: test-only change (tests/test_pr_ready.py); verify and launcher green on this head.
2026-11-04 02:22:32 +01:00
8e3c1c68b8 Slimes and magma cubes squash on the client.
AbstractCubeMob.tick updates the squish scale on the client as well as the server. The window only ran the landing splash, so the model stayed rigid.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:22:14 +01:00
9c5c94fb79 Show water-entry splashes for dropped items and mobs.
Entity.doWaterSplashEffect draws bubble and splash particles on the client. Dropped items never called it, and mob particles were only added on the server, so the window never drew them.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:17:07 +01:00
Ludovic MarieandClaude Opus 5.5 14b5c7632e Carts: a cart that does not move gathers no entity boxes
move_cart resolves a zero delta to zero whatever the boxes around it
(try_resolve_step_movement returns it before reading any shape, and the
step-up needs a blocked horizontal move), so an idle cart on its rails,
like every mineshaft's chest cart, no longer scans the level's mobs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:14:14 +01:00
c60ce00f1e Broadcast entity event 70 when a TNT minecart is primed.
The window's cart kept its fuse at -1, so it never showed the smoke, the white flash or the swell.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:10:40 +01:00
a12c22923f A defending fox plays entity.fox.aggro.
Fox.aiStep plays FOX_AGGRO through the server's playSound, so a server-thread player hears it at the fox.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:08:00 +01:00
505b6d2f12 A rabbit hop spawns the block-dust sprint particle
Vanilla Rabbit.handleEntityEvent(1) calls spawnSprintParticle and then
starts the hop clocks. The client played the hop and skipped the crumb.

Fixes #8867.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:07:56 +01:00
b5eee520e9 fix: primed TNT on the window shows its fuse smoke
PrimedTnt.tick adds one SMOKE particle each lit fuse tick on the client. The window ticks that fuse but never spawned the particle, and the server's Level.addParticle does not reach it.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:06:31 +01:00
Ludovic Marie 9abb7dc50f Merge pull request #7769 from martipops/split/fresh-launcher-accounts
🤖 Agent: reviewed the splitscreen account handoff; fixed sign-in-required accounts being handed over, the refresh lock, and leftover session files; desktop launcher + game crates green
2026-11-04 01:59:44 +01:00
Ludovic Marie ee1f66fc6b Merge pull request #8855 from horizon-rust/fix/win-uninstall-aside
🤖 Agent: release blocker for launcher 0.2.38. The Windows uninstall smoke test passes on a branch dry run of the launcher release (41747520933: build windows green, sign green), where 41744019794 failed with os error 17. GitHub launcher and verify are green on this head.
2026-11-04 01:56:14 +01:00
Ludovic Marie c713757300 Merge pull request #8694 from HrafnaValkyrja/fix/ban-ip-names
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client tests (812 game passed, GPU tests skipped). Queued.
2026-11-04 01:50:15 +01:00
Ludovic Marie fdf47d8674 Merge pull request #8676 from HrafnaValkyrja/fix/tnt-minecart-chain
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client tests (812 game passed, GPU tests skipped). Queued.
2026-11-04 01:50:11 +01:00
Ludovic MarieandClaude Opus 5.5 cbb81c5db4 Format and a clippy lint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:34:12 +01:00
Ludovic MarieandClaude Opus 5.5 55dcb11813 Mobs: a zombie looking for a village reads the villagers' slots only
MoveThroughVillageGoal.canUse (and the villagers' own village checks)
gathered the village sections from every slot of the tick's snapshot,
so each zombie at night read every loaded mob. The mob ticks now keep
the slots holding a villager (a slot keeps its mob and species all
tick) and village_sections reads those, in the same order, with the
same alive and POI tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
Ludovic MarieandClaude Opus 5.5 c8f7583036 Natural spawning: no biome lookup for a type no biome gives a spawn cost
NaturalSpawner.createState asks every loaded mob's biome for its type's
spawn cost each tick, and spawning asks again at each tested position.
Only a few nether types have one in any biome, so for the others the
answer is none wherever they stand: the zoomed biome lookup (the
fiddled BiomeManager zoom, then the chunk) is skipped for them. The
types are read from the level's biomes each pass, mods' included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
Ludovic MarieandClaude Opus 5.5 4f35e0d6f8 Carts: test an entity's box before the rest of its collision tests
Each cart, each tick, collected the box of every cart, boat and mob of
the level (with a mob's pushable state) and kept only those within 2
blocks of it, so a server's thousands of mobs cost every cart a full
scan of allocations and pushable tests. The box test now comes first,
in cart_entity_collisions and in pickup_from_cart's candidates: the
same entities pass, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
TiggerTakkyTTTandCodex 6b8bd75de7 Merge resolved view distance branch into simulation distance management
Carry current main, spawn protection, and view distance into the stacked simulation-distance PR while retaining every management route and harness case.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:31:29 +07:00
Ludovic MarieandClaude Opus 5.5 ebe6bd40d2 Launcher: uninstall steps aside on the install folder's drive
The running launcher moved itself into %TEMP% so its folder could go, but a
rename never crosses volumes: with TEMP on another drive (GitHub's Windows
runner, or any player with TEMP elsewhere) it failed with os error 17 and the
uninstall left the launcher behind.

It now goes into %TEMP% only when it is on the same drive, else beside the
install folder as a hidden file, asks Windows to delete it at restart, and the
next setup or uninstall sweeps both places. The folder choice is plain path
text (uninstall_aside.rs), tested on every OS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 9b05e6d523 Splitscreen accounts: never hand over a sign-in-required account, leave no session file behind
- The launcher's other-account handoff skipped the services' check for a token with time left,
  so an account marked sign-in required (a refused refresh, a token for another profile) could
  still be handed to a splitscreen player. Such accounts are now always skipped (Offline).
- Renewing the other accounts takes the account-operation lock, as every renewal does, so a
  rotated refresh token is not lost to another launcher renewing the same account.
- Session files written for a launch are deleted once the game would refuse them (2 min), when
  the launch fails before the game starts, and when the game exits; the sessions folder is
  0700 on Unix. The game consumes every listed file, even for an entry it then ignores.
- The splitscreen server rule (#6399) is a pure function with a test: offline profiles join only
  home-network offline-mode servers; a Microsoft session lifts that while it holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:31:09 +01:00
TiggerTakkyTTTandCodex afc9344898 Merge resolved spawn protection branch into view distance management
Carry current main and the spawn-protection management route into the stacked view-distance PR while preserving both parity harness ranges.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:28:52 +07:00
TheMrZZandCodex gpt-6.1-sol 1ae3ca6b1c Tick Allay duplication cooldowns with NoAI
Share only the positive cooldown decrement with the NoAI path and cover
both AI modes and signed loader controls on real dedicated servers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 01:28:07 +01:00
TiggerTakkyTTTandCodex 4a2713d4e5 Merge upstream/main into spawn protection management
Preserve the spawn-protection management path alongside the operator, status reply, and online-player visibility management changes now on main.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:26:15 +07:00
Ludovic Marie 14cb95e5dd Merge remote-tracking branch 'origin/main' into rv/7769 2026-11-04 01:25:35 +01:00
Ludovic Marie e41334ac01 Merge pull request #8625 from HrafnaValkyrja/feat/ctl-schema
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client-lib tests (lib 1587 + horizon-ctl 24 passed, GPU tests skipped). Queued.
2026-11-04 01:16:49 +01:00
Ludovic Marie 526c1f2ca8 Merge pull request #8582 from HrafnaValkyrja/fix/riding-parity
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client-lib tests 1588 passed (GPU tests skipped). Queued.
2026-11-04 01:11:49 +01:00
HrafnaValkyrja 7220152cb9 Merge remote-tracking branch 'origin/main' into fix/ban-ip-names 2026-11-03 19:07:14 -05:00
Ludovic Marie fae60c542b Merge pull request #8638 from MSedgeMC/fix/renderer-cull-box
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client tests (813 game passed, GPU tests skipped). Queued.
2026-11-04 01:05:44 +01:00
Ludovic Marie e05be64044 Merge pull request #8829 from horizon-rust/fix/8752-block-custom-name
🤖 Agent: setblock/data merge now load CustomName into block_names, data get shows it and clone copies it; the shared load_block_name keeps banners and signs on their own names as before.
2026-11-04 01:04:10 +01:00
HrafnaValkyrja d22519a7b3 Merge remote-tracking branch 'origin/main' into fix/ban-ip-names
# Conflicts:
#	crates/horizon-client/src/game/dedicated.rs
2026-11-03 19:03:31 -05:00
JoansitohandGrok f4196f750c Merge upstream/main into the Space-focus branch.
The overlap-note check timed out and a rerun needs admin
rights, so this push starts it again on current main.

Co-Authored-By: Grok <[email protected]>
2026-11-04 01:03:11 +01:00
JoansitohandGrok bb152dd336 Merge upstream/main into the health-boost clamp branch.
CI compiled this branch against a main that still built the
composite shader test's Resources without its rgb field.

Co-Authored-By: Grok <[email protected]>
2026-11-04 01:02:19 +01:00
Ludovic Marie 3e5a1bd040 Merge pull request #8604 from MSedgeMC/fix/player-own-sound-pitch
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client tests (811 game passed, GPU tests skipped). Queued.
2026-11-04 00:59:35 +01:00
Ludovic MarieandClaude Opus 5.5 3f98dd1ca6 Mobs: a box reads the mob's size once
Mob::bounds_at called width() and height(), and each ran the whole
dims() (the sleeping check, the species' baby, pose and scale rules).
Every box asked for (collisions, pushes, cart and spawn tests) paid it
twice. Same f32 rounding as before, so the same boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:59:07 +01:00
contactjayclattyandOpenCode 9870e9faac test_pr_ready: run the launcher snippet's fakes on Git Bash
On Windows the launcher fallback test fails: the fake uname, xcrun, pkg-config and cargo scripts are written in text mode, so their shebang is #!/bin/sh\r, and Git Bash puts /usr/bin ahead of every Windows PATH entry, so the real uname answers MINGW64_NT-... and the macOS fallback never adds --features gpui_platform/runtime_shaders.

Write the scripts with LF, and on Windows prepend their folder to PATH inside the shell, where Git Bash cannot reorder it.

Fixes #8823

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 07:57:16 +08:00
Ludovic Marie 998b1d8301 Merge pull request #8602 from MSedgeMC/fix/fireball
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client-lib + horizon-client tests (1589 lib + 812 game passed, GPU tests skipped). Queued.
2026-11-04 00:51:15 +01:00
Ludovic Marie ccfc1e91cb Merge pull request #8162 from CocofireHD/fix/rail-on-pushed-block
🤖 Agent: a moved block's cell now supports with vanilla's offset shape (getExtendedProgress: a block back at progress 0, in place at 1), so a rail on a chain-pushed stone still pops as in vanilla while a slime-carried rail survives (#8111, fails on main); dynamic_support 4.87M lines, piston_shape 1.10M and redstone 1000/1000 match on playhorizon.
2026-11-04 00:49:40 +01:00
gorkandClaude Code d179172286 docs: refresh stale shader API documentation
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:38:07 +01:00
gorkandClaude Code 62e5ef76fe docs: refresh README project status numbers
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:36:28 +01:00
Ludovic Marie 161c22dc7c Merge pull request #8773 from horizon-rust/fix/8570-nbt-item-namespace
🤖 Agent: item_from_nbt now reads ids as vanilla's Identifier (minecraft default namespace), so display/command NBT with "stone" no longer maps to wire air; one shared function, so containers and equipment follow too.
2026-11-04 00:35:43 +01:00
gorkandClaude Code b49b1f8f15 docs: refresh hosting command and option reference
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:35:24 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f4d073f8d0 Add Vèneto (vec_it) game translation
Bundles Horizon's own Vèneto strings so vec_it players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:34:13 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3cfd9dac2d Add Vèneto (vec) launcher translation support
Registers Vèneto as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:33:23 +01:00
Ludovic Marie a85b5dceb2 Merge pull request #8527 from horizon-rust/fix/launcher-shortcut-test-long-temp
🤖 Agent: the launcher redesign is signed off: the page-by-page audit against playhorizon.dev/launcher is closed (G1–G28; only G19, registry icons, waits on registry data). Main merged in at bb25d37be5; on the desktop the launcher passes fmt, clippy -D warnings and cargo test (466 passed). The Windows test this fixes only runs in the release build. This releases launcher 0.2.38 without the shop.
2026-11-04 00:33:09 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free baf34b068e Add Ślōnskŏ (szl) game translation
Bundles Horizon's own Ślōnskŏ strings so szl players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:31:25 +01:00
Ludovic MarieandClaude Opus 5.5 51e0c84d01 World: drop the redstone fixed cases (that fixture runs no block events)
Its pistons never move, so the cases compared nothing. dynamic_support pins
a moving_piston's support per progress against vanilla instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:31:04 +01:00
Ludovic Marie 36e36b063d Merge pull request #8296 from CocofireHD/fix/2120-cargo-binstall
🤖 Agent: all five targets build and the four runnable ones start on their runners (x86_64-apple-darwin through Rosetta on the arm64 macos-latest); releases are created with --latest=false so a cargo-horizon-mod tag never takes GitHub's latest from the game; the YAML-grep test is gone, the three left test the manifest-to-release contract.
2026-11-04 00:30:48 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8783d772ed Add Ślōnskŏ (szl) launcher translation support
Registers Ślōnskŏ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:30:05 +01:00
Ludovic Marie 732e0ebeb3 Merge pull request #8559 from MSedgeMC/fix/projectile-light
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client-lib + horizon-client tests (1589 lib + 811 game passed, GPU tests skipped). Queued.
2026-11-04 00:29:44 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79358b516e Add Davvisámegiella (se_no) game translation
Bundles Horizon's own Davvisámegiella strings so se_no players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:28:06 +01:00
TheMrZZandCodex gpt-6.1-sol 7800d0e905 Normalize invalid tropical fish NBT color bytes
Decode full packed color bytes with the DyeColor zero fallback and cover
valid, invalid and unknown-pattern controls on real dedicated servers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 00:27:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8209baacd7 Add Davvisámegiella (se) launcher translation support
Registers Davvisámegiella as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:27:16 +01:00
gorkandClaude Code 7edc16e18d perf: reduce overhead in audio mixing
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:26:08 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 822fc1ae0e Add Саха тыла (sah_sah) game translation
Bundles Horizon's own Саха тыла strings so sah_sah players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:24:42 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 92043991e5 Add Саха тыла (sah) launcher translation support
Registers Саха тыла as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:23:49 +01:00
gorkandClaude Code d874278161 perf: reduce texture atlas memory
Dedupe still textures by a hashed key instead of storing their full
frame bytes, and avoid retaining intermediate paletted atlas frames.

Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:22:27 +01:00
Ludovic Marie bb25d37be5 Merge main into fix/launcher-shortcut-test-long-temp 2026-11-04 00:22:10 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fe72ef9ce5 Add Occitan (oc_fr) game translation
Bundles Horizon's own Occitan strings so oc_fr players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:21:37 +01:00
Ludovic Marie 843e4c6eb4 Merge pull request #8695 from horizon-rust/lr7/instance-switch
🤖 Agent: Desktop proof on 04f8ef7a5c (cargo fmt --check, clippy --all-targets -D warnings, cargo test: lib 466 + bin 83 passed, exit 0). The risk was losing a player's files: a switch changes only instances.json, and the new test checks that the instance folder stays byte for byte the same across Horizon, Fabric, Horizon, Vanilla, with the Java settings coming back.
2026-11-04 00:21:08 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 9623d41ffd Add Occitan (oc) launcher translation support
Registers Occitan as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:20:49 +01:00
Ludovic Marie 1e6c59d46d Merge pull request #8588 from MSedgeMC/feat/advancement-tab
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-protocol + horizon-client-lib tests (lib 1588 passed, GPU tests skipped). Queued.
2026-11-04 00:20:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 460f3c86db Add Lombard (lmo) game translation
Bundles Horizon's own Lombard strings so lmo players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:18:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1cb4e91b76 Add Lombard (lmo) launcher translation support
Registers Lombard as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:17:49 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 87d61a948e Add Limburgs (li_li) game translation
Bundles Horizon's own Limburgs strings so li_li players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:15:34 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2b3854f081 Add Limburgs (li) launcher translation support
Registers Limburgs as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:14:00 +01:00
Ludovic MarieandClaude Opus 5.5 04f8ef7a5c launcher: Edit's switch notice in Greek, Persian and Swedish
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:13:17 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d7fee590de Add Furlan (fur_it) game translation
Bundles Horizon's own Furlan strings so fur_it players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 38bb499205 Merge origin/main into rail-on-pushed-block
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:11:43 +01:00
Ludovic MarieandClaude Opus 5.5 6301858b25 World: a moving block supports with vanilla's offset shape
PistonMovingBlockEntity.getCollisionShape moves the moved block's shape by
getExtendedProgress(progress): progress - 1 while extending, 1 - progress
while retracting. MOVING_PISTON's shape is dynamic, so isFaceSturdy reads it
every time. At moveBlocks (progress 0) the shape is a block back, outside the
cell, so a rail on a pushed block pops even when another block arrives under
it; at progress 1 it fills the cell, so a rail carried by slime finishes on a
slime block that is still moving. sturdy, canSupportRigidBlock,
canSupportCenter and the wire's support now test that shape. The redstone
harness gains both scenarios as fixed cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:11:35 +01:00
gorkandClaude Code 134096b5cf perf: reduce allocations in mob pathfinding
Replace per-node FxHashMap allocation in swim pathfinding with a fixed
six-element array, preserving traversal behaviour.

Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:10:39 +01:00
Ludovic MarieandClaude Opus 5.5 f3edffb6ea Commands: keep a container's CustomName from setblock, data and clone
BaseContainerBlockEntity.loadAdditional reads CustomName from the tag a
command gives (setblock, data merge), saveAdditional writes it back for
data get, and clone copies it. Horizon keeps the name beside the block
entity in block_names, which these paths never read or wrote.

Fixes #8752.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:10:15 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7148c0771a Add Furlan (fur) launcher translation support
Registers Furlan as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:10:12 +01:00
Ludovic Marie f94eb72853 Merge pull request #8646 from MSedgeMC/fix/guest-slot-consent
🤖 Agent: desktop (NixOS, -j 8) on head + main: fmt, clippy -D warnings, horizon-client-lib tests 1589 passed (GPU tests skipped). Queued.
2026-11-04 00:08:58 +01:00
Ludovic MarieandClaude Opus 5.5 63f664e909 Item NBT: read the id as an Identifier, minecraft by default
ItemStack.CODEC reads `id` through Identifier, so `{id:"stone"}` is
minecraft:stone. item_from_nbt kept "stone" as it came, which the
server's registry lookup then sent as air: an item_display summoned
with `{item:{id:"stone"}}` showed nothing (#8570). An id that is no
valid identifier makes no stack, as the codec's error does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:08:22 +01:00
Ludovic MarieandClaude Opus 5.5 a37f193ccb cargo-horizon-mod binstall: drop the test that greps the workflow's text
The other three test the manifest-to-release contract itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:06:56 +01:00
Ludovic Marie 6181cceee5 Merge pull request #8245 from TiggerTakkyTTT/fix/server-admin-stopped-list-api
🤖 Agent: a stop that reaches the game (in game, RCON, cmd) now leaves the supervisor and API up for list edits, but a stop on the supervisor's own stdin still ends it (I added that: Pelican and 'run' wait for the process to exit), and while stopped that stdin takes start/stop; ROUTES bumped to 30 after main's new route, horizon-server tests 133/133.
2026-11-04 00:06:22 +01:00
Ludovic Marie 8f62b8aa88 Merge remote-tracking branch 'origin/main' into lr7/instance-switch 2026-11-04 00:04:25 +01:00
Ludovic Marie a1b62cb73a Merge pull request #8598 from MSedgeMC/fix/piglin-brute-home
🤖 Agent: desktop on main merged in: fmt, clippy -D warnings, horizon-client-lib tests 1590 passed (GPU tests skipped); 80-tick retry added (fff6d306).
2026-11-04 00:02:12 +01:00
Ludovic Marie e839dddb29 Merge pull request #8329 from dontstopgorkingit/fa-launcher
🤖 Agent: Persian launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-04 00:02:08 +01:00
Ludovic Marie 7bc0cb2120 Merge pull request #8680 from horizon-rust/lr7/instance-nightlies
🤖 Agent: Desktop proof on ed007155f1 (cargo fmt --check, clippy --all-targets -D warnings, cargo test: lib 465 + bin 83 passed, exit 0). The risk was the launcher's selection: a Nightly installed for one instance becomes the launcher's version only when it has none, and a second install queues behind the running one instead of being refused.
2026-11-03 23:59:28 +01:00
Ludovic Marie e9cd2b4512 Merge pull request #8505 from MSedgeMC/test/animate-tick-world
🤖 Agent: desktop on main merged in: fmt, clippy -D warnings, horizon-client-lib tests 1587 passed (GPU tests skipped); precision fixes match the 26.3 bytecode.
2026-11-03 23:59:25 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 485c2f56ea HDR: Smooth Gradients' box unticked where the GPU can't, and the visibility buffer in the frame's format
The checkbox read the saved value; it now reads Off there, as its label does. HORIZON_VISBUFFER's resolve
pass draws into the frame, so it takes the frame's format. The float-frame readback test goes until a float
frame does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:58:43 +01:00
Ludovic Marie 1d1a7aba35 Merge pull request #8562 from HrafnaValkyrja/fix/harness-free-ports
🤖 Agent: desktop: Python tests 630/631 (the one error needs a git checkout), port locks hold across processes; Windows fallback added (a099b2e1).
2026-11-03 23:56:16 +01:00
Ludovic Marie 87df3cf7d6 Merge pull request #8327 from dontstopgorkingit/el-launcher
🤖 Agent: Greek launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:55:13 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 33f9fbfda0 HDR: grey Smooth Gradients out where the GPU can't draw the 16-bit frame
Like Translucent Batching: the saved On reads Off there and waits for a device that can.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:52:29 +01:00
Ludovic Marie 5866696152 Merge branch 'lr7/instance-nightlies' into lr7/instance-switch
# Conflicts:
#	launcher/lang/ar.json
#	launcher/lang/bg.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fi.json
#	launcher/lang/fr.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/id.json
#	launcher/lang/ja.json
#	launcher/lang/ko.json
#	launcher/lang/lt.json
#	launcher/lang/no.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sk.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-03 23:47:42 +01:00
Florian ERNST 2e50b8ed7d Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-ravager-counters 2026-11-03 23:47:27 +01:00
Ludovic Marie 20829d6f0e Merge pull request #8325 from dontstopgorkingit/sv-launcher
🤖 Agent: Swedish launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:47:15 +01:00
Ludovic Marie ed007155f1 Merge remote-tracking branch 'origin/main' into lr7/instance-nightlies
# Conflicts:
#	launcher/lang/bg.json
#	launcher/lang/da.json
#	launcher/lang/fi.json
#	launcher/lang/he.json
#	launcher/lang/hu.json
#	launcher/lang/lt.json
#	launcher/lang/no.json
#	launcher/lang/ro.json
#	launcher/lang/sk.json
2026-11-03 23:46:47 +01:00
Ludovic Marie 3bd9e46370 Merge pull request #8160 from TiggerTakkyTTT/fix/server-management-status-replies
🤖 Agent: enable_status moves from the fixed NetConfig into an AtomicBool on lan::Shared that both the legacy 0xFE query and the STATUS handshake read, so turning replies off drops the next ping without a restart (lan_bot covers the closed handshake); ids 210–219 are unique, and main is merged with #8144 and #8136 kept.
2026-11-03 23:44:58 +01:00
Florian ERNST beb1c5e150 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-ravager-counters 2026-11-03 23:42:52 +01:00
Florian ERNSTandCodex 3fed8554f1 Merge latest main projectile updates before validation
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:42:51 +01:00
Ludovic Marie 8312f371fc Merge pull request #8323 from dontstopgorkingit/cs-launcher
🤖 Agent: Czech launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:42:11 +01:00
Ludovic Marie 4cd5c1a413 Merge pull request #8397 from MSedgeMC/perf/chunk-task-release
🤖 Agent: desktop proof on main merged in: fmt, clippy -D warnings, horizon-client-render tests 1004+8 passed (GPU tests skipped). Remote-only queue release, safe as called.
2026-11-03 23:41:02 +01:00
Florian ERNSTandCodex ecbfc14d52 Merge latest main before H179 validation
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:40:59 +01:00
Ludovic Marie 7b0cede6f8 Merge pull request #8125 from masa-masa-0314/fix/player-trident-fire
🤖 Agent: merged main after #8221: its powder-snow sweep and this PR's baseTick fire/water step both follow the trident's move, both kept, sweep first (no shared state or RNG); the arrow environment code now lives in ArrowScene::apply_block_effects for arrows and tridents alike, and I aligned the trident flames with the front-view yaw the other entities use.
2026-11-03 23:40:58 +01:00
Florian ERNSTandCodex 526aadb20a Tick ravager attack stun and roar counters without AI
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:40:42 +01:00
Ludovic MarieandClaude Opus 5.5 7ec20ad606 test: drop the test of a constant
player_voice_pitch returns 1.0; asserting it twice pins nothing that can
regress (CLAUDE.md: skip tests of trivial constants).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:40:19 +01:00
Ludovic MarieandClaude Opus 5.5 a099b2e155 fix: free_port falls back to the plain bind on Windows
fcntl and os.getuid do not exist there, and lab.py runs on win32 too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:39:43 +01:00
Ludovic MarieandClaude Opus 5.5 fff6d3065e fix: the brute's walk home waits 80 ticks between tries
StrollToPoi sets nextOkStartTime 80 ticks after each try; without it a
brute 10-100 blocks from a home it cannot reach ran a path search on
every goal-selector pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:36:50 +01:00
Ludovic Marie 97e26548f3 Merge pull request #8317 from dontstopgorkingit/ur-launcher
🤖 Agent: Urdu launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:36:02 +01:00
Ludovic MarieandClaude Opus 5.5 c430cf43f5 fix: an unknown advancement tab id leaves the kept tab as it is
ServerGamePacketListenerImpl.handleSeenAdvancements calls setSelectedTab
only when ServerAdvancementManager.get finds the id; the hub cleared the
tab for any id that was not a selectable root. Also puts visibility's
doc comment back on visibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:35:58 +01:00
HrafnaValkyrja 7800a1ccd8 Merge remote-tracking branch 'origin/main' into fix/tnt-minecart-chain 2026-11-03 17:35:08 -05:00
Ludovic Marie 8417e54537 Merge pull request #8306 from dontstopgorkingit/th-launcher
🤖 Agent: Thai launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:31:08 +01:00
Ludovic MarieandClaude Opus 5.5 0f5067c85a Merge origin/main into status replies
Keeps #8136's operator level beside status replies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:27:05 +01:00
Ludovic Marie 54f2158db7 Merge pull request #8304 from dontstopgorkingit/vi-launcher
🤖 Agent: Vietnamese launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:26:14 +01:00
Ludovic Marie aa48266663 Merge pull request #8571 from horizon-rust/lr6/focus-ring
🤖 Agent: the focused field gets the mockup's 2 px accent-soft ring as an overlay child, so layout doesn't shift; the invalid red and the command/players fields keep their own borders.
2026-11-03 23:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 d1f9655090 Server API: 30 routes after main's new one
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:23:01 +01:00
Ludovic Marie e08cb064fe Merge pull request #8302 from dontstopgorkingit/nl-launcher
🤖 Agent: Dutch launcher table, stacked on the ones before it (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 passed lib tests incl. every_language_has_every_string).
2026-11-03 23:22:12 +01:00
AxxioandAntigravity 49f43c7569 Sniffer digging sound event 63 and silent particles (fixes #8628)
Sniffer.onDiggingStart broadcasts entity event 63 so clients play
SnifferSoundInstance. Sniffer.emitDiggingParticles emits block dust
regardless of silent state.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 23:20:13 +01:00
Ludovic Marie d3ca256213 Merge pull request #8286 from dontstopgorkingit/it-launcher
🤖 Agent: Italian launcher table, first of a 9-step stack (any merge order is safe); desktop on this exact head: cargo fmt --check, clippy -D warnings, cargo test all green (465 lib tests incl. every_language_has_every_string). The fallback test now uses eo, since it-IT is supported.
2026-11-03 23:17:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3a801dcc57 HDR: Smooth Gradients under the HDR switch, said by what it does
Calibrate's "the three below" again means the brightness settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:16:22 +01:00
Ludovic MarieandClaude Opus 5.5 7d9a642b77 fa: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:22 +01:00
Ludovic MarieandClaude Opus 5.5 3c6eed82f3 Merge main into #8329 (fa)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:22 +01:00
Ludovic MarieandClaude Opus 5.5 790ace00cf el: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:21 +01:00
Ludovic MarieandClaude Opus 5.5 b8a99a0166 sv: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:21 +01:00
Ludovic MarieandClaude Opus 5.5 45a01d7a12 Merge main into #8327 (el)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:21 +01:00
Ludovic MarieandClaude Opus 5.5 b4e145a738 Merge main into #8325 (sv)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:21 +01:00
Ludovic MarieandClaude Opus 5.5 263b9314c2 cs: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:20 +01:00
Ludovic MarieandClaude Opus 5.5 c5848522ca ur: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:20 +01:00
Ludovic MarieandClaude Opus 5.5 0d04275a39 Merge main into #8323 (cs)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:20 +01:00
Ludovic MarieandClaude Opus 5.5 4b35bb5b3a Merge main into #8317 (ur)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:20 +01:00
Ludovic MarieandClaude Opus 5.5 308f74002f th: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:19 +01:00
Ludovic MarieandClaude Opus 5.5 0387cc4d18 vi: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:19 +01:00
Ludovic MarieandClaude Opus 5.5 d8c581c3dc Merge main into #8306 (th)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:19 +01:00
Ludovic MarieandClaude Opus 5.5 6cd38eb809 Merge main into #8304 (vi)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:19 +01:00
Ludovic MarieandClaude Opus 5.5 3773c46e71 nl: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:18 +01:00
Ludovic MarieandClaude Opus 5.5 3515b6963d it: the 14 strings of main's Servers page and log times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:18 +01:00
Ludovic MarieandClaude Opus 5.5 d088a9b856 Merge main into #8302 (nl)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:18 +01:00
Ludovic MarieandClaude Opus 5.5 c113d01fc0 Merge main into #8286 (it)
main's shader API reference is up to date again (367b20268d), which the
previous head's verify run failed on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:05:18 +01:00
Ludovic Marie 2a43173d1a Merge pull request #8549 from horizon-rust/feat/launcher-glass-offline-heads
🤖 Agent: offline faces come from the player's own prepared files (no Mojang asset bundled), read on a worker and keyed by vanilla's offline UUID, so a skin change is picked up by its file time. Proof (GitHub's queue is backed up): on the desktop at 4d20c0efc0, inside launcher/: cargo fmt --check, clippy --all-targets -D warnings, cargo test (465+82 lib+bin) all exit 0.
2026-11-03 23:03:49 +01:00
Ludovic Marie 1960a896ed Merge pull request #8523 from horizon-rust/feat/launcher-glass-played
🤖 Agent: the mockup's times are glass-only helpers; the light look keeps instances::play_time and worlds_page::ago, so its wording is unchanged (only the SMP count gets translated). Proof (GitHub's queue is backed up): on the desktop at 34d9859ff1, inside launcher/: cargo fmt --check, clippy --all-targets -D warnings, cargo test (465+82 lib+bin) all exit 0.
2026-11-03 22:58:04 +01:00
JoansitohandClaude Sonnet 5.5 9dc679d49e Play the bow and crossbow shot sounds at the shooter's feet, as vanilla does (#8448)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 22:50:45 +01:00
Ludovic Marie da6b3c353d Merge pull request #8515 from horizon-rust/feat/launcher-glass-overview
🤖 Agent: Overview's blur makes each screenshot's blurred copy once, off the UI thread and only while blur is on, so with blur off the tiles draw the same images as before. Proof (GitHub's queue is backed up): on the desktop at d2cf230cb6, inside launcher/: cargo fmt --check, clippy --all-targets -D warnings, cargo test (465 lib + 80 bin) all exit 0.
2026-11-03 22:50:07 +01:00
Ludovic Marie 4476320954 Merge pull request #8531 from horizon-rust/feat/launcher-glass-light
🤖 Agent: the light look; the layout follows a glass flag separate from dark, and the sky and tint still follow dark, so dark renders unchanged. Desktop on this head: fmt, build, clippy, 465 launcher tests pass.
2026-11-03 22:46:13 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f437985e02 HDR: a Smooth Gradients switch for the 16-bit frame, on by default
On the HDR page, after Calibrate. Off, the frame stays 8-bit with HDR on, as
before. Like Translucent Batching it applies at the next start or resource
reload, and is no part of a preset. Saved as hdrSmoothGradients; the two new
strings are in en_us only, for the translators.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:45:21 +01:00
martipopsandClaude Opus 5.5 ef242dfe34 Merge upstream main into launcher accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:44:02 -05:00
JoansitohandClaude Sonnet 5.5 f4b991cc51 Nautilus Breath applies ambient in every game mode (#8594)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 22:43:08 +01:00
Ludovic Marie 4d20c0efc0 Merge branch 'feat/launcher-glass-played' into feat/launcher-glass-offline-heads 2026-11-03 22:38:49 +01:00
Ludovic Marie 34d9859ff1 Merge branch 'feat/launcher-glass-overview' into feat/launcher-glass-played
# Conflicts:
#	launcher/lang/bg.json
#	launcher/lang/da.json
#	launcher/lang/fi.json
#	launcher/lang/he.json
#	launcher/lang/hu.json
#	launcher/lang/lt.json
#	launcher/lang/no.json
#	launcher/lang/ro.json
#	launcher/lang/sk.json
2026-11-03 22:38:46 +01:00
Ludovic Marie d2cf230cb6 Merge remote-tracking branch 'origin/main' into feat/launcher-glass-overview
# Conflicts:
#	launcher/lang/bg.json
#	launcher/lang/da.json
#	launcher/lang/fi.json
#	launcher/lang/he.json
#	launcher/lang/hu.json
#	launcher/lang/lt.json
#	launcher/lang/no.json
#	launcher/lang/ro.json
#	launcher/lang/sk.json
2026-11-03 22:38:35 +01:00
Ludovic MarieandClaude Opus 5.5 1e316634b9 Merge main into feat/launcher-glass-light (main's "My Horizon" rows win)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:35:38 +01:00
Ludovic Marie 9e7843c9d5 Merge pull request #8584 from horizon-rust/lr6/server-rows
🤖 Agent: stopped servers show their game mode as in the mockup, read from the server's own server.toml; fmt, clippy and 465 launcher tests pass on this head on the desktop.
2026-11-03 22:33:28 +01:00
Ludovic Marie 0b47e52206 Merge pull request #8563 from horizon-rust/lr6/multiplayer-status
🤖 Agent: Multiplayer rows get the mockup's status dot and player count; also adds the 'My Horizon' string main's 9 new language tables lack, which fixes main's launcher i18n test. fmt, clippy and 464 launcher tests pass on this head on the desktop.
2026-11-03 22:33:24 +01:00
Ludovic MarieandClaude Opus 5.5 6d9e15cfb5 launcher: "My Horizon" in the 9 tables that lacked it
Main's bg, da, fi, he, hu, lt, no, ro and sk tables miss it, which fails every_language_has_every_string_the_code_asks_for. Same lines as #8563, so the two merge cleanly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:31:07 +01:00
Ludovic Marie 17507c50b4 Merge branch 'lr7/instance-nightlies' into lr7/instance-switch 2026-11-03 22:29:34 +01:00
Ludovic Marie 80116a34f8 Merge remote-tracking branch 'origin/main' into lr7/instance-nightlies 2026-11-03 22:29:15 +01:00
Ludovic Marie 0cc7ae127b Merge pull request #8214 from janibert1/whitelist-bypass
🤖 Agent: merged main and gave the temporary-ban test's Lists the new whitelist_bypass field (main added that initializer after this branch); cargo check of horizon-client lib + tests passes on the desktop. Off unless the owner adds whitelist-bypass.txt.
2026-11-03 22:27:09 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 6b306767a7 Name the affected players in /ban-ip feedback
BanIpCommands.banIp sends commands.banip.info with the count and
EntitySelector.joinNames of the affected players; the dedicated console
passed an empty string for the names. Build the line from the same list
used for the disconnects, and still send nothing when no one is affected.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:25:30 -05:00
Ludovic Marie cec81d44a0 Merge pull request #8692 from horizon-rust/fix/hud-animated-root-race
🤖 Agent: tests only: each hud test gets its own temp folder, so a strip one test adds can't leak into another's atlas; 8 repeat runs of the hud tests pass on the desktop.
2026-11-03 22:25:30 +01:00
Ludovic MarieandClaude Opus 5.5 ff024d09a3 hud tests: each animated_root gets its own folder
animated_block_sprites_register_and_pack_their_first_mcmeta_frame and
a_strip_frames_size_comes_from_its_mcmeta shared one per-process folder.
The second adds a two-frame strip of its own, so the first, running beside
it, sometimes found that strip (first frame 0) instead of its own (1).
That broke main's verify on #8531's run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:24:24 +01:00
Ludovic MarieandClaude Opus 5.5 ccff0d4db7 Merge main into lr6/server-rows
The new key sits after "Stopped" in all 25 tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:23:17 +01:00
Ludovic MarieandClaude Opus 5.5 de505bbe3c Merge main into lr6/multiplayer-status
The new key sits after "Offline" in all 25 tables; "My Horizon" added to the 9 tables that lacked it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 d2a173b6e1 test: give the temporary-ban test's Lists the new whitelist_bypass field
Main added a Lists initializer in this test after this branch was cut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:21:46 +01:00
Ludovic MarieandClaude Opus 5.5 fd3e17c2bc Merge main into whitelist-bypass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:21:46 +01:00
Ludovic MarieandClaude Opus 5.5 bfe9c360ed launcher: Edit can switch an instance's kind, Minecraft version and loader
As in the mockup, Edit shows the three kinds (Horizon, Vanilla, Modded)
and, for a Java instance, Mojang's versions and the loaders. Saving a
change calls instances::switch, which changes only the instance's entry
in instances.json: its folder, with its worlds, mods and options, is never
touched, and a Java instance's settings (memory, Java, arguments, window)
are kept aside while it plays Horizon. Before Save, a notice lists what
can't keep working: mods for another game or version, and worlds that a
different Minecraft version upgrades for good. The game's home and
modpacks play what they were made with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:17:35 +01:00
Ludovic MarieandClaude Opus 5.5 424524cce4 Merge main into fix/player-trident-fire
#8221's powder-snow sweep and this branch's baseTick fire/water step both follow the
trident's move in trident.rs: both kept, the sweep first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:16:35 +01:00
Ludovic Marie 8fed23e865 Merge pull request #8361 from senjinthedragon/shader-portals-beams
🤖 Agent: under a shaders/ pack, portal faces and beams no longer fall back to Horizon's entity shader at all, so a pack with no gbuffers_block (or no beaconbeam/textured/basic) shows no portal or beam; the portal texture is copied once per atlas rect change, not per frame.
2026-11-03 22:16:17 +01:00
Ludovic Marie 476e5cd79f Merge pull request #8237 from itarqos5/perf/startup-core-overlap
🤖 Agent: merged main, whose registry-v1.bin snapshot already takes facts.json off most starts, so the slice parse now only helps a snapshot miss; the model-library parse beside Assets::load is the part that still moves time off the critical path (skipped for a server).
2026-11-03 22:16:13 +01:00
Ludovic Marie 1978658b83 Merge pull request #8121 from janibert1/chat-hook
🤖 Agent: cancel drops the message for everybody before the broadcast, after secure chat and the filter, so a muted player's signed chain is never relayed; I added the test-stub field and registered chat.rs's free hook point in catalog_matches_hook_points.
2026-11-03 22:16:08 +01:00
Ludovic Marie 45d7ee31e3 Merge pull request #8221 from byMehd/fix/8217-powder-snow-entity-particles
🤖 Agent: merged main after the STOP: main now ticks orbs and primed TNT in join order (ordered_tick.rs, tick_one_tnt), so their snowflake calls moved there, still right after each move as PowderSnowBlock.entityInside runs inside it; verify green on this head.
2026-11-03 22:16:04 +01:00
AkirabaneandClaude Opus 5.5 479918f6a3 A guest keeps and saves the game mode they left last
The owner already had previousPlayerGameType; a guest had no such field,
and the save removed the key, so the game mode switcher of a guest who
rejoined had nothing to go back to. A guest now keeps it as
ServerPlayerGameMode.changeGameModeForPlayer does (the mode left becomes
the previous one; the mode in use changes nothing), and its file writes it
when there is one and reads it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 10d1e900b9 A guest's save keeps the time spent frozen
PlayerMotion.ticks_frozen never reached the file: a guest who left inside
powder snow came back thawed, and one who thawed kept whatever TicksFrozen
the file it joined from had. The saved body now carries it, written only
above 0 as Entity.saveWithoutId does (the key is removed otherwise), and read
back with Entity.load's default of 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 2f80af1466 A guest's save keeps the food exhaustion and the food timer
store set the food level and saturation but left foodExhaustionLevel and
foodTickTimer at 0, so a guest who left mid-sprint came back with no
exhaustion and the regeneration timer restarted. The saved body now carries
both from the live FoodData (FoodData.addAdditionalSaveData) and puts them
back on the next body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 54acf9e4be A guest's save keeps the player's motion
store never set Motion, so a guest's file always said the player stood
still. The saved body now carries the live delta movement, written as
Motion (Entity.saveWithoutId) and read back for the next body with
Entity.load's rule: a component over 10 blocks a tick reads as 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
Ludovic MarieandClaude Opus 5.5 05ab05c807 Merge main into feat/launcher-glass-light (#8681's reference fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:08:20 +01:00
Ludovic Marie 29c8931f45 Merge pull request #8681 from horizon-rust/fix/shaders-reference-doc
🤖 Agent: unblocks main's verify: #8186 hand-edited the generated shaders-reference.md; the Anti-aliasing section now comes from the generator, word for word, so the doc is unchanged and the_reference_is_up_to_date passes.
2026-11-03 22:05:25 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2ff9047fff HDR: draw the frame in 16 bits when HDR is on, so dark gradients stop banding
With HDR output on at the start, the frame every pass draws into is
Rgba16Unorm instead of the swapchain's 8-bit format. It clips at white after
every pass as the 8-bit frame does, so the picture stays SDR's, but keeps
the steps 8 bits band in dark gradients, which HDR shows. Where the device
can't draw, blend and filter it, and with HDR switched on later, the 8-bit
frame as before. SDR is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:05:05 +01:00
Ludovic MarieandClaude Opus 5.5 367b20268d shader API reference: the anti-aliasing section comes from the generator
#8186 wrote its Anti-aliasing section into docs/api/shaders-reference.md,
which horizon_shader_api::reference::markdown generates, so main's
the_reference_is_up_to_date failed. The section is now generated, and the
doc is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:03:49 +01:00
Ludovic MarieandClaude Opus 5.5 08f504c6d6 launcher: the instance dialog lists every Nightly and installs the picked one
The Horizon version list showed only installed versions. Like the mockup,
it now lists every Nightly published for this platform (the launcher's
release list, with its GitHub cache), then the installed ones the list
lacks. Create or Save makes the instance at once and installs a picked
version that isn't installed through the existing install path (signed
checksums, progress), after the install running now if there is one;
Play shows Installing meanwhile. A version installed for an instance
becomes the launcher's only when it has none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:02:28 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 1950f986e8 Fix TNT minecarts surviving weak blasts and fire
VehicleEntity.hurtServer destroys a cart when its damage passes 40 or
MinecartTNT.shouldSourceDestroy is true (fire, explosions). Horizon only
checked the damage, so a TNT cart hit by a weak blast was pushed but never
primed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 15:55:21 -05:00
Ludovic Marie 298f165557 Merge pull request #8136 from TiggerTakkyTTT/fix/server-management-operator-level
🤖 Agent: the set path updates both state.op_level and the lists' op_level, so ops added after a change get the new default level as vanilla's settings.update does; harness ids moved to 220–229 (150s clashed with #8108), main merged with #8144's hide-online-players kept.
2026-11-03 21:54:13 +01:00
Ludovic MarieandClaude Opus 5.5 ee014866e7 Merge main into feat/launcher-glass-light (#8674's render test fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:53:20 +01:00
Ludovic MarieandClaude Opus 5.5 126e6f6e44 Merge origin/main into stopped-list-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:53:03 +01:00
Ludovic Marie 175c9b99f4 Merge pull request #8674 from horizon-rust/fix/render-tests-visbuffer-spans
🤖 Agent: unblocks main's verify: the inter-stage audit now covers visbuffer.wgsl from #7054 (still within 15 variables), and the two spans tests share one lock instead of racing on the process-wide switch. Tests only; render crate's 1295 tests + clippy pass on the desktop.
2026-11-03 21:50:46 +01:00
Ludovic MarieandClaude Opus 5.5 f722c0c72c render tests: audit visbuffer.wgsl's assembly, keep the spans switch to one test at a time
- inter_stage_tests: visbuffer.wgsl (#7054) only compiles after terrain.wgsl,
  so the audit failed on main. It now checks the module the renderer builds
  (terrain.wgsl + visbuffer.wgsl), which stays within the 15 inter-stage
  variables.
- spans: laps_time_consecutive_steps turns spans on for every thread, so
  notes_keep_the_slowest_steps, running beside it, recorded the note it
  expects left out. Both take one lock now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:48:40 +01:00
Kacper 50fe88ed1d Merge pull request #8592 from mattiaemolo/fix/spectator-container-openers
Containers no longer open in spectator mode
2026-11-03 21:43:18 +01:00
Kacper da19873333 Merge pull request #8420 from HrafnaValkyrja/fix/entity-save-matches-vanilla
Entity save data: Fire default, kept tags and the movement speed attribute as vanilla writes them
2026-11-03 21:41:45 +01:00
Kacper cc77dc5261 Merge pull request #8494 from MSedgeMC/fix/mob-effect-refusal
An effect a mob refuses is never added
2026-11-03 21:40:04 +01:00
Kacper 2a18eb5ecd Merge pull request #8576 from HrafnaValkyrja/fix/leads-parity
Only mobs and boats keep a leash tag
2026-11-03 21:39:54 +01:00
Ludovic Marie 3cfb8846e2 Merge pull request #8641 from kacpherek/far/no-floating-cells-pr
🤖 Agent: far cells now keep any column's blocks (trunks stay under crowns) instead of the majority rule; the cost is that a thin feature in a wide cell draws cell-wide, which the before/after shows reads better than floating slabs. Same stored format, so caches aren't rebuilt.
2026-11-03 21:33:07 +01:00
Ludovic MarieandClaude Opus 5.5 875ed64de4 Merge main into feat/launcher-glass-light (main's pack_post test fix, #8664)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:31:49 +01:00
Ludovic Marie c332740d31 Merge pull request #8088 from Joansitoh/fix/8051-reduced-debug-info
🤖 Agent: matches vanilla's REDUCED_DEBUG_INFO callback (entity event 22/23 to each player on change, Login.reducedDebugInfo at join). Main merged in twice: limited_crafting landed in the same hub/lan/remote spots, both kept. Verify passed on the previous head with the same diff; fmt and clippy passed on this one. One gap left: a guest whose Login was built but who isn't placed yet misses a flip.
2026-11-03 21:30:31 +01:00
Ludovic Marie 1564656e02 Merge pull request #8548 from horizon-rust/fix/ramlimit-windows-no-paging
🤖 Agent: queued, verify and launcher green on 3f941752. CI never builds Windows, so the Windows-only code was checked separately: clippy -D warnings for x86_64-pc-windows-gnu is clean on horizon-threads (all targets) and horizon-client (with and without default features), with the C build scripts (mimalloc, ring) stubbed.
2026-11-03 21:28:52 +01:00
Ludovic Marie a026fd085d Merge pull request #7054 from elythepreciouscat/perf/async-occlusion-culling
🤖 Agent: re-merged main after the merger's conflict stop (main's new shift-click cycling test kept beside the particle culling one); Off, the default, still calls the plain render path, so players who don't opt in pay nothing per frame.
2026-11-03 21:27:12 +01:00
Ludovic Marie 6e02bdb4b3 Merge pull request #8664 from horizon-rust/fix/pack-post-rgb
🤖 Agent: unbreaks main's horizon-client-render test build (#8131 added Resources::rgb, #8065's new test initializer lacked it); clippy --all-targets -D warnings passes on the desktop.
2026-11-03 21:27:08 +01:00
Ludovic MarieandClaude Opus 5.5 b877faf1d7 launcher: "My Horizon" in the bg, da, fi, he, hu, lt, no, ro and sk tables
main's i18n test failed: the new instance's default name reached main
after those nine tables were filled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:26:52 +01:00
Ludovic MarieandClaude Opus 5.5 467deab05f Merge main into feat/launcher-glass-light
The palette's card now uses main's ui::dialog_plate (#8514). The light
look's lighter scrim moves into the shared helpers (ui::scrim and
dialog_plate), so the Edit dialog, the server dialog, the owner notice
and the palette all dim a light launcher the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:26:52 +01:00
Kacper dbd56824f4 Merge pull request #8481 from dontstopgorkingit/docs/user-server
docs: improve server/user documentation links and structure
2026-11-03 21:25:12 +01:00
Kacper b88fafd2d7 Merge pull request #8207 from Akirabane/feat/8206-config-set-live-motd-max-players
Server: `config set server.motd` and `server.max_players` apply live
2026-11-03 21:24:25 +01:00
Kacper 93a326e142 Merge pull request #8546 from MSedgeMC/fix/squid-movement-sounds
A squid and a bat step without a sound
2026-11-03 21:22:45 +01:00
Kacper 7c8ccece9d Merge pull request #8561 from MSedgeMC/fix/baby-parrot-voice
A baby parrot keeps the plain voice pitch
2026-11-03 21:22:30 +01:00
Ludovic MarieandClaude Opus 5.5 4a9d204be7 pack_post: give the composite test's Resources its rgb field
#8131 added Resources::rgb and #8065 added a test initializer without it;
both merged, so main's horizon-client-render tests no longer compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:22:15 +01:00
Ludovic Marie 770ce88060 Merge branch 'feat/launcher-glass-played' into feat/launcher-glass-offline-heads 2026-11-03 21:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 f4b4f4d10a launcher: the played line's times in main's nine new languages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:22:00 +01:00
Ludovic Marie 851086d248 Merge branch 'feat/launcher-glass-overview' into feat/launcher-glass-played 2026-11-03 21:21:50 +01:00
Ludovic MarieandClaude Opus 5.5 4acedeaad1 launcher: "My Horizon" in the bg, da, fi, he, hu, lt, no, ro and sk tables
#8509's default name crossed with main's nine new languages, so main's
every_language_has_every_string_the_code_asks_for lacks it there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:21:39 +01:00
Ludovic Marie 64ec25e9c9 Merge remote-tracking branch 'origin/main' into feat/launcher-glass-overview
# Conflicts:
#	launcher/lang/ar.json
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/id.json
#	launcher/lang/ja.json
#	launcher/lang/ko.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-03 21:20:43 +01:00
Kacper 9aa4be30f8 Merge pull request #8197 from Akirabane/fix/8195-breaking-cracks-varyings
Renderer: keep every pipeline within 15 inter-stage variables, and test it
2026-11-03 21:16:55 +01:00
Kacper acea78948d Merge pull request #8065 from arslanarm/shaderpacks/pack-fog-uniforms
Shader packs: fog and per-draw uniforms as Iris gives them per program kind
2026-11-03 21:16:09 +01:00
Kacper 0b98fd3ec6 Merge pull request #8044 from arslanarm/shaderpacks/composite-blend
Shader packs: blend.<program> blends composite-style programs
2026-11-03 21:16:00 +01:00
Ludovic Marie dd66b5105b Merge pull request #8499 from horizon-rust/feat/launcher-glass-memory
🤖 Agent: Maximum memory in the Edit dialog; it edits the instance being edited, not the selected one, which was the risk here. Head includes lr6-memory's #8509 (instance dialog defaults), whose fmt/clippy/tests passed.
2026-11-03 21:14:48 +01:00
Ludovic Marie 88d201d7d0 Merge pull request #8577 from horizon-rust/ci/self-hosted-max
🤖 Agent: reviewed the .github changes: bots on pull_request_target/issue_comment check out ref: main only and take comment/branch text through env; checks jobs on the desktop hold no secrets or write tokens (tests/test_ci_runners.py pins it). Launcher failure was main's, fixed by #8640; merged main in.
2026-11-03 21:12:35 +01:00
Ludovic Marie afd319aeee Merge pull request #8328 from dontstopgorkingit/el-game
🤖 Agent: el_gr game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:12:31 +01:00
Ludovic Marie 566614fd72 Merge pull request #8326 from dontstopgorkingit/sv-game
🤖 Agent: sv_se game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:12:27 +01:00
Ludovic MarieandClaude Opus 5.5 dcde458f09 Merge main into ci/self-hosted-max (picks up #8640's launcher fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:10:46 +01:00
Ludovic Marie 466402216a Merge pull request #8324 from dontstopgorkingit/cs-game
🤖 Agent: cs_cz game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:10:18 +01:00
Ludovic Marie abea3bd4b3 Merge pull request #8318 from dontstopgorkingit/pt_br-game
🤖 Agent: pt_br game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:10:14 +01:00
Ludovic Marie 0981da3950 Merge pull request #8315 from dontstopgorkingit/ar_sa-game
🤖 Agent: ar_sa game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:10:09 +01:00
Ludovic Marie e919ca8862 Merge pull request #8314 from dontstopgorkingit/ja_jp-game
🤖 Agent: ja_jp game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:10:05 +01:00
Ludovic Marie 804d44b2b9 Merge pull request #8307 from dontstopgorkingit/th-game
🤖 Agent: th_th game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:10:01 +01:00
Ludovic Marie f28e56abdf Merge pull request #8305 from dontstopgorkingit/vi-game
🤖 Agent: vi_vn game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:09:57 +01:00
Ludovic Marie cf04e87525 Merge pull request #8303 from dontstopgorkingit/nl-game
🤖 Agent: nl_nl game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:09:52 +01:00
Ludovic Marie 7f4b71542b Merge pull request #8287 from dontstopgorkingit/it-game
🤖 Agent: it_it game table, stacked with the other nine (any merge order is safe); "Horizon" kept as the name where the table had the word horizon, English copies dropped and the six controller options named as options, as #8385 did.
2026-11-03 21:09:47 +01:00
Ludovic MarieandClaude Opus 5.5 160f206c16 fa: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 65bb30cbe7 el: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 8ddf1e96ed Merge main into #8329 (fa)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 4cdce63a27 Merge main into #8327 (el)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 c3fde80ea9 sv: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:58 +01:00
Ludovic MarieandClaude Opus 5.5 6805e4b57a cs: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:58 +01:00
Ludovic MarieandClaude Opus 5.5 5b912b5178 ur: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:58 +01:00
Ludovic MarieandClaude Opus 5.5 c97fc9bfba Merge main into #8325 (sv)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:58 +01:00
Ludovic MarieandClaude Opus 5.5 6a23f7bf4f Merge main into #8323 (cs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:58 +01:00
Ludovic MarieandClaude Opus 5.5 d1e43f4554 th: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:57 +01:00
Ludovic MarieandClaude Opus 5.5 7cfcbde368 vi: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:57 +01:00
Ludovic MarieandClaude Opus 5.5 e4aca3a739 Merge main into #8317 (ur)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:57 +01:00
Ludovic MarieandClaude Opus 5.5 42fe8f1aff Merge main into #8306 (th)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:57 +01:00
Ludovic MarieandClaude Opus 5.5 cb3fac7b8f Merge main into #8304 (vi)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:57 +01:00
Ludovic MarieandClaude Opus 5.5 bc523faa36 nl: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:56 +01:00
Ludovic MarieandClaude Opus 5.5 f20336ac00 it: the 30 strings of main's Mods page, palette and toasts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:56 +01:00
Ludovic MarieandClaude Opus 5.5 9932cfea44 Merge main into #8302 (nl)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:56 +01:00
Ludovic MarieandClaude Opus 5.5 e42fb452ef Merge main into #8286 (it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:07:56 +01:00
Kacper f24a23f68d Merge pull request #8029 from horizon-rust/perf/slow-tick-culprit
Dedicated server: a slow tick's line names whose packets or event took it
2026-11-03 21:06:44 +01:00
Kacper 06834efd1f Merge pull request #8516 from horizon-rust/lr6/sign-in-glass
launcher: the sign-in gate in the glass look
2026-11-03 21:06:15 +01:00
Kacper d1bccfe524 Merge pull request #8514 from horizon-rust/lr6/dialog-backdrop
launcher: dialogs over the dimmed, blurred launcher, as the mockup's
2026-11-03 21:06:08 +01:00
Kacper 622c75eefb Merge pull request #8181 from Akirabane/fix/8174-flat-qualifier
Shader packs: a flat varying with centroid or sample is flat, and tests for the interpolation qualifiers
2026-11-03 21:05:00 +01:00
Kacper e48c04fafb Merge pull request #8043 from arismoko/v2/ses_01M6FN1T2FS6T4M8P7GFXJ6WTR-tp-pitch-clamp
Wrap teleport rotation like vanilla /tp
2026-11-03 21:04:01 +01:00
Kacper 7aa3911897 Merge pull request #8404 from MSedgeMC/fix/shulker-boarding-face
A shulker that boards a vehicle sits lid up
2026-11-03 21:03:53 +01:00
Kacper 568334b23a Merge pull request #8411 from MSedgeMC/fix/silent-mob-voice
A silent mob keeps its voice quiet
2026-11-03 21:03:45 +01:00
Kacper 94c76ed2ca Merge pull request #8489 from Akirabane/fix/vanilla-keys-mute-tempban
Server: /mute, /tempmute and /tempban answer with vanilla's messages where vanilla has them
2026-11-03 21:02:58 +01:00
Kacper f6a16f5584 Merge pull request #8226 from Akirabane/fix/8222-fishy-advance
Fonts: draw a glyph whose advance is fishy, as vanilla does
2026-11-03 21:02:49 +01:00
Kacper 8781b60ee0 Merge pull request #8196 from Akirabane/fix/8177-1d-images
Shader packs: 1D images and their samplers (image.* with one size)
2026-11-03 21:02:41 +01:00
Kacper 3940341834 Merge pull request #7969 from gigamans442/fix/glsl-array-declarators-20261102
Shader packs: keep comma-separated GLSL declarator types independent
2026-11-03 21:02:33 +01:00
JoansitohandClaude Sonnet 5.5 299b9a9ba4 Keep boosted hearts across remove/re-add modifier batches (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
JoansitohandClaude Sonnet 5.5 a3f4d04bfa Clamp health in set_modifier for every max_health change (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
JoansitohandClaude Sonnet 5.5 c0407fa8b7 Clamp health to max_health when effect modifiers change (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
Ludovic Marie c763fa21b0 Merge pull request #8076 from glitchsnake/fix/8054-dragons-breath-bottle
🤖 Agent: queued, verify and launcher green on 0657af35. Two fixes pushed: the client's glass bottle PASSes on a breath cloud again (the cloud's owner isn't synced, so BottleItem.use finds none client-side; the packet still goes), and the bottle now treats the cloud as gone exactly when vanilla does (age - 20 >= duration, tested at the last tick), after main's #8244 added Breath.victims.
2026-11-03 21:00:17 +01:00
Kacper da6d099fdc Merge pull request #8476 from razzo715/fix/end-exit-world-spawn
Leave the End near the world spawn when the player has no point of its own
2026-11-03 21:00:02 +01:00
Kacper af4f345dc6 Merge pull request #8407 from razzo715/fix/death-drop-offhand-first
A player's death drops the off hand before the armour
2026-11-03 20:59:51 +01:00
Kacper 698e368da9 Merge pull request #8457 from itarqos5/fix/double-name-tag-on-custom-named-players
fix(names): one name tag for a player a server has given a custom name
2026-11-03 20:57:45 +01:00
Kacper 5968865a17 Merge pull request #8199 from Akirabane/fix/parity-source-hashes-junction
Parity harnesses: name sources under a linked .local within the repository
2026-11-03 20:57:31 +01:00
Kacper 07961b4a5a Merge pull request #8179 from Akirabane/fix/8164-zombie-shield-case
server_knockback: put the half-shield case's zombie where Bob's shield always faces it
2026-11-03 20:56:47 +01:00
Kacper 65a0447f04 Merge pull request #8403 from k3yh/fix/warn-undecodable-pool
Warn when a template pool can't be read
2026-11-03 20:56:38 +01:00
Kacper 814d18d513 Merge pull request #8186 from Akirabane/docs/8173-aa-under-a-pack
Docs: a shader pack's post chain owns anti-aliasing
2026-11-03 20:55:39 +01:00
Kacper fdff9f3f03 Merge pull request #8470 from dontstopgorkingit/docs/improve-docs
docs: remove stale known issue about removed shader packs
2026-11-03 20:55:24 +01:00
Kacper 8d63b0b7ee Merge pull request #8480 from dontstopgorkingit/docs/worldgen
docs: improve world-generation documentation navigation
2026-11-03 20:55:11 +01:00
Kacper 47ab8fd5ae Merge pull request #8485 from dontstopgorkingit/docs/parity-perf
docs: polish parity, port-ledger, and benchmark docs
2026-11-03 20:55:03 +01:00
Kacper 7d3b25b68f Merge pull request #8482 from dontstopgorkingit/docs/mods-shaders
docs: refresh mods, modpacks and shaders docs
2026-11-03 20:54:52 +01:00
MSedgeMC ddbda5917f port_map: Slot.mayPickup is the guest menu's gate for swap and throw too
The row named the pickup and quick-move paths; the number key, the off
hand's F and the drop click now go through the same `View::may_pickup`.
2026-11-03 19:51:50 +00:00
MSedgeMC 2666bd9170 test: the guest's swap and throw over a slot that refuses
A chest slot whose rules forbid the pickup, and binding-cursed armour in
the guest's own chest slot (survival against creative), reached through
`menu::click` - the path a `ServerboundContainerClickPacket` takes. Both
fail without the fix: the number key moved the stack into the hotbar and
the drop click threw it out.
2026-11-03 19:51:50 +00:00
MSedgeMC a2233c609e fix: a guest's number-key swap and drop click ask the slot first
`doClick`'s SWAP arm reads the slot's stack itself and puts it in the
hotbar (or, through `Inventory.getItem(40)`, the off hand), and its THROW
arm takes the stack through `Slot.safeTake`. Both therefore go through
`Slot.mayPickup`: a slot that withholds its stack - `ArmorSlot.mayPickup`
with a binding curse in survival, or a menu whose rules refuse - neither
hands it to a number key nor lets a drop click throw it out.

`lan::menu::swap` only checked `Slot.mayPlace` on each side, and the THROW
arm read the slot directly. The host applies both of those, so this was
the host's own authority rather than a client prediction.
2026-11-03 19:51:50 +00:00
Ludovic Marie 4274948b50 Merge pull request #8640 from horizon-rust/fix/launcher-main-palette
🤖 Agent: fixes main's launcher build (palette now passes the wardrobe flag like the top bar) and the 30 strings 9 languages lacked; fmt, clippy -D warnings and 464+ launcher tests pass on the desktop.
2026-11-03 20:48:04 +01:00
Ludovic MarieandClaude Opus 5.5 9ff63e6e9f launcher: fix main's build after the palette and Wardrobe merges crossed
glass_palette.rs called Page::in_top_bar() without the wardrobe flag #8569
added, so the palette now passes self.wardrobe_shown() like the top bar.
The bg, da, fi, he, hu, lt, no, ro and sk tables lacked the 30 strings the
mods page, palette and toasts added while those languages were landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:42:34 +01:00
MSedgeMC 28497f72ae port_map: the culling-box rows say what each renderer grows
Two of them were filed as skips ("culling box only") and the illusioner's and
sniffer's were absent, so the ledger claimed Horizon needed nothing here.
2026-11-03 19:39:42 +00:00
MSedgeMC 132179414d test: pin the four culling-box growths and the box they build
An illusioner's box reaches 3.3 blocks either side of its position and a
happy ghast's drops to 2 below its feet, where the plain box reached 0.3 and
its own floor.
2026-11-03 19:39:42 +00:00
MSedgeMC 63c35fd546 fix: the culling box follows the renderer, four living kinds grow it
EntityRenderer.shouldRender culls with getBoundingBoxForCulling and its own
half-block inflate. Four renderers override it and Horizon culled every mob
with one plain box: IllusionerRenderer inflate(3, 0, 3) for the mirror images
it draws outside its own box, SnifferRenderer inflate(0.6),
HappyGhastRenderer setMinY(minY - getBbHeight() / 2) for the riders hanging
below it, and LivingEntityRenderer inflate(0.5) while a dragon head is worn.
The box is also taken at the frame's partial tick now, as
getInterpolatedBoundingBox does.
2026-11-03 19:39:42 +00:00
Ludovic MarieandClaude Opus 5.5 6e2d33a267 CI: verify on our runners sees no GPU, as on GitHub's
The desktop's checks runners have Mesa's software Vulkan (for parity), so verify ran GPU tests there that return
early on GitHub's runners, and one of them flakes on lavapipe (#8627): two of three runs on ludodesktop-checks-2
failed. WGPU_BACKEND=dx12 (no such backend on Linux) gives the tests no adapter there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:32:38 +01:00
JoansitohandClaude Sonnet 5.5 80aece6be0 Fix Space re-triggering a focused button after clicking a text field (#8521)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 20:32:30 +01:00
Ludovic Marie 9697aaf14d Merge pull request #8347 from Teiwazik/shader/displacement-shadow
🤖 Agent: queued, verify and launcher green on 47c26bc0 (own change is the last commit; #8203/#8258 already merged). One gap: the new test builds the grid shadow shader only for a pack without a vertex stage, so the DISPLACE_PACK branch (the material module's `vertex` called from the shadow grid) is checked only by reading compose, not by a naga validation.
2026-11-03 20:29:40 +01:00
Ludovic MarieandClaude Opus 5.5 b8a348f56d CI: playhorizon's job hook keeps a checkout's .git under 2 GB and a day of runner logs
12 light runners' checkouts grew 2.8 GB of packs and 1.3 GB of diagnostic logs each in an hour: 46 GB of the disk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:28:44 +01:00
Kacpherek a7274ef833 Keep far terrain joined to the ground at every cell width
Far cells hung in the air: crowns over air, slabs of snow, sheets that
the fog turned pale. Three sources, all now joined to the ground:

- Reduced columns drew a height only where most of the footprint held a
  block, so trunks (one column of 16 or 256) vanished under the crowns
  they hold up. A height is now drawn where any column holds a block,
  in the material most of them hold: what touches the ground in the
  chunk touches it at every width.
- Seed-estimated trees in wide cells drew a crown with no trunk, one
  block or more over the ground. A cell's own tree now stands on a
  trunk and a crown from a neighbouring trunk reaches the ground.
- Wide seed cells are one point of the terrain: an overhang's lip or a
  cave roof stood for the whole cell, and the aquifer-less sample put
  water in caves under sea level. Under the top solid span such a
  column is now ground.

On "New World (1)", 47 tiles a level around the player: floating
components per level 1..6 went from 24, 326, 325, 43, 150, 213 to
0, 0, 1, 0, 0, 0 (the one left floats in the saved chunk itself).
2026-11-03 20:28:39 +01:00
Kacpherek fd7962f451 Count floating far-terrain cells per level on a real save
An ignored census test samples the tiles each far level draws around a
camera, from saved chunks and the seed as the far sampler gives them,
and counts components of drawn cells that touch neither the world's
bottom nor the tile's halo.
2026-11-03 20:28:39 +01:00
Ludovic Marie d84eb46214 Merge pull request #8509 from horizon-rust/feat/launcher-glass-instance-defaults
🤖 Agent: a new instance now opens named "My Horizon" (the mockup's defaultName) and numbers a taken default name, so Create no longer fails with "An instance needs a name."; stacked on #8499, land after it.
2026-11-03 20:26:08 +01:00
Ludovic MarieandClaude Opus 5.5 c961f3c5cb Merge main into fix/8217-powder-snow-entity-particles
main ticks orbs and primed TNT in join order (ordered_tick.rs, tick_one_tnt): the
powder-snow snowflake calls move there, after each one's move as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:22:55 +01:00
Ludovic Marie 801c0e3947 Merge pull request #8569 from horizon-rust/feat/launcher-shop-gate
🤖 Agent: with the cosmetics switch off by default, the Wardrobe entry used to hide even when the service opened the claim window; it now follows GET /v1/shop (and the account's kept preview answer), and with no window set it stays hidden as before. Also fills main's 9 missing strings in 9 languages, which made the launcher's i18n test fail on main.
2026-11-03 20:17:06 +01:00
Ludovic Marie d913536418 Merge pull request #8487 from horizon-rust/feat/launcher-glass-toasts
🤖 Agent: toasts as the mockup's say; a single line that any handler can set, drawn by the 80 ms drain, so there is no new per-frame work.
2026-11-03 20:12:52 +01:00
Ludovic MarieandClaude Opus 5.5 0ecf7783f6 CI: the desktop takes the pull requests ending in 0 or 1; main's pushes stay on GitHub
Three runners give ~180 runner-minutes an hour against ~100 checks runs an hour (21 of them main's pushes, which also
save the Rust cache GitHub's pull request runs read): a fifth of the pull requests fills them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:07:55 +01:00
Ludovic Marie 8dbbde4350 Merge pull request #8359 from kacpherek/perf/render-bundle
🤖 Agent: queued whole, verify and launcher green on 94cc6490. One gap left for later: nothing checks that the WGSL packed_corner decodes a record to the same vertex as Rust's packed_vertex; the new mesher/packed.rs tests only cover the Rust side.
2026-11-03 20:03:31 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 933a5ef900 Add Føroyskt (fo_fo) game translation
Bundles Horizon's own Føroyskt strings so fo_fo players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:03:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7f02471f16 Add Føroyskt (fo) launcher translation support
Registers Føroyskt as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:02:18 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 9489b8ea55 Sync launcher tables with the newest strings
These tables were generated before the latest strings landed, so the
launcher completeness test fails on main and on every PR. Add the
missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:02:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 39410d1e7e Add Чӑвашла (cv_cu) game translation
Bundles Horizon's own Чӑвашла strings so cv_cu players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:58:42 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0985acf94d Add Чӑвашла (cv) launcher translation support
Registers Чӑвашла as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:57:51 +01:00
Ludovic Marie 49c3220312 Merge pull request #8466 from horizon-rust/feat/launcher-glass-palette
🤖 Agent: the ⌘K palette; Play only runs when the instance's Play button would, otherwise it opens the instance's page, so it never starts an unprepared game.
2026-11-03 19:57:38 +01:00
Ludovic Marie 5b492e718d Merge pull request #8215 from janibert1/player-entity-id
🤖 Agent: entity_id now comes from native_acting_entity (falls back to the guest list when lan_acting_entity is unset, as the mount hooks do), and a helper fragment gives the field an API number so a mod can require it instead of reading 0 on an older host.
2026-11-03 19:57:33 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fc2cf9fe0c Add Brezhoneg (br_fr) game translation
Bundles Horizon's own Brezhoneg strings so br_fr players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:55:51 +01:00
Ludovic MarieandClaude Opus 5.5 ecdc236ba7 CI: 12 light runners on playhorizon
overlap note fetches up to 40 other pull requests' heads and takes 1-8 minutes there; the bursts of pushes left 350
bot jobs waiting for 8 runners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:55:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 6790d55665 Add Brezhoneg (br) launcher translation support
Registers Brezhoneg as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:55:00 +01:00
MSedgeMC 621cb56d98 port_map: Player.getVoicePitch names player_voice_pitch 2026-11-03 18:54:40 +00:00
MSedgeMC abd5f98688 test: the player's own voice pitch is one
The rule is what a new call site gets wrong, so it is pinned: the helper answers
1.0, and two calls answer the same, which is what says it cannot be wearing a
draw of the level random behind a constant name.

The two call sites are not covered: they sit inside Game's packet handlers and
this crate has no seam that builds a Game.
2026-11-03 18:54:40 +00:00
MSedgeMC e4a521e27e fix: a player's own hurt and death sounds are not pitched
LivingEntity.getVoicePitch jitters every voice by +-0.2 and raises a baby by
1.5; Player overrides it with a flat one:

  public float getVoicePitch();
     0: fconst_1
     1: freturn

So both sounds this client plays for its own player - the hurt sound from
handleDamageEvent and the death sound from handleEntityEvent 3 - belong at pitch
1.0. They were played at 1.0 + (random.nextFloat() - random.nextFloat()) * 0.2,
which is a mob's pitch: the sound came out up to a fifth off for the one player
who cannot hear the server's copy (the server's packet reaches everyone else at
1.0), and each event also drew two floats from the client's level random that
vanilla never draws, shifting every random thing the client does afterwards.

player_voice_pitch() now holds the rule, with the bytecode in its doc comment,
and the stale comment that claimed the +-0.2 walk is corrected.
2026-11-03 18:54:40 +00:00
Ludovic Marie 588c6355e0 Merge pull request #8235 from itarqos5/perf/lod-sampler-threads
🤖 Agent: 8 cores now give 5 samplers and 2 meshers (was 4/3); samplers stay behind_server so ticks and frames keep priority, but steady-state far remeshing on 8-12 thread machines has fewer threads, measured on one 12-thread machine only.
2026-11-03 19:54:38 +01:00
HrafnaValkyrja 1f9dbebe66 Merge remote-tracking branch 'origin/main' into feat/ctl-schema 2026-11-03 13:54:00 -05:00
Ludovic Marie dc3a18c5f9 Merge pull request #8358 from HrafnaValkyrja/feat/admin-mods-list
🤖 Agent: the fields read match what launcher/src/native_mods.rs writes to native-mods.json (base, horizon, hooks, target, mods[id, version, sha256]); no local path is returned. Merged main after #8248 (both edited the API reference).
2026-11-03 19:53:40 +01:00
Ludovic Marie 94d01a9c67 Merge pull request #8288 from HrafnaValkyrja/fix/tick-order-orbs-tnt-falling
🤖 Agent: I replaced the per-entity rescan of the orb/TNT/falling lists (quadratic per tick in an XP farm or a TNT cannon) with one sort per run plus a binary search by join number; same order. Parity's mob_ai bee_hive_rain miss is in the client-lib probe, which holds no game/ code, so not this PR: rerun once.
2026-11-03 19:53:35 +01:00
Ludovic Marie 979f799e9f Merge pull request #8276 from Joansitoh/fix/8265-remote-arrow-particles
🤖 Agent: checked in the 26.3 jar: AbstractArrow.tick adds the bubbles after applyInertia (motion x0.6) at pos - motion*0.25, and the crit trail from the start-of-tick motion; Projectile::arrow_trail already did both, it just had no caller on a client.
2026-11-03 19:53:31 +01:00
Ludovic Marie 9d6a5ad056 Merge pull request #8219 from TiggerTakkyTTT/fix/shutdown-after-extended-session
🤖 Agent: abort_generation now also runs on the server-thread and remote close paths; it leaves Save jobs queued, and leave_server builds a new Streaming afterwards, so the aborted flag can't leak into the next world.
2026-11-03 19:53:26 +01:00
Ludovic Marie 9a08507259 Merge pull request #8134 from arslanarm/shaderpacks/higher-shadowcolor
🤖 Agent: shadowcolor2–7 only exist when the pack names HIGHER_SHADOWCOLOR (the 0b11 mask stays otherwise), so packs without it get the same two buffers; clear_rest now opens one render pass per cleared buffer, which is one or two extra empty passes a frame for ordinary packs, a cost worth merging back into one pass when the formats fit the bytes-per-sample limit.
2026-11-03 19:53:22 +01:00
Ludovic Marie dba9f2d336 Merge pull request #8252 from HrafnaValkyrja/feat/pterodactyl-egg
🤖 Agent: roadmap item server-panels. I fixed the online-mode setting's text: it said Horizon's client joins offline servers only, out of date since Microsoft sign-in. The egg is not tested on a live panel.
2026-11-03 19:53:18 +01:00
Ludovic Marie 0d3e6a2ce1 Merge pull request #8281 from HrafnaValkyrja/feat/install-verify-signature
🤖 Agent: checked the key against release-keys.txt, the Ed25519 SPKI DER prefix, and the message (horizon release <tag>, nightly channel for nightly-*). I replaced the test's GNU sed -i, which fails on macOS with Homebrew OpenSSL.
2026-11-03 19:53:13 +01:00
Ludovic Marie 04fc448cf0 Merge pull request #8282 from HrafnaValkyrja/feat/server-import-resources
🤖 Agent: I made the destination follow the server's lookup order ([engine] resources, then an existing resources/, then the installer's game-files), else an import could land where the next start never looks; version from VERSION_ID.
2026-11-03 19:53:09 +01:00
Ludovic Marie 7357ba25ac Merge pull request #8284 from HrafnaValkyrja/fix/launcher-version-constant
🤖 Agent: GAME now is horizon_core::version::VERSION_ID, which port_regen updates; I dropped the regex-over-browser.rs Python test, which tested the source's words.
2026-11-03 19:53:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b71bbb2e02 Add Башҡортса (ba_ru) game translation
Bundles Horizon's own Башҡортса strings so ba_ru players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:53:01 +01:00
Ludovic Marie 57d42f49b6 Merge pull request #8294 from HrafnaValkyrja/feat/ctl-api-version
🤖 Agent: nothing found; --api checks hello once per connection and refuses before the caller's command reaches the game (exit 5).
2026-11-03 19:53:01 +01:00
Ludovic Marie 5dc441f966 Merge pull request #8316 from HrafnaValkyrja/fix/explosion-push-always-sent
🤖 Agent: ServerExplosion puts every hurt non-spectator player in hitPlayers with the knockback regardless of hurtMarked; the explode packet is sent before the tracker's motion packet, so the client ends on the server velocity.
2026-11-03 19:52:57 +01:00
Ludovic Marie 13fa32fbea Merge pull request #8308 from HrafnaValkyrja/fix/mob-held-weapon-damage
🤖 Agent: Mob.doHurtTarget reads getAttributeValue(ATTACK_DAMAGE), which includes the held item's modifiers; no other path added them before, so no double count (vindicator + iron axe = 13 on Normal, as the wiki says).
2026-11-03 19:52:53 +01:00
Ludovic Marie 64c23f7253 Merge pull request #8363 from Joansitoh/fix/8277-item-slot-gate
🤖 Agent: matches LivingEntity.createEquipmentSlotAccess (filter on every slot but head/mainhand/offhand) and ItemStack.toString ("count item"). Body and saddle slots on mobs are still ungated: the rest of #8277.
2026-11-03 19:52:49 +01:00
Ludovic MarieandClaude Opus 5.5 6291efd685 Merge main; the hook catalog test knows chat.rs's free hook point
native_player_chat is a free function in horizon_client_lib::lan::hub::chat, like the
menu, breathing and motion hooks: catalog_matches_hook_points now reads that file too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:52:47 +01:00
Ludovic Marie 82a92ee8c5 Merge pull request #8350 from Joansitoh/fix/8279-mob-air-frozen-meta
🤖 Agent: vanilla sends DATA_TICKS_FROZEN whenever it differs from 0 (negative via TicksFrozen NBT), and the axolotl's air default is its 6000 max. The unit test returns early without .local, so CI's verify does not exercise it.
2026-11-03 19:52:44 +01:00
Ludovic Marie 5344a91630 Merge pull request #8275 from Joansitoh/fix/8260-disc-fragment-tooltip
🤖 Agent: checked in the 26.3 jar: DiscFragmentItem.appendHoverText adds descriptionId + ".desc" in GRAY, not gated on TooltipDisplay, before the component lines, which is where detail_lines puts it.
2026-11-03 19:52:40 +01:00
Ludovic MarieandClaude Opus 5.5 6e9da7a0aa Merge origin/main into status replies
Keeps both live settings beside each other: hide-online-players (#8144) and
status replies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:52:34 +01:00
MSedgeMC b78f970e81 port_map: Fireball.setItem and readAdditionalSaveData are ported
Fireball.setItem is projectile.rs::Projectile::set_item, and
Fireball.readAdditionalSaveData is entity_nbt.rs::fireball_from_nbt, which now
reads `Item`. ThrownItemRenderer's two rows say a fireball's stack is drawn as
the item model instead of a sprite. Fireball.getSlot stays missing: nothing in
Horizon reads an entity's SlotAccess yet, so `/loot insert` has no path to a
fireball's slot even though the item itself is there.
2026-11-03 18:52:12 +00:00
MSedgeMC ae76563090 fix: a fireball draws as the item it carries, on the host and for a guest
ThrownItemRenderer draws ItemSupplier.getItem as the item model in the GROUND
context at 3.0 for a large fireball and 0.75 for a small one, full bright; the
item is DATA_ITEM_STACK, which rides ClientboundSetEntityData at index 8 (the
add-entity packet carries only an int `data`, an owner id for a projectile).
Horizon drew every fireball from the hardcoded `item/fire_charge` sprite and
never told a guest the item at all.

The stack the fireball holds now goes through resolve_item and submit_item_world
at the renderer's scale, and the host sends a non-default item as
DATA_ITEM_STACK, which the guest applies through set_item.
2026-11-03 18:52:12 +00:00
MSedgeMC 32fe06b0b3 fix: a fireball keeps the item it is drawn as
Fireball.setItem stores the stack in DATA_ITEM_STACK: an empty one goes back
to the default fire charge, anything else is kept as a single item
(copyWithCount(1)). Fireball.readAdditionalSaveData reads `Item` through that
setter, and addAdditionalSaveData writes what getItem returns.

Horizon had no such field. fireball_from_nbt read no `Item` key at all, and
fireball_to_nbt wrote back whatever `Item` the compound it was handed carried,
so a fireball summoned or loaded only ever showed, saved and reported the item
in that raw compound, and `/data get` said what the compound said rather than
what the entity holds.

Projectile now carries `item`, set_item ports the setter, fireball_from_nbt
reads `Item` into it and fireball_to_nbt writes it, so the entity is the one
source of that item and a stale `Item` in the compound no longer shadows it.
2026-11-03 18:52:12 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 644a335044 Add Башҡортса (ba) launcher translation support
Registers Башҡортса as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:52:11 +01:00
Ludovic Marie 7768bf3f8c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-03 19:52:08 +01:00
Ludovic MarieandClaude Opus 5.5 35a315344e Registry snapshot test: std::fs::File, the parent no longer imports it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:51:45 +01:00
Ludovic MarieandClaude Opus 5.5 be5c477960 Merge origin/main into operator permission level
Keeps both live settings: hide-online-players (#8144) and the operator level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:51:31 +01:00
Ludovic MarieandClaude Opus 5.5 03e1f2a6ec Merge main into feat/admin-mods-list
Docs: main's players row (with position) and its done single-dimension
restore, plus this branch's /api/v1/mods row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:50:58 +01:00
Ludovic MarieandClaude Opus 5.5 81c837f979 Merge origin/main into stopped-list-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:50:49 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4ea8c48b36 Add isiZulu (zu_za) game translation
Bundles Horizon's own isiZulu strings so zu_za players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:49:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7b24627544 Add isiZulu (zu) launcher translation support
Registers isiZulu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:48:53 +01:00
MSedgeMC 5dea1cebb2 The ledger rows for a brute's home are ports
PiglinBruteAi.initMemories was listed missing: the memory is set in
Ctx::finalize and the brute now acts on it. The createIdleMovementBehaviors
note names the walk back and keeps the two behaviours still absent.
2026-11-03 18:48:52 +00:00
MSedgeMC 4795422517 Test the brute's walk home and the home a hit leaves alone
register_brute's goals ticked as ai_step ticks them, on a stone floor, with
the brute twenty-four blocks from its home: BruteStrollHome runs and its path
ends at the home block. Five blocks away, past the memory's hundred, and with
no home at all, nothing starts and no path is made. A hit from a mob that is
not in the world leaves a brute's home in place and still takes a piglin's
celebration spot.
2026-11-03 18:48:52 +00:00
MSedgeMC 5e7e5542b1 A piglin brute walks back to where it spawned
PiglinBruteAi.initMemories stores the brute's block position as the HOME
memory, and createIdleMovementBehaviors spends it on two behaviours:
StrollToPoi(HOME, 0.6F, 2, 100) and StrollAroundPoi(HOME, 0.6F, 5). The memory
was already set when a brute is finalized (extra.home and the brain mirror),
but nothing read it, so a brute that was led or knocked away from its post
stayed away.

register_brute now gives it BruteStrollHome ahead of the plain stroll: within
a hundred blocks of home and outside the ten blocks the stroll can reach, the
brute paths back to within two blocks of the memory. Inside that reach the
stroll keeps the brute wandering around it, which is what StrollAroundPoi does
around the same memory in vanilla.

A hit also no longer clears it. piglin_hurt erased extra.home unconditionally,
which is PiglinAi.wasHurtBy erasing CELEBRATE_LOCATION -- the field a piglin
uses for the spot it celebrates at. PiglinBruteAi.wasHurtBy erases nothing, so
the erase is now a piglin's alone.
2026-11-03 18:48:52 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free aa7b959b0d Add Yorùbá (yo_ng) game translation
Bundles Horizon's own Yorùbá strings so yo_ng players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:47:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 70d475eefc Add Yorùbá (yo) launcher translation support
Registers Yorùbá as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:46:19 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0e0ae0bf03 Sync launcher tables with the newest strings
These ten tables were generated before the latest settings, native-mod
and instance strings landed, so the launcher completeness test fails on
main and on every PR. Add the missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:45:54 +01:00
MSedgeMC 2792705984 port_map: the seen-advancements packet and its server handler are ported
Three rows said the tab a player opens goes nowhere: the packet had no codec, the host kept no tab,
and the handler was absent. They name the codec and the hub arm that do it now.
2026-11-03 18:43:48 +00:00
MSedgeMC 19a222e33e test: pin the advancement tab a guest opens and the answer it gets
The protocol test reads a vanilla-shaped OPENED_TAB payload and a CLOSED_SCREEN one, checks that
neither decodes as `Unknown` now, that a packet too short for its tab is malformed, and that the
clientbound tab round-trips with its absence flag.

The hub test builds a two-advancement data folder (a root with a display, and a task hanging off
it), sends the packets through `Hub::drain_packets`, and reads what the guest is sent: the root a
client opens is the tab the server keeps and is sent back to it, opening it again sends nothing, a
tab that is not a root clears the selection and is answered with none, an unknown id changes
nothing, and a closed screen keeps the tab.

Without the handler the guest is sent no tab at all and the host keeps none.
2026-11-03 18:43:48 +00:00
MSedgeMC 785f305cd2 feat: keep and answer the advancement tab a player opens
ServerboundSeenAdvancementsPacket is a typed packet now (action, then the tab only for
OPENED_TAB; a closed screen is the action alone), and ClientboundSelectAdvancementsTabPacket
exists on the clientbound side.

ServerGamePacketListenerImpl.handleSeenAdvancements: an OPENED_TAB names an advancement the host
looks up in its own tree; PlayerAdvancements.setSelectedTab keeps it only when it is a root with a
display, and clears the tab for anything else. Whenever the tab changed, the player is sent the tab
the server keeps, null when it keeps none - which is what closes the client's screen on a tab a
player may not have.

Horizon host-side had nothing: the opcode arrived as `Unknown` with no handler, so a player's tab
was dropped and its screen never corrected. `Guest::selected_tab` keeps it and
`ServerAdvancements::selectable` answers the root-with-display question from the same tree the
advancements packets are built from.
2026-11-03 18:43:48 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d9aa85b6a9 Add ייִדיש (yi_de) game translation
Bundles Horizon's own ייִדיש strings so yi_de players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:42:54 +01:00
Ludovic Marie a74a9ff2ba Merge pull request #8443 from horizon-rust/feat/launcher-glass-mods
🤖 Agent: the Mods page in the mockup's layout; the kinds stay as tabs (Modrinth and shader packs would vanish otherwise), the placeholder only counts a fully listed registry.
2026-11-03 19:42:46 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7066159406 Add ייִדיש (yi) launcher translation support
Registers ייִדיש as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:42:10 +01:00
Ludovic MarieandClaude Opus 5.5 767de40a17 launcher: stopped servers say their mode in the Servers list, as the mockup's
A stopped server's row read "Stopped"; the mockup reads "Creative · stopped". The mode
comes from the open server's loaded settings, else from each folder's server.toml
([world] game_mode), read once per list load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:40:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ae089e970f Add Татарча (tt_ru) game translation
Bundles Horizon's own Татарча strings so tt_ru players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:39:59 +01:00
mattiaemoloandDeepSeek V4.1 Flash Free ae6600f7a5 fix(lan): a spectator no longer opens a container's lid
ChestBlockEntity.startOpen returns early for a spectator, and so does stopOpen (BarrelBlockEntity and ShulkerBoxBlockEntity the same), so a spectator never joins ContainerOpenersCounter. The guest open path counted one anyway: the lid opened, the open sound played for everyone around, and the close path then skipped the spectator, leaving the lid stuck open. Count an opener only for a player who is not a spectator.

tools/lan_e2e.py gains a spectator-container check: the menu opens, the lid stays shut and neither the open nor the close sound is heard, on both sides.

Fixes #8517

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:39:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2c01b2f5f5 Add Татарча (tt) launcher translation support
Registers Татарча as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:39:04 +01:00
Ludovic MarieandClaude Opus 5.5 64c8c8b399 CI: a default Rust toolchain for verify's Python tests on our runners
The desktop's checks runners have their own rustup homes with no default toolchain, and some Python tests run a
bare rustc before the Rust step installs one (25 tests failed on ludodesktop-checks-3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:38:52 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5d3593c1b3 Add Latina (la_la) game translation
Bundles Horizon's own Latina strings so la_la players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:35:50 +01:00
Ludovic MarieandClaude Opus 5.5 bfe0be195c CI: run every job that can on our runners
The bots move to a shared horizon-light label (8 light runners on playhorizon, from 2), and triage command, mojang
republish and nightly build's answer join them. checks' verify and launcher, mod-registry, telemetry-server and the
pack bench run on the desktop's isolated horizon-checks runners, forks included: main's pushes and the pull requests
whose number ends in 0-2 by default (GitHub's pool keeps the others, so both work at once), every one with
SELF_HOSTED_CI=all, none with light (desktop paused) or off (everything back to GitHub).

Jobs with a secret or a token that can push, publish or start workflows stay on GitHub: tests/test_ci_runners.py
pins it. playhorizon's job hook now also drops a checkout's .git past 4 GB: each light runner's held 6822 packs, 50 GB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:34:17 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f9b6a2a820 Add Latina (la) launcher translation support
Registers Latina as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:33:19 +01:00
gorkandClaude Code 23a2db75b0 ci: add concurrency guards and stagger scheduled claim run
Add PR-scoped cancellation to mod-registry and telemetry-server, per-event
concurrency to pr-labels and triage-command, and move claim-command off minute 23.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 19:30:06 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 9c94f59e24 Seat heights of riders and vehicles follow the entity type table
Passenger attachments were known for a handful of mobs; zombies, endermen,
ghasts, cows, silverfish and others fell back to the box top, and values were
doubles where vanilla builds them from floats. Port the passengerAttachments
and ridingOffset values of EntityTypes (ghast and phantom offsets, wither
skeleton 0.875) and widen them from float as vanilla does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:29:53 -05:00
Ludovic MarieandClaude Opus 5.5 56ec4302d1 launcher: focused glass fields wear the mockup's focus ring
The mockup rings whatever has the keyboard (`.hzl :focus-visible`: 2 px of the accent's soft
shade), so a dialog opens with its name field ringed. The launcher only made a focused field a
little lighter. Every glass text field of its own (servers_page::field in the dark look, and the
wide Proxy and Game arguments fields) now draws the ring while focused. It sits inside the field's
edge with its corners, because GPUI has no outline and a field clips what lies outside it. Fields
inside a chip or the console line keep their container's look.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:28:54 +01:00
Ludovic Marie a4a52d4f63 Merge origin/main into fix/8051-reduced-debug-info
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/lan/hub.rs
2026-11-03 19:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 8835bf7537 Launcher: the 9 strings main's bg, da, fi, he, hu, lt, no, ro and sk tables lacked
The i18n test failed on main: those nine languages had none of the settings
and native-mods strings the others have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:23:25 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 35c0142f89 Add Igbo (ig_ng) game translation
Bundles Horizon's own Igbo strings so ig_ng players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:22:29 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f68b448ef1 Add Igbo (ig) launcher translation support
Registers Igbo as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:21:47 +01:00
martipopsandCodex GPT-6 cc66b6f8b6 Merge upstream main into launcher accounts
Preserve the local-server restriction for offline profiles while allowing authenticated Microsoft accounts to join external servers. Keep both translation additions and account regression tests.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-03 13:21:25 -05:00
Ludovic MarieandClaude Opus 5.5 a2ba9c1f4b launcher: the Multiplayer tab says who is online, as the mockup's
The mockup's Multiplayer rows start with a dot (green when the server answers) and say
"address · 3 of 10", or "address · Offline" with Join off. The launcher's rows said only the
address. Each saved server is now asked once for its players by the server-list status ping
the game's own Multiplayer list sends (telemetry::server_players, which the official server's
"N playing now" already used, through SRV, 3-second timeout), on the background executor.
One new string, "{online} of {max}", in the 16 languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:20:58 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 0c8be8e9f9 Only mobs and boats keep a leash tag
Vanilla only makes Mob and AbstractBoat Leashable; other kinds summoned with a leash
tag kept and saved it. Drop it on load. Part of #8024.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:20:15 -05:00
Ludovic Marie 33b996f7da Merge pull request #8236 from Joansitoh/fix/8223-riptide-spin-hit
🤖 Agent: pushed c7c48fe7: a ServerPlayer is client authoritative, so in the 26.3 jar aiStep does not travel on the server and checkAutoSpinAttack tests the current box only. The tick-long sweep would have hit mobs that vanilla's server passes through. Mobs only for now (no players, no armor stands).
2026-11-03 19:19:47 +01:00
Ludovic Marie 6ae9441043 Merge pull request #8231 from HrafnaValkyrja/fix/shader-constructor-argument-types
🤖 Agent: T(x) of an x already a T is the identity, so correct shaders are unchanged; only array and struct constructors of scalar or vector types are touched, and lines are kept for error spans. Pushed bbc4e8f6 adding the missing bvec4 to ELEMENTS.
2026-11-03 19:19:43 +01:00
Ludovic Marie 29f55881c4 Merge pull request #8126 from HrafnaValkyrja/fix/dimension-type-rules
🤖 Agent: checked in the 26.3 jar: Level.canHaveWeather is hasSkyLight && !hasCeiling && dimension != END, which the helper now answers for every weather rule. Pushed c93829b1 to put camera_rotation's doc comment back on camera_rotation.
2026-11-03 19:19:39 +01:00
Ludovic Marie c31d269d32 Merge pull request #8098 from Joansitoh/fix/8050-minecart-display
🤖 Agent: save/load matches AbstractMinecart in the 26.3 jar (DisplayState via BlockState.CODEC, DisplayOffset written only off getDefaultDisplayOffset). Pushed 60a1ee31 so the cart loop looks the display state up once per cart instead of twice (cull and draw). Remote carts still draw the kind's block until the entity-data half of #8050 lands.
2026-11-03 19:19:35 +01:00
Senjin cc345f396f End portal faces bind end_portal.png as a texture of their own 2026-11-03 19:19:33 +01:00
MSedgeMC bac2dbb5ad port_map: Parrot.getVoicePitch names voice_pitch 2026-11-03 18:19:29 +00:00
MSedgeMC 214535c9e7 test: a baby parrot's voice stays in the plain pitch range
The parrot is built from the test mob kind, so the test needs no extracted data
and runs everywhere: 64 draws of a baby parrot's voice pitch stay inside
0.8..=1.2 and average 1.0, where a baby chicken keeps LivingEntity's 1.5.
2026-11-03 18:19:29 +00:00
MSedgeMC a05ca96bc3 fix: a baby parrot keeps the plain voice pitch
Parrot.getVoicePitch is its own getPitch(random), the plain 1.0 +- 0.2 range,
whatever the parrot's age:

  public float getVoicePitch();
     0: aload_0
     1: getfield      #390  // Field random:Lnet/minecraft/util/RandomSource;
     4: invokestatic  #349  // Method getPitch:(Lnet/minecraft/util/RandomSource;)F
     7: freturn

  public static float getPitch(RandomSource);
     0: aload_0  1: nextFloat  6: aload_0  7: nextFloat  12: fsub
    13: ldc 0.2f  16: fmul  17: fconst_1  18: fadd  19: freturn

LivingEntity.getVoicePitch, which Mob::voice_pitch ports, returns 1.5 +- 0.2 for
any baby, so a baby parrot's ambient, hurt and death sounds were played a fifth
higher than vanilla's. The two draws are the same, so nothing else moves.
2026-11-03 18:19:29 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79836dfb8c Add ʻŌlelo Hawaiʻi (haw_us) game translation
Bundles Horizon's own ʻŌlelo Hawaiʻi strings so haw_us players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:19:11 +01:00
MSedgeMC c0fa34e564 port_map: ShulkerBulletRenderer.getBlockLightLevel is the projectile pass
The row was missing while the pass lit a bullet by the room it flew through;
the note also names the two base-class rules the same code now carries.
2026-11-03 18:19:05 +00:00
Ludovic MarieandClaude Opus 5.5 eb1a4f91a4 fixup: the Wardrobe preview's claim says whether it is open
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:19:05 +01:00
MSedgeMC e2498f2bf3 test: pin the projectile light overrides and the eye probe
A shulker bullet and a fireball override the block light, a plain arrow does
not, and an ignited snowball does; an arrow in the top eighth of a block is
lit from the block above it while a wind charge keeps its feet (its entity
type declares no eye height).
2026-11-03 18:19:05 +00:00
MSedgeMC 4e1a240672 fix: a shulker bullet and a burning projectile draw full bright
EntityRenderer.getBlockLightLevel returns 15 for the shulker bullet's
renderer and for anything whose isOnFire is true, and getPackedLightCoords
samples the block the entity's light probe - its eye - is in, not the cell
under its feet. The projectile pass read the level's light at the feet for
every kind but the three fireballs, which it forced to 15 in both nibbles.
2026-11-03 18:19:04 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 201bca5d8a Add ʻŌlelo Hawaiʻi (haw) launcher translation support
Registers ʻŌlelo Hawaiʻi as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:18:23 +01:00
Ludovic MarieandClaude Opus 5.5 eda66d2df4 Launcher: the Wardrobe entry follows the cosmetics service's Shop, not the cosmetics switch
The founder set's Shop lives in the Wardrobe page, which the top bar only showed with the
cosmetics switch on (off by default). Opening the claim window would have shown players nothing.

The launcher now asks the cosmetics service GET /v1/shop at start and every 30 minutes (no
session, nothing about the player) and shows the Wardrobe entry when the set can be claimed now,
when the public window has dates, or when the selected account's own answer from its last visit
(kept on disk) shows it: a claim, or a preview the service grants that account. With no window
set, nothing changes. The switch still shows the page on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:17:47 +01:00
Ludovic MarieandClaude Opus 5.5 0e77c0266c Merge main into #7054 again: keep main's shift-click cycling test beside the particle culling one
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:17:46 +01:00
Ludovic Marie 8279128508 Merge branch 'feat/launcher-glass-played' into feat/launcher-glass-offline-heads 2026-11-03 19:16:53 +01:00
Ludovic MarieandClaude Opus 5.5 816fb20331 launcher: translate the "on the official SMP" count
It was the one raw English string in Home's played line (dark and light).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:16:51 +01:00
Ludovic Marie dd6c0c122d Merge pull request #6399 from martipops/split/fresh-servers
🤖 Agent: narrowed to Ludovic's 2026-11-03 rule: an offline splitscreen guest follows P1 only to a server whose resolved addresses (after the SRV redirect) are all loopback/private/link-local; public servers and friends' invites refuse it on the profile page, pinned by a unit test.
2026-11-03 19:16:39 +01:00
Ludovic Marie ad0b2de014 Merge pull request #8148 from kacpherek/far/1-threads
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Utility QoS for the whole session put macOS far terrain on the efficiency cores for good (Kacper's own finding on #5916). I added his step-back from 6f45ae4278: far threads stay behind only while Lod::set_near_loading says the near terrain loads. Linux is unchanged, since an unprivileged thread can't raise itself again.
2026-11-03 19:16:34 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3deec8af4e Add Esperanto (eo_uy) game translation
Bundles Horizon's own Esperanto strings so eo_uy players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:16:33 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free cc57fd06c8 Add Esperanto (eo) launcher translation support
Registers Esperanto as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 0334e50120 launcher: offline names wear a face in the profile chip and menu (dark)
As the mockup's chip and profile menu (Player46008, pixelmoth): an offline name shows the face
of its own skin (skins/<name>.png), else of vanilla's default skin for its offline UUID
(OfflinePlayer:<name>), read from the prepared game files as a signed-in account's default
already is. It was a person icon. The head is read again when the name's skin file changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:14:49 +01:00
Ludovic Marie 5524e13d9c Merge pull request #8541 from horizon-rust/docs/macos-notary-not-dispo
🤖 Agent: docs only. The README no longer names the DISPO notary key, and it points out that keychain.sh's MACOS_NOTARY_ITEM default is still that key (script left to the signing owner).
2026-11-03 19:14:34 +01:00
Ludovic MarieandClaude Opus 5.5 3f9417521f RAM limit: step down before the limit on Windows too, never page the game out
On Windows a RAM limit was a hard working set limit (SetProcessWorkingSetSizeEx
with QUOTA_LIMITS_HARDWS_MAX_ENABLE): Windows kept the game under it by moving
what it used least to the page file, and the game only stepped down once it
was already slow and faulting hard. Players saw a low limit make the game slow
instead of making it hold less (#6651, #6652).

Windows now does what Linux and macOS do: once a second the game compares its
working set with the limit and, past nine tenths of it, turns far terrain off
and lowers the render distance in use until it fits. The system cap, the page
fault pressure, the frozen-game check and the frame clock that fed it are
gone. The limit stays opt-in (no launcher value, no limit), and
HORIZON_MEMORY_HARD_MB is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:14:29 +01:00
MSedgeMC 2d66c0cccc port_map: the movement emission names its port
getMovementEmission had no row, so a lane reading the ledger saw the whole
rule as unported; it is movement_sounds on Entity and on the two mobs that
carry the override.
2026-11-03 18:13:52 +00:00
MSedgeMC 93bfa12f6f Test the squid, glow squid and bat that step silently
Mob::movement_emission on real types standing on stone: one step's walk from a
squid, a glow squid and a bat sends the minecraft:step game event with no
sound, a silverfish sends the event and no sound for the entity.silverfish.step
its own playStepSound names, a sheep walking the same block is heard at
entity.sheep.step volume 0.15 and pitch 1.0, and a shulker, whose emission is
NONE, sends nothing at all.

The test walks through movement_emission, the path Entity.move calls, and
fails without the three types in movement_sounds: the squid plays
block.stone.step at 0.15.
2026-11-03 18:13:52 +00:00
MSedgeMC 394871bcee A squid and a bat step without a sound
Entity.getMovementEmission decides which movement sounds a type makes, and
both callers of it were short a few types. Silverfish, endermites, guardians,
elder guardians and breezes were already answered with EVENTS, so their
footfalls and swim strokes stayed quiet; a squid, a glow squid and a bat also
answer EVENTS in the jar and were heard here, and a squid's beached flop
walked the stone under it at 0.15 of the block's step volume.

The list now holds all eight, and the walking path asks it too: movement
emission played the footstep regardless, so only step_sound's own silent arm
kept the five quiet, and a squid, which has no playStepSound override and so
takes the block's, was never in it. The step game event still follows for an
EVENTS type, as Entity.vibrationAndSoundEffectsFromBlock sends it.

Silverfish and endermite now name the step sound their own playStepSound asks
for (entity.silverfish.step, entity.endermite.step at 0.15) and are silenced by
the emission, which is where the jar silences them; the silent arm keeps the
types whose override is an empty body: the fish, the nautilus pair, allays,
bees, happy ghasts.
2026-11-03 18:13:52 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c6e8d9fa43 Add Malti (mt_mt) game translation
Bundles Horizon's own Malti strings so mt_mt players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:13:46 +01:00
Ludovic MarieandClaude Opus 5.5 a8f5765c4c docs(macos): the notary profile uses team YYJU63HSD4's key, never DISPO's
packaging/macos/README.md named "App Store Connect API - GitHub CI
(DISPO)" as the key behind the horizon-notary profile. That key belongs
to another Apple team and must never be used for Horizon. The README now
says the profile comes from an App Store Connect API key of team
YYJU63HSD4 (the Developer ID's team), and that keychain.sh notary needs
MACOS_NOTARY_ITEM because its default item is still the other team's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:13:18 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 26207e4532 Add Malti (mt) launcher translation support
Registers Malti as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8a6a766e8a Add ಕನ್ನಡ (kn_in) game translation
Bundles Horizon's own ಕನ್ನಡ strings so kn_in players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:34 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0f95bf2bd2 Add ಕನ್ನಡ (kn) launcher translation support
Registers ಕನ್ನಡ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2c767855fa Add Кыргызча (ky_kg) game translation
Bundles Horizon's own Кыргызча strings so ky_kg players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:11:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 567bdff46b Add Кыргызча (ky) launcher translation support
Registers Кыргызча as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:10:52 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2fb0ab01e8 Add Lëtzebuergesch (lb_lu) game translation
Bundles Horizon's own Lëtzebuergesch strings so lb_lu players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:10:24 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4fd3ea1b41 Add Lëtzebuergesch (lb) launcher translation support
Registers Lëtzebuergesch as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:09:41 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f08f9e62e8 Add Gàidhlig (gd) launcher translation support
Registers Gàidhlig as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:09:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 53f77529f5 Add Gàidhlig (gd_gb) game translation
Bundles Horizon's own Gàidhlig strings so gd_gb players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:08:43 +01:00
Ludovic MarieandClaude Opus 5.5 0657af35cd Bottle dragon's breath until the cloud's wait-shifted end
#8244 made a breath cloud last until tickCount - waitTime >= duration
(dragon::breath_expired); BottleItem.use takes any living cloud, so the
bottle reads that rule too instead of age >= duration, which refused the
cloud's last 20 ticks. The tests' clouds get #8244's victims field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:07:42 +01:00
Ludovic MarieandClaude Opus 5.5 e87fd89b80 launcher: shortcut test starts from the long temp path
GitHub's Windows runners give TEMP as an 8.3 short path
(C:\Users\RUNNER~1\...), and the shell hands a shortcut's target back
expanded (C:\Users\runneradmin\...), so
installer::tests::the_shell_reads_the_target_of_the_shortcut_it_gets
failed on every launcher build since #7091 and no launcher release
went out after [email protected]. The test now builds its folder from the
canonical long path, as a real install folder is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:07:15 +01:00
Ludovic Marie 5d425e3f5d Merge origin/main into #8076 (Breath gained victims in #8244) 2026-11-03 19:07:10 +01:00
MSedgeMC 51dd497dce test: the ambient sampling a tick case also runs reads the default biome 2026-11-03 18:05:40 +00:00
Ludovic MarieandClaude Opus 5.5 1c4f710ee5 launcher: the light look in the redesign's layout
The glass layout now serves both appearances: light keeps the mockup's
pages, palette and toasts, with ink text on light plates over the dawn
sky, light status colours, and a lighter scrim behind the palette.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:05:07 +01:00
Ludovic MarieandClaude Opus 5.5 3a602c8ec3 launcher: "You've played for 41 hours! Last played 2 hours ago." (dark)
Home's played line and the Worlds tab's rows say times as the mockup does: whole hours
("41 hours", "1 hour", "25 min"), and when within a day "just now", "25 min ago",
"2 hours ago", then "yesterday", then the day ("Oct 31") instead of the ISO date. The light
look keeps its own wording.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:05:04 +01:00
Ludovic MarieandClaude Opus 5.5 28c5525946 fa: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:52 +01:00
Ludovic MarieandClaude Opus 5.5 11dc2f6c1a Merge main (with #8385's translations) into #8329 (fa)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:52 +01:00
Ludovic MarieandClaude Opus 5.5 cb10f23a6b el: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:51 +01:00
Ludovic MarieandClaude Opus 5.5 e958049eba Merge main (with #8385's translations) into #8327 (el)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:51 +01:00
Ludovic MarieandClaude Opus 5.5 b8d123941e sv: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:49 +01:00
Ludovic MarieandClaude Opus 5.5 fa158d1eb2 Merge main (with #8385's translations) into #8325 (sv)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:49 +01:00
Ludovic MarieandClaude Opus 5.5 b1b585dae3 cs: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:48 +01:00
Ludovic MarieandClaude Opus 5.5 55434edc35 Merge main (with #8385's translations) into #8323 (cs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:48 +01:00
Ludovic MarieandClaude Opus 5.5 e9331ca4f3 ur: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:47 +01:00
Ludovic MarieandClaude Opus 5.5 bb53dfd0b1 Merge main (with #8385's translations) into #8317 (ur)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:47 +01:00
Ludovic MarieandClaude Opus 5.5 1b98beaf98 th: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:46 +01:00
Ludovic MarieandClaude Opus 5.5 777f76e4f0 Merge main (with #8385's translations) into #8306 (th)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:46 +01:00
Ludovic MarieandClaude Opus 5.5 956213f1bd vi: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:45 +01:00
Ludovic MarieandClaude Opus 5.5 c9d3a1e27f Merge main (with #8385's translations) into #8304 (vi)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:45 +01:00
Ludovic MarieandClaude Opus 5.5 2a45192720 nl: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:44 +01:00
Ludovic MarieandClaude Opus 5.5 2f4d62edea Merge main (with #8385's translations) into #8302 (nl)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:43 +01:00
Ludovic MarieandClaude Opus 5.5 bf43508ea6 it: the 9 strings of main's new look settings
The accent, glass and reset settings and the native mods card came to main
after this table; the i18n test needs every language to have them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 4b1af15559 Merge main (with #8385's translations) into #8286 (it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 8d7c8e6b02 el_gr: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:19 +01:00
Ludovic MarieandClaude Opus 5.5 3bbd1bced4 Merge main (with #8385's translations) into #8328 (el_gr)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:19 +01:00
Ludovic MarieandClaude Opus 5.5 64fda24b03 sv_se: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:18 +01:00
Ludovic MarieandClaude Opus 5.5 405cccee34 cs_cz: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:18 +01:00
Ludovic MarieandClaude Opus 5.5 dd632f4794 Merge main (with #8385's translations) into #8326 (sv_se)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:18 +01:00
Ludovic MarieandClaude Opus 5.5 7bf51053cb Merge main (with #8385's translations) into #8324 (cs_cz)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:18 +01:00
Ludovic MarieandClaude Opus 5.5 ccc94b6bbe pt_br: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 9949f2c975 ar_sa: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 d3fd6e7e1c Merge main (with #8385's translations) into #8318 (pt_br)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 c8943bdf07 Merge main (with #8385's translations) into #8315 (ar_sa)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 eca4c2d566 ja_jp: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:16 +01:00
Ludovic MarieandClaude Opus 5.5 fd5b299140 Merge main (with #8385's translations) into #8314 (ja_jp)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:16 +01:00
Ludovic MarieandClaude Opus 5.5 4ae086ce49 th_th: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:15 +01:00
Ludovic MarieandClaude Opus 5.5 92f74d3be4 vi_vn: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:15 +01:00
Ludovic MarieandClaude Opus 5.5 7dc688bd3e Merge main (with #8385's translations) into #8307 (th_th)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:15 +01:00
Ludovic MarieandClaude Opus 5.5 70047c7d3d Merge main (with #8385's translations) into #8305 (vi_vn)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:15 +01:00
Ludovic MarieandClaude Opus 5.5 1fd627c94c nl_nl: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:14 +01:00
Ludovic MarieandClaude Opus 5.5 4a6dc38a26 it_it: drop English copies, name the controller options
As #8385 did for its tables: entries equal to en_us are dropped (the game
falls back to en_us per key), and the six controller options, which the
table had as verbs (look at the deadzone, invert appearance), are option
names now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:14 +01:00
Ludovic MarieandClaude Opus 5.5 882d342431 Merge main (with #8385's translations) into #8303 (nl_nl)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:14 +01:00
Ludovic MarieandClaude Opus 5.5 ac974a2bcd Merge main (with #8385's translations) into #8287 (it_it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:03:13 +01:00
Ludovic MarieandClaude Opus 5.5 21215383fb launcher: the sign-in gate in the glass look
"Sign in to play" (Play without an account that owns the game) still had the old look:
pixel-cut corners, a solid violet panel and pixel buttons. In the dark glass look it is now a
dialog like the mockup's modals: a plate round at 28 over the dimmed, blurred launcher, the
Account card's white "Sign in with Microsoft" button, the code in the card's inner panel, and
the accent "Open link" with a text "Not now". Same strings, same behaviour; the light look keeps
the old sheet until its own PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:01:44 +01:00
Ludovic MarieandClaude Opus 5.5 6f6972f7b6 launcher: Overview's blur toggle, the played Nightly's patch log, the game log (dark)
As the mockup's Overview:
- the eye button beside See all (and in the Screenshots tab) blurs the screenshots, as
  blur-2xl does: a small blurred copy of each, made in the background, drawn stretched;
- the patch log shows the changes of the Nightly the instance plays (the newest Nightly's when
  that one lists none), "Nightly 2026-11-01 · c9d474d · Nov 1"; a Java instance keeps the
  launcher's notes, dated the same way;
- while the instance prepares, starts or runs, a Game log card over the screenshots shows its
  newest lines and how many there are;
- one screenshot takes half the width (grid-cols-2), not all of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:59:40 +01:00
Ludovic MarieandClaude Opus 5.5 178fae5509 launcher: dialogs over the dimmed, blurred launcher, as the mockup's
The instance and Create server dialogs drew their plate from the bare scene, so the sun showed
through them at full strength, and the page around them stayed sharp under the scrim. The
mockup's modals are hzl-glass over bg-[#07050f]/50 backdrop-blur-xl: the page blurred away and
the plate dimmed with it. Both now do what the palette already did: the scene's blurred copy
covers the page under the scrim, and the plate wears the scrim's dimming under its white.
The palette uses the same two helpers (glass_ui::scrim_backdrop, dialog_plate).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:58:39 +01:00
Ludovic MarieandClaude Opus 5.5 367c94e36d launcher: the instance dialog's default name, dates and import, as the mockup's
A new instance starts named as the mockup's defaultName says: "My Horizon"
for a Horizon one (Java ones already had "Vanilla 26.3" / "Fabric 26.3"),
following its kind until the player types a name. A default name already
taken gets a number ("My Horizon 2"), so Create no longer fails on it.

The glass dialog's Nightlies read "Nov 1" (formatDay), not 2026-11-01, and
Edit offers Import a modpack… as the mockup's Edit does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:53:48 +01:00
MSedgeMC 704a88ac8f fix: four animated particle coordinates were rounded in single precision
The zoo's new cases caught particle coordinates that differ from vanilla's in
the last bits, where Horizon did the arithmetic in the other precision from
vanilla's expression.

Vanilla's `random.nextFloat() * 0.04` is a float times a double literal, so the
product stays double: the sculk sensor's and the respawn anchor's y velocity
were formed in single precision here (0.00036899326369166374 against vanilla's
0.00036899328231811525). The brewing stand's three sums are double products the
same way (6.519509056210518 against 6.519509053230286), and the ender chest's
`pos.getY() + random.nextFloat()` is the other way round - an int plus a float
rounds the sum to single precision before it widens (58.59754526615143 against
58.5975456237793).
2026-11-03 17:52:34 +00:00
MSedgeMC a4f096ec50 test: the rest of the animateTick block zoo
tools/test_marker_particles_parity.py ran the whole ClientLevel.animateTick
loop, but the block it ticked was always a campfire. Every other block the
loop animates - the torches, the lit furnaces, the portals, the end rod, the
candles, the leaves, the redstone wire, the sculk sensor - had no case in any
harness, so their particle positions, their sounds and their draws from the
level random were unpinned.

Run the same loop over a world that is all of one state, for 79 states: the
three torches and their wall forms in all four facings both lit and unlit, the
furnace, blast furnace and smoker lit and unlit in every facing, both nether
portal axes, the end portal, the end rod in all six facings, the ender chest,
the spore blossom, the mycelium, one to four candles and the candle cake, the
brewing stand, six leaf blocks, three redstone wire states, two repeaters, two
levers, fire at two ages, soul fire, a blinking sculk sensor and a charged
respawn anchor, each at two seeds and forty of them at a random one. The
oracle's stand-in level answers isRainingAt with false, as the probe's
stand-in world does, so a leaf block's dripping-water gate can run instead of
reaching a dimension type the stand-in level does not have.
2026-11-03 17:52:34 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 1053072a2c tools: claim each harness port with a lock so parallel harnesses cannot share one
lab.free_port() closes the probe socket before the server binds, so two
harnesses running at once (other worktrees, a job queue) could be handed
the same port and one server failed to start. Each port is now also locked
in the temp dir until the process exits; a port another harness holds is
skipped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:52:20 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3a17d0ae4d Fill in new launcher strings for bundled languages
Nine tables were generated before the remaining settings and native-mod
strings landed, so the launcher completeness test fails on main. Add the
missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:51:08 +01:00
Ludovic Marie b33bc70c4f Merge pull request #8421 from horizon-rust/feat/launcher-glass-about
🤖 Agent: Native mods in Settings > Game is a profile picker (Performance/Dev/Release build), not the mockup's switch, because native mods are always on; it's the same setting as the Mods page's picker.
2026-11-03 18:48:58 +01:00
Ludovic Marie c36dbe39ff Merge pull request #8409 from horizon-rust/feat/launcher-glass-sliders
🤖 Agent: Glass (0-16 %) and Blur (0-48 px) re-tint and re-blur the plates live and save on release; out-of-range or non-number values in settings.json fall back without losing other settings (test glass_and_blur_default_to_the_mockups_and_stay_in_range).
2026-11-03 18:48:38 +01:00
Ludovic Marie 51e6523a81 Merge pull request #8133 from arslanarm/shaderpacks/snorm-buffers
🤖 Agent: the SNORM format is used only where get_texture_format_features allows RENDER_ATTACHMENT; one remaining risk is a pack that sets blend.<program> on a SNORM buffer on an adapter that renders the format but cannot blend it, which a BLENDABLE check would cover.
2026-11-03 18:48:34 +01:00
Ludovic Marie 4f6f5a54e5 Merge pull request #8131 from arslanarm/shaderpacks/rgb-buffer-alpha
🤖 Agent: the (0, 0, 0, 1) start clear runs once per buffer rebuild (rgb_unset), and the per-frame cost is only a COLOR write mask and a Clear(BLACK) load in place of the overwrite on colortex0, so nothing is added per draw.
2026-11-03 18:45:37 +01:00
Ludovic Marie e6359e9d57 Merge pull request #8130 from arslanarm/shaderpacks/composite-default-drawbuffers
🤖 Agent: load_geometry keeps the 0..8 default, which is right because geometry stages load only for the shadow pass, so the colortex0-only default reaches every non-shadow program and nothing else.
2026-11-03 18:45:33 +01:00
Ludovic Marie 5f7f23e2e7 Merge pull request #8129 from arslanarm/blocks/sign-pick-copy
🤖 Agent: checked against the 26.3 jar: sign_text_front/back are data components 118/119, and SignText.STREAM_CODEC is four NBT messages (fixedSizeCollection, no count), an optional filtered four, DyeColor's varint and a bool, which is what sign_text_tag reads and sign_text_bytes writes.
2026-11-03 18:45:28 +01:00
Ludovic Marie 6dad81747f Merge pull request #8091 from CocofireHD/feature/3d-skin-layers
🤖 Agent: off by default and skin_3d_model returns before any work while it is off; the measured cost when on is +0.19 ms CPU per skinned player in view (pose + submit of the extruded slabs), so a crowd is where a distance fallback to the flat layer would pay off next.
2026-11-03 18:45:23 +01:00
Ludovic MarieandClaude Opus 5.5 659cce3aba launcher: Maximum memory in the Edit dialog, Overview's leftover rows (dark)
The glass look keeps an instance's Maximum memory in its Edit dialog: a
SettingRow under the icon, with the mockup's Slider (as Settings' Glass
and Blur), the box in MB and the saved line. It sets the instance being
edited, not the selected one. Home's Overview no longer shows it there.

Overview drops the Microsoft account and Playing offline rows in the
glass look: its top bar has Sign in with Microsoft and says Offline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:43:08 +01:00
Ludovic MarieandClaude Opus 5.5 f145601875 Merge main into the AvoidEntityGoal targeting fix
goals.rs: main's peaceful_targets_tests (#8046's fix) and this branch's
avoid_entity_targeting_tests sit at the file's end; both kept. Since that
fix, can_target_player already refuses a player in Peaceful through
spares_players, so the goal's own difficulty check is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:40:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b43cc79a53 Add Frysk (fy_nl) game translation
Bundles Horizon's own Frysk strings so fy_nl players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:40:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 56b9d1d31c Add Frysk (fy) launcher translation support
Registers Frysk as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:39:24 +01:00
MSedgeMC b499414bfa port_map: the effect refusal names its arm
canBeAffected was in no verdict file, so the ledger ranked it unported; it is
Mob::can_be_affected now, one row on LivingEntity and one on each class that
overrides it.
2026-11-03 17:39:23 +00:00
MSedgeMC 3f89e00503 Test the effects a mob's type refuses
Mob::add_effect on real types from the extracted data: a zombie, a skeleton, a
phantom, a zombie horse and a wither never list poison or regeneration (the
ignores_poison_and_regen tag is #minecraft:undead) while strength lands on
each of them; a silverfish refuses infested and a slime oozing, a magma cube
takes oozing; a spider, a cave spider and a nautilus refuse poison where a cow
takes it; a wither and a wither skeleton refuse the wither effect a zombie
takes; a parched refuses weakness and takes slowness.

The test goes through the list add_effect builds, which is what the client
draws and what the effect tick reads, and fails without the canBeAffected
gate: the refused effect is listed instead of absent.
2026-11-03 17:39:23 +00:00
MSedgeMC e5ce84bc95 An effect a mob refuses is never added
LivingEntity.addEffect asks canBeAffected before it stores the instance, and
that rule had no port: Horizon stored whatever it was handed, so a spider and
a nautilus took poison, a wither and a wither skeleton took the wither effect,
a parched took weakness, and every undead mob listed poison and regeneration
it can never be ticked by.

can_be_affected now answers it the way the jar does. Five classes narrow it
first: Spider.canBeAffected (CaveSpider with it) and AbstractNautilus refuse
poison, WitherBoss and WitherSkeleton refuse the wither effect, Parched refuses
weakness. The three entity type tags answer for the rest: immune_to_infested
keeps infested off a silverfish, immune_to_oozing keeps oozing off a slime,
and ignores_poison_and_regen, which is #minecraft:undead, keeps poison and
regeneration off the undead. add_effect checks it before the absorption
modifier, as vanilla does before the map lookup.

WitherRoseBlock.entityInside no longer spells the wither mobs' refusal out
itself; the effect it asks for is refused inside addEffect, which is where
vanilla refuses it.
2026-11-03 17:39:23 +00:00
Ludovic Marie 9a035fc9d5 Merge pull request #8385 from dontstopgorkingit/lt_lt-game
🤖 Agent: Lands the whole stack (#8330 to #8385, 10 game + 9 launcher tables). Product names translated as words (Discord as "Nesantaika", Fabric mods as textile mods, Horizon as "Хоризонт") were dropped or set back to English, and so were game entries that only copied en_us. The six controller options, which every table had as verbs ("look at the deadzone"), were rewritten. The 8 launcher strings main gained since were filled, and nb/nn now picks Norwegian.
2026-11-03 18:38:29 +01:00
Ludovic Marie 8475a6c69a Merge pull request #8393 from horizon-rust/feat/launcher-glass-sky-tint
🤖 Agent: the scene's stars, runs, fireflies and meteor take the accent the way CSS mix-blend-mode: color does (hue from the accent, luminance kept); test the_accent_tint_keeps_luminance_and_takes_the_hue_like_css.
2026-11-03 18:35:22 +01:00
Ludovic MarieandClaude Opus 5.5 44bc013845 Merge main (main's rustfmt fix for client_lib.rs, #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:34:47 +01:00
Ludovic Marie 0877efffb4 Merge pull request #8429 from horizon-rust/docs/desktop-parity-runner
🤖 Agent: docs only. release-flow.md now describes the desktop runner as it is since 11-03 (gha user, no Linux label, stops within a minute when the maintainer is at the screen). parity-workflow.md says the 10-26 parity-host entry is retired.
2026-11-03 18:34:12 +01:00
AkirabaneandClaude Sonnet 5.5 072990fd30 Server: /mute, /tempmute and /tempban answer with vanilla's messages where vanilla has them
A name no player has was answered "No player was found named X", and a
player banned already "X is already banned". Vanilla's `/ban`, `/kick` and the
other player commands answer `argument.entity.notfound.player` and
`commands.ban.failed`, translated into each client's language.

The replies of these commands are now a `Reply`: Horizon's own line, or a
vanilla translation key. A guest is sent the key as a translatable component,
the console prints its text. The other lines (usage, durations, "Muted ...",
"Banned ... until ...") have no vanilla message and stay as they were.

Follow-up to #8157, asked for in its review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:31:30 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 00e2549a20 Add Soomaali (so_so) game translation
Bundles Horizon's own Soomaali strings so so_so players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:31:03 +01:00
gorkandClaude Code 07a226cbf4 docs: polish parity, port-ledger, and benchmark docs
Verify internal links across the edited docs and tidy wording for clarity.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:30:44 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a71fecab82 Add Soomaali (so) launcher translation support
Registers Soomaali as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:30:21 +01:00
gorkandClaude Code 1b29b9bcff docs: verify and update native hook doc links and counts
Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:30:10 +01:00
gorkandClaude Code fdb1cf4688 docs: add contents to server-levels
Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:29:22 +01:00
gorkandClaude Code c85c4baf4a docs: tidy formatting in native-client
Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:29:07 +01:00
gorkandClaude Code 8a6441c63d docs: improve user-server docs readability and references
Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:28:46 +01:00
gorkandClaude Code 0d817ffd87 docs: improve world generation docs and verify links
Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:28:45 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b14c46b59c Add ລາວ (lo_la) game translation
Bundles Horizon's own ລາວ strings so lo_la players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:28:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 090c6fdcd3 Add ລາວ (lo) launcher translation support
Registers ລາວ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:27:58 +01:00
gorkandClaude Code cb7c5c8967 docs: reorganize CONTRIBUTING and remove stale shader packs known issue
Shader packs are back as a clean-room rewrite, so the stale removal passage and
known-issue bullet no longer apply.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:27:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3b45f7c91c Add Монгол (mn_mn) game translation
Bundles Horizon's own Монгол strings so mn_mn players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:26:14 +01:00
Ludovic MarieandClaude Opus 5.5 94cc6490cb Test the compact quad layout
Whole cube faces take one record whichever corner they start at and
whichever way they go round; quads off the block grid, the UV steps or
the light steps keep their vertices; and a quad without a record points
at its own four vertices after the padded records.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:25:14 +01:00
Ludovic Marie 68586a41aa Merge pull request #8392 from MSedgeMC/fix/dolphin-attack-sound
🤖 Agent: checked in the 26.3 jar: Dolphin.playAttackSound is playSound(DOLPHIN_ATTACK, 1.0, 1.0) and Entity.playSound returns for a silent entity, so the new !m.silent on the axolotl arm fixes a second mismatch too; the test skips in CI without the extracted mob types.
2026-11-03 18:25:10 +01:00
Ludovic Marie d6bdf70cf1 Merge pull request #8389 from MSedgeMC/fix/sniffer-hop-nudge
🤖 Agent: checked in the 26.3 jar: Sniffer.jumpFromGround is super, then moveRelative(0.1F, (0, 0, 1)) when getSpeedModifier() > 0 and horizontalDistanceSqr < 0.01, which the arm mirrors after the jump power is set; I merged main in (both sides added tests at the end of living.rs's test module, both kept).
2026-11-03 18:25:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e0967311bd Add Монгол (mn) launcher translation support
Registers Монгол as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:24:59 +01:00
Ludovic MarieandClaude Opus 5.5 ea3498284b Merge main into pack fog uniforms
pack_uniforms.rs: main's smoothed-uniform half-life test and this
branch's fog and per-program tests sit in the same place; both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:24:33 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ac26100d0f Add தமிழ் (ta_in) game translation
Bundles Horizon's own தமிழ் strings so ta_in players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:23:13 +01:00
razzo715andClaude Opus 5.5 beea32e326 Leave the End near the world spawn when the player has no point of its own
The End exit fell back to the server's own `level.spawn`, which does not
follow `setworldspawn` and is not the travelling player's, so a player
without a respawn point landed at a stale spot or (0, 64, 0) instead of on
a free column around the world spawn. It now takes the player's own
Overworld point, else the world spawn, as a missing bed already should.

Fixes #8333

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:22:52 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free df4c3895dd Add தமிழ் (ta) launcher translation support
Registers தமிழ் as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:22:44 +01:00
gorkandClaude Code 279a04a8eb docs: remove stale 'shader packs removed' known issue
Shader packs were reinstated as a clean-room rewrite, so the known issue no longer applies.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:22:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b66e55041e Add Filipino (fil_ph) game translation
Bundles Horizon's own Filipino strings so fil_ph players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:21:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8e0614ddf0 Add Filipino (fil) launcher translation support
Registers Filipino as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:20:28 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2d86492394 Add Galego (gl_es) game translation
Bundles Horizon's own Galego strings so gl_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:18:49 +01:00
itarqos5andClaude Opus 5 e63d485d89 fix(names): one name tag for a player a server has given a custom name
A server that names its players -- a rank prefix, a nickname -- sets a
custom name on the player entity. Both passes of `name_tags` then claimed
it: the player-list pass, which names every player a server sends, and
the custom-name pass, which names every mob holding a custom name. Two
tags were built for one player and drawn at the same point, so they sat
on top of each other.

The set of players the list already names, which the custom-name pass
skips, was built only when native mods were loaded (a8cdcce9d). Without
them it was `None` and the pass skipped nobody. It is built unconditionally
again, now through `named_by_player_list`, whose doc comment records why it
cannot be conditional. The same commit's other fast path -- leaving an
unnamed, non-mannequin mob out of the pass entirely, which no longer needs
`hooks` to be correct -- is kept.

This matches vanilla, where one entity draws one tag and a player is named
from its profile: `Player.getName` overrides `Entity.getName` and ignores
`customName`. Servers that decorate names through a team prefix and suffix
were never affected; those go through the player-list pass and its
`getDisplayName` handling.

The duplicate also appeared without the custom name being flagged visible,
whenever the crosshair was on the player: the custom-name pass names the
mob under the crosshair whether or not its name is flagged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 17:18:35 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free dbc8d5e52d Add Galego (gl) launcher translation support
Registers Galego as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:18:11 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 24982d2ce4 Add Euskara (eu_es) game translation
Bundles Horizon's own Euskara strings so eu_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:16:22 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 07e377015c Add Euskara (eu) launcher translation support
Registers Euskara as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:15:37 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c696f5a17f Add Gaeilge (ga_ie) game translation
Bundles Horizon's own Gaeilge strings so ga_ie players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:13:53 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d385fa042c Add Gaeilge (ga) launcher translation support
Registers Gaeilge as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:13:06 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 882d679119 Add Cymraeg (cy_gb) game translation
Bundles Horizon's own Cymraeg strings so cy_gb players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:11:20 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4af6cd6f0a Add Cymraeg (cy) launcher translation support
Registers Cymraeg as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:10:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 344b67e56d Add Shqip (sq_al) game translation
Bundles Horizon's own Shqip strings so sq_al players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:08:47 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7b031bc412 Add Shqip (sq) launcher translation support
Registers Shqip as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:08:14 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e47e463341 Add Македонски (mk_mk) game translation
Bundles Horizon's own Македонски strings so mk_mk players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:06:47 +01:00
Ludovic Marie 49a6e02ee3 Merge pull request #8391 from horizon-rust/feat/launcher-glass-accent
🤖 Agent: the accent now takes any #rrggbb as well as the named swatches, and a typed colour survives settings.json (test a_typed_colour_is_worn_as_is_and_survives_settings_json).
2026-11-03 18:06:22 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3a412f7dd1 Add Македонски (mk) launcher translation support
Registers Македонски as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:06:07 +01:00
Kacpherek ca61868478 Merge remote-tracking branch 'origin/main' into opt/compact-quads
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client-render/src/gfx/gpu_culling.rs
#	crates/horizon-client/src/game/render_control.rs
2026-11-03 18:05:59 +01:00
Ludovic Marie f5fe44b835 Merge pull request #8117 from Slavik-85/feat/8020-pack-outline-shader
🤖 Agent: only runs when a pack replaces core/rendertype_lines (no cost otherwise); a pack shader the backend rejects or with bigger blocks falls back to the built-in outline inside one error scope, so a bad pack can't fail the draw. Not yet seen in game with the RGB Block Outline pack.
2026-11-03 18:04:54 +01:00
Ludovic Marie 5dbeb8b604 Merge pull request #8210 from horizon-rust/net/io-pool
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Slot reuse is safe (a closed outbox refuses pushes, the pipe's on_ready is cleared before its slot is freed), and stalls and read timeouts are on the 100 ms sweep. Risk: Conn::read loops until WouldBlock with no per-pass budget, so one client flooding packets holds its thread and delays the other players on it until the rate limit kicks it; it was tested on Linux only, so macOS kqueue and Windows AFD get their first run from players.
2026-11-03 18:04:49 +01:00
Ludovic Marie 60f1ec3d66 Merge pull request #8084 from glitchsnake/fix/8052-limited-crafting
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Matches RecipeCraftingHolder.setRecipeUsed (special or known recipes only) and the Login/GameEvent 12 sync. Simpler way: KnownRecipes has seven impls where production uses only the recipe book Settings; and recipes.recipe(id) scans every crafting recipe per grid change while the rule is on.
2026-11-03 18:04:45 +01:00
Ludovic Marie e6980583f4 Merge pull request #8152 from glitchsnake/fix/7730-spawner-distance-overflow
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. abs_diff and filtering spawners first fix the overflow on the menu's (i32::MIN, 0, 0) placeholder; the 64-block box is only Horizon's pre-filter, the player range test stays in client_tick_spawners. finalpantasy's draft #7818 covers the same issue.
2026-11-03 18:04:41 +01:00
Ludovic Marie 66017a34c5 Merge pull request #8137 from glitchsnake/fix/8123-cycle-backwards
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Matches CycleButton.onPress (shift steps back, positiveModulo wraps): the old code passed right, which is always false by then. The Act::Extra part of the test sits in an if let Some, so it passes silently if that button moves.
2026-11-03 18:04:37 +01:00
Ludovic Marie a189637cfd Merge pull request #8032 from Teiwazik/older-servers/v1-21-10
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Requeued after its STOP: the new head only merges main (now that #8008 landed); its own change is the 1.21.10 step alone (v1_21_10 tables, the bee/wolf anger INT to LONG and avatar main arm BYTE to HUMANOID_ARM), still behind HORIZON_OLDER_SERVERS=1, and it merges clean.
2026-11-03 18:04:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d234aec4fe Add Bosanski (bs_ba) game translation
Bundles Horizon's own Bosanski strings so bs_ba players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:04:28 +01:00
Ludovic Marie 4bd73c9ae1 Merge pull request #8185 from Teiwazik/shader/post-pass-limit
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Only manifest.rs reads MAX_POST_PASSES; the renderer sizes its pass chain from the manifest, so 12 needs no other change, and budget.gpu_ms (max 16) still bounds what the extra passes cost.
2026-11-03 18:04:28 +01:00
Ludovic Marie 3b8dfa69b2 Merge pull request #8373 from horizon-rust/fix/launcher-world-lock-unlock
🤖 Agent: unlocking before close matters because flock belongs to the open file a forked child shares until exec; #7448 touches lock_world too, a small rebase for whichever lands second.
2026-11-03 18:04:24 +01:00
Ludovic Marie 57bb626689 Merge pull request #8201 from oxi-does-dev/fix/button-scrolling-label
🤖 Agent: matches WidgetTooltipHolder.refreshTooltipForNextRenderPass (hovered || focused && last input was the keyboard): a click's focus no longer pins the tooltip; the hover highlight still follows the focus as before.
2026-11-03 18:04:20 +01:00
Ludovic Marie df151f2f33 Merge pull request #8099 from TheMrZZ/perf/borrow-jigsaw-connector-strings
🤖 Agent: nothing found: Cow borrows from the cached StructureTemplate only change ownership; shuffle draws, palette pick and priority sort are untouched, so CI's worldgen harnesses are the check.
2026-11-03 18:04:15 +01:00
Ludovic Marie 481cbc13ed Merge pull request #8095 from Slavik-85/fix/raid-hero-guest-killer
🤖 Agent: raider_died now resolves the killer's connection with player_uuid_of, the same lookup villager_murdered uses on the next line, so a guest's kill makes that guest the hero (Raider.die → raid.addHeroOfTheVillage(player)).
2026-11-03 18:04:11 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1443d76064 Add Bosanski (bs) launcher translation support
Registers Bosanski as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:03:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ba51566766 Add Հայերեն (hy_am) game translation
Bundles Horizon's own Հայերեն strings so hy_am players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:02:30 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 09f41157c2 Add Հայերեն (hy) launcher translation support
Registers Հայերեն as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:01:57 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0029029841 Add ქართული (ka_ge) game translation
Bundles Horizon's own ქართული strings so ka_ge players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:00:37 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a98449dc6a Add ქართული (ka) launcher translation support
Registers ქართული as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:00:04 +01:00
Ludovic Marie ea66f0b2d1 Merge pull request #8090 from masa-masa-0314/fix/pacing-dimension-flight
🤖 Agent: nothing found: tooling only (pacing_bench); the liveness/flight checks run outside the recorded windows, so the measured transition costs are unchanged.
2026-11-03 17:59:59 +01:00
Ludovic Marie 507ede9d05 Merge pull request #8089 from Slavik-85/fix/8047-acting-player-uuid
🤖 Agent: the horse tame now runs in the rider's as_guest turn, so the owner is set before tame_animal fires for that rider, as AbstractHorse.tameWithName orders it; the host no longer gets a guest's advancement.
2026-11-03 17:59:55 +01:00
Ludovic Marie 447e043650 Merge pull request #8083 from OlaHulleberg/content/shapes-occludes
🤖 Agent: one point checked: occludes:false drops a solid render's dampening to 1 (0 with skylight_down) as getLightDampening does for non-solid full blocks; only content-mod blocks that set the key change, vanilla rows are untouched.
2026-11-03 17:59:50 +01:00
Ludovic Marie faa9a0f090 Merge pull request #8081 from OlaHulleberg/fix/food-effect-flags
🤖 Agent: nothing found: the ambient/show_particles defaults (false, true) are MobEffectInstance.CODEC's, and the single ApplyEffects consumer now carries them.
2026-11-03 17:59:45 +01:00
Ludovic Marie 11e6cedd9b Merge pull request #8078 from ClickingMaster/fix/baby-zombie-armor
🤖 Agent: checked on the 26.3 jar: HumanoidArmorLayer.renderArmorPiece reads HumanoidRenderState.isBaby and picks LayerType.HUMANOID_BABY for every slot, and the humanoid_baby textures exist for all eight materials, so this is the vanilla choice.
2026-11-03 17:59:41 +01:00
Ludovic Marie 562b4662a1 Merge pull request #8366 from MSedgeMC/port_map/client-003-display-light
🤖 Agent: checked on main: text_displays.rs display_light answers the packed brightness override or the world light at the display's block, and both geometry passes call it.
2026-11-03 17:59:36 +01:00
Ludovic Marie 084bdbbd25 Merge pull request #8364 from MSedgeMC/port_map/client-005-layer-color
🤖 Agent: checked on main: armor_stands.rs layer_color reads the stack's dyed_color for a dyeable layer and returns white otherwise, so the gap note about undyed leather was stale.
2026-11-03 17:59:32 +01:00
Ludovic Marie 576fb83f6e Merge pull request #8362 from MSedgeMC/port_map/game-004-display-save
🤖 Agent: checked on main: native_entities.rs NativeDisplay::write stores the transformation, both durations and the flipped start_interpolation, and nothing writes billboard, brightness or shadow, which the new note says.
2026-11-03 17:59:27 +01:00
Ludovic Marie 6915962807 Merge pull request #8345 from MSedgeMC/fix/guest-nautilus-jump
🤖 Agent: on_player_jump matches the 26.3 bytecode (isSaddled and dashCooldown <= 0, then getPlayerJumpPendingScale); in vanilla the rider's client runs it, so the host running it from START_RIDING_JUMP's data is the right side for a guest, and the 40-tick clock keeps the host's re-derived bar from dashing twice.
2026-11-03 17:59:22 +01:00
Ludovic Marie 4c7fc96aec Merge pull request #8357 from MSedgeMC/port_map/game-010-bat-flap
🤖 Agent: checked on main: sculk.rs flaps is the bat arm (awake, every tenth tick) and the movement pass posts minecraft:flap over air, so a bat's beat does reach a sculk sensor.
2026-11-03 17:59:18 +01:00
Ludovic Marie 2e6a4800e3 Merge pull request #8355 from MSedgeMC/port_map/classes-01-brute-push
🤖 Agent: checked on main: piglin.rs ai_step's brute branch calls update_activity then maybe_play_brute_sound, and minecarts.rs push_along is the 1.0E-4 and 0.001 projection, so the three rows name their own fns.
2026-11-03 17:59:13 +01:00
Ludovic Marie 1f066e533a Merge pull request #8354 from MSedgeMC/port_map/client-002-tracker-ack
🤖 Agent: checked on main: remote_chat.rs send_chat_acknowledgement is the take_offset and ChatAck-above-zero body, and level_loading.rs holds start_client_load and is_level_ready with the close delay, so the three rows now name the functions that do the work.
2026-11-03 17:59:08 +01:00
Ludovic Marie f7d0fb6957 Merge pull request #8353 from MSedgeMC/port_map/game2-002-names-ammo
🤖 Agent: checked on main: commands.rs target_name's Target::Item arm reads the saved CustomName, then item_name, then the display name, and crossbow.rs mob_vanilla_ammo searches the off hand then the main hand for the weapon's supported stack, else an arrow, so neither row is missing.
2026-11-03 17:59:03 +01:00
Ludovic Marie 5ec8cb805b Merge pull request #8293 from MSedgeMC/test/campfire-animate-tick
🤖 Agent: the lava loop's bound is now drawn on every pass as in vanilla's for (i < random.nextInt(1) + 1), which only the new all-campfire tick cases could see; the harness grows from 25,578 to 58,268 lines, all tick cases on one input.
2026-11-03 17:58:57 +01:00
Ludovic Marie b7680c8178 Merge pull request #8292 from MSedgeMC/fix/vibration-provider
🤖 Agent: remote vibration packets now reach the provider arm with the sprite draw vanilla's Provider makes; the destination is resolved once at arrival, so a signal flying to a moving warden or allay lands where it was (noted in the PR as the gap left).
2026-11-03 17:58:53 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fb613892f9 Add Қазақша (kk_kz) game translation
Bundles Horizon's own Қазақша strings so kk_kz players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:58:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8b7040df82 Add Қазақша (kk) launcher translation support
Registers Қазақша as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:58:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1c23b3eb70 Add Oʻzbekcha (uz_uz) game translation
Bundles Horizon's own Oʻzbekcha strings so uz_uz players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:56:23 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 31301bf5a6 Add Oʻzbekcha (uz) launcher translation support
Registers Oʻzbekcha as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:55:10 +01:00
Ludovic MarieandClaude Opus 5.5 2b4185859a docs: the desktop's parity runner is back, as a NixOS service
Since 2026-11-03 ludodesktop-horizon takes pull requests' parity jobs again
(labels horizon-game, ludodesktop; no Linux): user gha walled off from the
maintainer's files, low-priority slice, only while he isn't at the screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:54:16 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3f771f701d Add Azərbaycanca (az_az) game translation
Bundles Horizon's own Azərbaycanca strings so az_az players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:53:27 +01:00
Ludovic Marie 8dffb823a4 Merge pull request #8376 from razzo715/fix/player-frozen-ticks
🤖 Agent: index 7 now goes out in vanilla's dirty order (1, 7, 17) and only on change, as ServerEntity.sendDirtyEntityData does; Horizon's own client still ignores it for its own player (it counts the ticks itself), so only vanilla clients and other viewers see a change.
2026-11-03 17:52:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2d04ef0198 Add Azərbaycanca (az) launcher translation support
Registers Azərbaycanca as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:52:36 +01:00
Ludovic Marie bd66b6313f Merge pull request #8187 from rdbtCVS/t3/relocate-bench-script
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. The move also puts the bench's server runs on lab.game_env, so they now get HORIZON_NO_DISCORD and scratch XDG and launcher homes, which the old hand-built env left out. Nothing else found.
2026-11-03 17:52:34 +01:00
Ludovic Marie b6ee30c12d Merge pull request #8161 from CocofireHD/fix/set-time-clock-holder
🤖 Agent: test-only; the bytes are a real 26.3 SetTime, and the (0, 66678)/(1, 13071) keys only decode as plain registry ids, which pins WorldClock.STREAM_CODEC = holderRegistry against a later off-by-one 'fix'.
2026-11-03 17:52:29 +01:00
Ludovic Marie 91658b2dff Merge pull request #8332 from razzo715/fix/pick-potted-plant
🤖 Agent: matches FlowerPotBlock.getCloneItemStack (new ItemStack(potted) before the caller's isEmpty check); the potted lookup now runs before the air check that used to return None for every potted_* block.
2026-11-03 17:52:25 +01:00
Ludovic Marie 19f61008b8 Merge pull request #8371 from MSedgeMC/port_map/rest-003-started-cleanly
🤖 Agent: checked on main: run_loading writes startedCleanly false after unclean_start_recovery reads the last start's value, and Loading::finish sets it true and saves, so the protocol is whole.
2026-11-03 17:52:21 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 26f51528c1 Add Беларуская (be_by) game translation
Bundles Horizon's own Беларуская strings so be_by players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:50:13 +01:00
Ludovic Marie 953a24e35d Merge pull request #8147 from kacpherek/perf/tick-off-thread
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Checked hooks.rs Sim::entity_inside: only pressure plates, detector rails, buttons and tripwire act, which is exactly senses_entities, and after a block change every later position still goes in sorted order, so the calls and their order match. Nothing else found.
2026-11-03 17:50:09 +01:00
Ludovic Marie 2a08f74d46 Merge pull request #8257 from CocofireHD/fix/7832-launcher-renderer-env
🤖 Agent: env_remove on the game's Command only drops the four Mesa/WebKit workarounds, so the launcher keeps them for its own window while a radeon player's game gets the GPU; a player who wants the game on llvmpipe on purpose now has to start horizon-client directly.
2026-11-03 17:50:05 +01:00
Ludovic Marie c1372e3b14 Merge pull request #8228 from TiggerTakkyTTT/fix/server-admin-player-positions
🤖 Agent: position comes from the guest's own field, so it's that player's last known position even while another guest is swapped into Game; the API is token-gated on 127.0.0.1, so exposing coordinates stays admin-only. #8245 edits the same 'Not there yet' line in docs/api/server.md and will need main merged after this.
2026-11-03 17:50:01 +01:00
Ludovic Marie 7bcb77ae03 Merge pull request #8258 from Teiwazik/shader/displacement-grid
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Per-frame cost only when a pack declares [displacement], its option is on and the resource packs have LabPBR heights (native_pack.rs gates it); the shadow map and the water mirror still draw flat faces, as the PR says.
2026-11-03 17:49:57 +01:00
Ludovic Marie 8a8af5f0d9 Merge pull request #8203 from Teiwazik/shader/displacement-api
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. API side only: [displacement] needs [material], ranges and option kinds are checked, and MaterialInput.displaced is 0 until #8258 draws the grid. Nothing else found.
2026-11-03 17:49:53 +01:00
Ludovic Marie 7c5fb85f28 Merge pull request #8189 from rdbtCVS/perf/random-tick-next-mask
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. The reuse rule (same revision, from in [f, y]) is sound: the lowest ticking section at or above any Y in that range is still y. The tick_parity test now drives next_masked directly, so the game's own cached answer has no test of its own.
2026-11-03 17:49:49 +01:00
Ludovic Marie b7304857dd Merge pull request #8172 from rdbtCVS/perf/spawn-candidate-incremental
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Per-player segments with a per-level SpawnScratch in levels.rs; the 3,000-step random walk against the uncached union is the right proof for the order the shuffle sees. Nothing else found.
2026-11-03 17:49:45 +01:00
Ludovic Marie fcee2057b8 Merge pull request #8073 from glitchsnake/fix/7461-arrow-impact-server-parity
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Vanilla pairs the arrow at addEntity and its first sendChanges runs before the arrow ticks, so IN_GROUND reaches the client a tick later, as this does. Risk: the joined arrow's metadata is built a second time in lan_entities.rs, so it can drift from the tracker's own arrow_meta path.
2026-11-03 17:49:40 +01:00
Ludovic Marie 49c99f9fa5 Merge pull request #8004 from NerfPhaselock/perf/gpu-culling-ii
🤖 Agent: one point checked: gpu_culling::supported still needs MULTI_DRAW_INDIRECT_COUNT and 8 storage buffers, so Metal and GL keep the CPU walk and only Vulkan/DX12 frames change, the two backends the device tests cover; a camera past the i32 plane terms falls back to a CPU frame, so far teleports can't drop sections.
2026-11-03 17:49:36 +01:00
Ludovic Marie 3b3094e2f2 Merge pull request #8149 from kacpherek/far/2-coarse-first
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Each mesh dispatch now clones plan.nodes and plan.split into the snapshot (thousands of keys, beside the samples map it already clones); an Arc on the plan's sets, rebuilt only on replan, would make that free. Behaviour and tests look right.
2026-11-03 17:49:32 +01:00
Ludovic Marie 8d1e059489 Merge pull request #8151 from kacpherek/far/4-sea-floor
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. Checked: vanillagen/lod.rs shade_sky has dimmed water a level per block since ee7d443c (10-30), so lighting visited water as open sky (96877dc, b4629e0) was what drew the bright patchwork. The heightmap change (bed found under shaded water spans) has no test of its own.
2026-11-03 17:49:27 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5121d46ea7 Add Беларуская (be) launcher translation support
Registers Беларуская as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:49:22 +01:00
Ludovic Marie 4600012a2e Merge pull request #8150 from kacpherek/far/3-hold-split
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. The 5% hold applies only to tiles split last plan, so the 8-to-10 band is unchanged and the new test pins both sides of the margin (670 vs 700 blocks). Nothing else found.
2026-11-03 17:49:22 +01:00
Ludovic Marie 313f351428 Merge pull request #7968 from ErikKalikas/mod-config
🤖 Agent: checked against main: nothing there covers ModConfigEvent yet (only native_config_dir, API 26), and no hook changes landed since this head's base, so the fragment still stamps cleanly; the reload poll is one stat per loaded file per second and nothing at all with no configs registered.
2026-11-03 17:49:17 +01:00
Ludovic Marie 28fb2bf226 Merge pull request #8208 from Joansitoh/fix/8205-scoreboard-air
🤖 Agent: matches ServerPlayer.doTick's lastRecordedAirLevel check: the air score moves only when getAirSupply changes, negative values included while drowning. The red note check was a GitHub 504 in overlap_note.py, rerun.
2026-11-03 17:49:13 +01:00
Ludovic Marie e98bb1c714 Merge pull request #8248 from HrafnaValkyrja/feat/admin-dimension-restore
🤖 Agent: dimension_folder refuses '..', empty segments and anything outside resource-id characters before any path is joined, and the zip entries go through enclosed_name plus a component-wise prefix match, so a crafted backup cannot write outside the dimension folder. Overlaps #8245 in api.rs/cli.rs; whichever lands second carries a small conflict.
2026-11-03 17:49:08 +01:00
Ludovic MarieandClaude Opus 5.5 2dbe322dd1 Shader packs: cite the shaders.properties reference, not Iris, in the new comments
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:48:53 +01:00
Ludovic MarieandClaude Opus 5.5 2afd3b99f8 Merge main into #7054: the particle culling row joins the Performance page's Culling group
Main moved the graphics settings to pages (General, Quality, Performance,
Advanced): Particle Culling now sits beside Entity Culling in the Culling
group, its test drives the row with main's helpers, and the docs list it
with the other culling modes. Lang tables and particles.rs keep both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:48:00 +01:00
Ludovic MarieandClaude Opus 5.5 c1d887afd0 cargo-horizon-mod: its releases never become the repo's latest
The launcher and nightly releases pass --latest=false too; without it every
cargo-horizon-mod tag would take GitHub's "latest" from the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:47:52 +01:00
Ludovic MarieandClaude Opus 5.5 a832968513 Powder snow: a resting entity skips the sweep
PowderSnowBlock.entityInside draws nothing without horizontal known movement, so the
per-tick sweep (and its HashSet) is skipped for entities that do not move sideways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:47:51 +01:00
0f7f9db8cf Far terrain threads step back only while the near terrain loads
From 6f45ae4278 on #5916 (kacpherek's own fix): kept at utility QoS for
the whole session, macOS ran the far terrain on the efficiency cores long
after the near chunks were in, and it took minutes to settle. Each far
thread now steps behind (far_terrain_behind) while Lod::set_near_loading
says the near terrain loads, and back to the default once it is in. On
Linux and Android nothing changes: an unprivileged thread can't raise its
priority again, so they stay behind_server as before.

Co-Authored-By: kacpherek <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:47:17 +01:00
Ludovic Marie 0a79df7554 Merge origin/main into fix/8051-reduced-debug-info
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
2026-11-03 17:47:15 +01:00
Ludovic MarieandClaude Opus 5.5 b266bf1144 Trident flames: the front view's turned yaw, as the other entities' flames
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:46:34 +01:00
Ludovic MarieandClaude Opus 5.5 faa8038177 Keep the client's glass bottle PASS without water
BottleItem.use on the client never finds a dragon's breath cloud (an
AreaEffectCloud's owner is not synced), so with no water in reach it
PASSes and startUseItem gives the off hand its turn. The UseItem packet
is sent before this check either way, so the server still fills the
bottle from the cloud.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:46:30 +01:00
Ludovic MarieandClaude Opus 5.5 e88dc500cb Server: a stop on the supervisor's stdin still ends it
Hosting panels and `horizon-server run` send `stop` on stdin and wait for
the process to exit; with the stopped phase they would wait forever. While
the game is stopped, the supervisor's stdin now takes `start` and `stop`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:45:58 +01:00
Ludovic MarieandClaude Opus 5.5 0eab6d3656 Pterodactyl egg: online mode's description matches today
Horizon's client signs in with Microsoft and joins online servers, so the
note that it can join offline servers only was out of date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:45:39 +01:00
Ludovic Marie e18186e29e Merge pull request #8291 from senjinthedragon/shader-pass-timing
🤖 Agent: the mip timer brackets only each chain's first level (the largest, most of the cost), so read 'mipmaps colortexN' as a lower bound; the label's format! runs every frame even with timing off, a few ns.
2026-11-03 17:45:25 +01:00
Ludovic Marie a1063aadbd Merge pull request #8244 from HrafnaValkyrja/fix/dragon-breath-cloud-timing
🤖 Agent: checked in the 26.3 jar: AreaEffectCloud.serverTick discards at tickCount - waitTime >= duration, returns while waiting before radiusPerTick, and runs the victims map every 5 ticks; neither DragonFireball nor DragonSittingFlamingPhase calls setWaitTime, so the default 20 holds for both clouds.
2026-11-03 17:45:21 +01:00
Ludovic Marie daa33dcb62 Merge pull request #8243 from Joansitoh/fix/8225-pvp-fire-aspect
🤖 Agent: Fire Aspect on a PvP hit only when the hit hurt, as vanilla's doPostAttackEffects. Simpler way for later: Enchantments::ignite_seconds duplicates the ignite half of enchant_effects.rs post_attack's loop, which could call it too.
2026-11-03 17:45:16 +01:00
Ludovic Marie a0018408fe Merge pull request #8239 from HrafnaValkyrja/fix/attribute-command-mob-modifiers
🤖 Agent: modifier add/remove/value get now go through finalize's store, so the cached fields (speed, attack_damage, follow_range...) follow, and an attribute the type lacks answers commands.attribute.failed.entity as vanilla's null getAttribute does. Modifier.id as a Cow stays a borrow for the built-in ids, so no new allocation on the refresh path.
2026-11-03 17:45:11 +01:00
Ludovic MarieandClaude Opus 5.5 8e7b5fa7e9 Merge main into perf/startup-core-overlap
main now restores the block registry from registry-v1.bin (load_registry_with_flags); keep that,
and parse facts.json from a slice on a snapshot miss.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:45:07 +01:00
Ludovic Marie f8350550da Merge pull request #8132 from HrafnaValkyrja/fix/projectile-vehicle-hits
🤖 Agent: checked in the 26.3 jar: LlamaSpit.onHitEntity hurts only for a LivingEntity owner and never discards (only onHitBlock does), so the spit flying on through an entity is right. Moving vehicle_targets into projectile_targets also gives fireworks' and potions' callers boats and carts, which matches VehicleEntity.isPickable for every projectile.
2026-11-03 17:45:07 +01:00
Ludovic Marie b5a8a4271c Merge pull request #8113 from Joansitoh/fix/8100-scrolling-labels
🤖 Agent: ui::scrolling_text is the existing AbstractWidget.extractScrollingStringOverContents port, so the three Horizon widgets now behave like vanilla buttons. The cycler's clip box is w - 18, inside its 9 px arrow gutters, so the sliding text never runs under the arrows. Nothing else found.
2026-11-03 17:45:02 +01:00
Ludovic MarieandClaude Opus 5.5 9835a47fdc install.sh signature test: edit files without GNU sed -i
BSD sed (macOS) reads the expression after -i as a backup suffix, so the
test failed on a Mac with OpenSSL 3 or later on PATH (Homebrew's), where
it does not skip. Edit through a copy instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:45:00 +01:00
Ludovic Marie cf463d354f Merge pull request #8365 from MSedgeMC/test/scene-entity-boxes
🤖 Agent: checked: the oracle's getEntityCollisions keeps CollisionGetter's getSize() < 1e-7 shortcut and the bounds.inflate(1e-7) query, and the wall fixtures sit on the player's leading face plus or minus the inflation, so the probe's entities slice is pinned at the boundary that decides it; it carries #8278's two commits, queued before it.
2026-11-03 17:44:58 +01:00
Ludovic Marie 9ac513c02d Merge pull request #8096 from HrafnaValkyrja/fix/entity-move-block-speed
🤖 Agent: no mob path scaled by the block speed factor before, so no double scaling. LivingEntity.getBlockSpeedFactor also lerps by movement_efficiency, which is 0 for every mob type by default, so leaving it out changes nothing today; it matters once armor with Soul Speed is on a mob.
2026-11-03 17:44:53 +01:00
Ludovic Marie 4054a9e9fc Merge pull request #8086 from Joansitoh/fix/8046-peaceful-targets
🤖 Agent: checked in the 26.3 jar: LivingEntity.canAttack returns false for a Player target in PEACEFUL before canBeSeenAsEnemy, and spares_players feeds can_target_player, can_keep_target_player, HurtByTarget and the golem's DefendVillage, so every targeting path gets it. The w.player.is_some() half is redundant (every caller already has a player) but harmless.
2026-11-03 17:44:49 +01:00
Ludovic Marie f3220f820c Merge pull request #8387 from MSedgeMC/fix/support-blocks
🤖 Agent: checked in the 26.3 jar: handlePlayerPositionChange reads verticalCollisionBelow of the previous move before player.move, as this does; one small difference: the check runs after the anticheat drops a false ground claim, so with [anticheat] on a corrected claim skips the resend vanilla would do, which is harmless.
2026-11-03 17:44:44 +01:00
Ludovic Marie 10a06ec47a Merge pull request #8155 from arslanarm/shaderpacks/pack-clear-both-sides
🤖 Agent: clearing the other texture too is right whenever a buffer flips more than once a frame (both 2 and 3 writes leave the first target stale); it costs one more full-screen clear per cleared buffer each frame, so a later perf pass could skip buffers that flip exactly once, whose alt side still holds that frame's clear.
2026-11-03 17:44:40 +01:00
Ludovic Marie 6cff270d12 Merge pull request #8379 from MSedgeMC/fix/dragon-arena-ticket
🤖 Agent: checked in the 26.3 jar: EnderDragonFight.tick adds TicketType.DRAGON at (0, 0) with radius 9 while the boss bar has a player and removes it otherwise, so ARENA_LEVEL = 33 - 9 = 24 is right, and the source follows fight.players, the same 20-tick validPlayer scan the bar uses.
2026-11-03 17:44:36 +01:00
Ludovic Marie 82d02678d9 Merge pull request #8144 from TiggerTakkyTTT/fix/server-management-hide-online-players
🤖 Agent: hide-online-players now lives in an AtomicBool on lan::Shared that status_json reads per ping, so a management change applies to the next server-list ping without a restart; harness ids 190–199 are unique across the open management PRs.
2026-11-03 17:44:31 +01:00
Ludovic MarieandClaude Opus 5.5 c7c48fe7a8 Riptide: the server tests the player's current box only
A ServerPlayer is client authoritative, so LivingEntity.aiStep does not travel on the server and checkAutoSpinAttack gets the current box twice (26.3 bytecode). Sweeping from where the tick's first move started hit mobs vanilla's server passes through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:44:22 +01:00
Ludovic MarieandClaude Opus 5.5 60a1ee3176 Minecarts: look the display state up once per cart
The cull now needs the display state (a custom air state stretches nothing), and the draw needed it again: compute it once past the distance cut instead of two registry lookups per cart each frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:44:20 +01:00
Ludovic MarieandClaude Opus 5.5 bbc4e8f6c7 Shader constructors: bvec4 arrays and members too
ELEMENTS listed bvec2 and bvec3 but not bvec4, so a bvec4[] constructor or a struct with a bvec4 member was left unconverted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:44:17 +01:00
Ludovic MarieandClaude Opus 5.5 c93829b157 Keep camera_rotation's doc comment on camera_rotation
level_can_have_weather sat between the comment and its function, so rustdoc gave the camera's comment to the weather helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:44:12 +01:00
Ludovic MarieandClaude Opus 5.5 f50c9f25fe import-resources: copy to the folder the server reads first
The destination ignored [engine] resources and an existing resources/
folder, both of which the server reads before the installer's copy, so an
import could land where the next start never looks. Follow the lookup
order, and take the version from horizon_core's pinned VERSION_ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:44:11 +01:00
Ludovic MarieandClaude Opus 5.5 4518465581 LOD thread test: < cores, not <= cores - 1 (clippy int_plus_one)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:43:53 +01:00
arismoko 14e5230c9e Wrap relative teleport angle deltas before resolving them 2026-11-03 16:43:32 +00:00
Ludovic MarieandClaude Opus 5.5 3f73256b01 Translations: keep only real ones, fill the launcher's new strings
Over the 19 tables merged above (fa, he, hu, ro, da, fi, no, bg, sk, lt):

- Game tables: entries equal to en_us dropped (the game falls back to
  en_us per key), as main did for hi_in, id_id, pt_pt and ur_pk.
- Entries that translated a product name as a word (Discord as
  "Nesantaika", Fabric mods as textile mods, Horizon as "Хоризонт",
  Tailscale as "tail scale") dropped in the game, English in the launcher.
- The six controller options (Look Sensitivity, Invert Look X/Y, Sneak
  Button, Move/Look Deadzone) were verbs in every table ("look at the
  deadzone", "invert appearance X"): rewritten as option names.
- Launcher tables: the 8 strings main added since (offline names,
  Microsoft sign-in), so every_language_has_every_string passes.
- Language::parse: nb and nn (what macOS and Windows report for
  Norwegian) pick the Norwegian table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:43:28 +01:00
Kacpherek 512d5cf856 Measure scene_replay's memory with the game's allocator
The replay ran on the system heap, which after --world freed its
remeshed meshes kept 0.8 to 4.9 GB of them dirty ("Malloc Small"),
depending on how the meshes were laid out, so --memory's footprint did
not say what the game holds. It now uses the game's own allocator, as
horizon-client does: the heap's dirty memory is 26 MB after the run.

Also takes clippy's then_some in SectionGpu::vertex_ranges.
2026-11-03 17:43:11 +01:00
Kacpherek f2c8e2ab18 Store whole cube faces of near sections as one 24-byte record
Most solid quads of a section are whole faces of a block's cube on the
block grid: their corners, UVs, light and colour follow from the block,
the face and a few bytes. The mesher now lays such a quad out as one
24-byte record (block and face, UV rectangle, layer, tint, alpha, each
corner's grey level and light) instead of four 24-byte vertices, and the
rest stay as vertices that their record points to. The quads keep their
order, so the side buckets, draws, occlusion culling and GPU culling
count quads as before. A record is kept only when expanding it gives
back the quad's four vertices bit for bit.

The solid pipelines now read the arena page themselves through the flat
draws' page groups, flat and per-section draws alike: a vertex index
below 2^28 names a vertex, above it a corner of the record at that slot.
They expand records to the same vertex words, so the shading after is
unchanged; the colour is converted as the M1's vertex fetch converts
Unorm8x4 (times 1/255), which keeps the old layout's picture exact
through them. The visibility buffer reads records in its resolve; its
IDs now hold a record bit and 6 bits of page, and frames with more than
64 pages draw without it. Translucent quads and far tiles keep their
vertices. With a shader pack (or one of Horizon's own) the sections are
meshed again as vertices, since its programs read the arena as such.
HORIZON_PACKED_QUADS=0 turns the layout off.

On the RD 48 capture, meshed again from its save, 30.4M of 34.4M solid
quads get a record: near-section vertex data falls from 3,362 MB to
1,367 MB and the process's graphics memory from 5.4 GB to 3.4 GB, with
the same GPU frame time. Per-section draws give the same image as
before. Flat draws differ by 1/255 in 15 to 25 pixels of 3.7M: moving
unchanged meshes to other places in the arena does the same (22 pixels),
since the draws then group by page differently.
2026-11-03 17:43:11 +01:00
Ludovic MarieandClaude Opus 5.5 164eb13b65 Drop the source-text test for the launcher's GAME constant
GAME is now horizon_core::version::VERSION_ID itself, so it cannot drift;
a regex over browser.rs tests the words in the source, not behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:51 +01:00
Ludovic MarieandClaude Opus 5.5 28718ed4ee Ordered ticks: sort the run's entities once instead of rescanning per entity
run_ordered_ticks scanned the orb, TNT and falling-block lists once per
entity it ticked (and again to find it), quadratic in their count per
tick. Collect the run's entities once, sort by join number and look each
up with a binary search (linear fallback for a list out of join order).
Same order and same skipped entities as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:36 +01:00
Ludovic MarieandClaude Opus 5.5 876ffc8e4d fa: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:10 +01:00
Ludovic MarieandClaude Opus 5.5 3e8acdf30d el: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:10 +01:00
Ludovic MarieandClaude Opus 5.5 a7b22dde01 Merge main and the translations before it into #8329 (fa)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:10 +01:00
Ludovic MarieandClaude Opus 5.5 5943ae630a Merge main and the translations before it into #8327 (el)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:10 +01:00
Ludovic MarieandClaude Opus 5.5 50022747fe sv: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:09 +01:00
Ludovic MarieandClaude Opus 5.5 10f32b5c05 Merge main and the translations before it into #8325 (sv)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:09 +01:00
Ludovic MarieandClaude Opus 5.5 23bda81bb2 cs: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 e2c852bdd9 Merge main and the translations before it into #8323 (cs)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 4aaec2e26b ur: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:07 +01:00
Ludovic MarieandClaude Opus 5.5 f6721e5930 Merge main and the translations before it into #8317 (ur)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:07 +01:00
Ludovic MarieandClaude Opus 5.5 702764374a th: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:06 +01:00
Ludovic MarieandClaude Opus 5.5 85e59adf3d Merge main and the translations before it into #8306 (th)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:06 +01:00
Ludovic MarieandClaude Opus 5.5 2aa71b0c72 vi: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:05 +01:00
Ludovic MarieandClaude Opus 5.5 2149056a27 Merge main and the translations before it into #8304 (vi)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:05 +01:00
Ludovic MarieandClaude Opus 5.5 98b45cff64 nl: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:04 +01:00
Ludovic MarieandClaude Opus 5.5 3f5e2f0c7b Merge main and the translations before it into #8302 (nl)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:04 +01:00
Ludovic MarieandClaude Opus 5.5 d36f6e7e2e it: add the Account page's 8 strings, keep the name Horizon
main's Account page added 8 strings after this table was written; the
launcher's i18n test needs every language to have them. Strings that
translated the name Horizon as the word horizon now keep the name.

The fallback assertions used it-IT as the language the launcher lacks;
Italian is here now, so they use Esperanto (eo) instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:03 +01:00
Ludovic MarieandClaude Opus 5.5 d71adf169c Merge main and the translations before it into #8286 (it)
Each PR of this series adds its language to the same lines of i18n.rs; this
step holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:42:03 +01:00
Ludovic MarieandClaude Opus 5.5 69132ee3d3 Merge main and the translation PRs #8330 to #8382 into #8385
One union of the 19 translations, which all add lines at the same places
(lang.rs Bundled arms, the launcher Language enum and Language::ALL):
each merges clean with main alone but not with the others. Every file
equals its PR's; lang.rs and i18n.rs carry all of them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:41:28 +01:00
Ludovic MarieandClaude Opus 5.5 7fe3549887 el_gr: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:40 +01:00
Ludovic MarieandClaude Opus 5.5 92e7dfbeb1 Merge main and the translations before it into #8328 (el_gr)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:40 +01:00
Ludovic MarieandClaude Opus 5.5 3fec1f53e7 sv_se: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:38 +01:00
Ludovic MarieandClaude Opus 5.5 f0a0c0519d Merge main and the translations before it into #8326 (sv_se)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:38 +01:00
Ludovic MarieandClaude Opus 5.5 2f5141eb46 cs_cz: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 0b939ab69d Merge main and the translations before it into #8324 (cs_cz)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 e5310a4bf0 pt_br: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:36 +01:00
Ludovic MarieandClaude Opus 5.5 3881c85f75 Merge main and the translations before it into #8318 (pt_br)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:36 +01:00
Ludovic MarieandClaude Opus 5.5 b2cd4ad7d6 ar_sa: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 4b72588a80 Merge main and the translations before it into #8315 (ar_sa)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 f2ab85d7da ja_jp: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:34 +01:00
Ludovic MarieandClaude Opus 5.5 4f7ac75bef Merge main and the translations before it into #8314 (ja_jp)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:34 +01:00
Ludovic MarieandClaude Opus 5.5 76654ac3b6 th_th: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:33 +01:00
Ludovic MarieandClaude Opus 5.5 b56cb1e293 vi_vn: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:32 +01:00
Ludovic MarieandClaude Opus 5.5 67f9e4fa3e Merge main and the translations before it into #8307 (th_th)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:32 +01:00
Ludovic MarieandClaude Opus 5.5 a9e0496cc5 nl_nl: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:31 +01:00
Ludovic MarieandClaude Opus 5.5 02af8ae24e Merge main and the translations before it into #8305 (vi_vn)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:31 +01:00
Ludovic MarieandClaude Opus 5.5 d47f36726f Merge main and the translations before it into #8303 (nl_nl)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:30 +01:00
Ludovic MarieandClaude Opus 5.5 3a93bcc3b6 it_it: keep the name Horizon untranslated
The table translated the name Horizon as the word horizon (or dropped stray
zero-width spaces into it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:29 +01:00
Ludovic MarieandClaude Opus 5.5 ea756a2cc1 Merge main and the translations before it into #8287 (it_it)
Each PR of this series adds its table to the same lines of lang.rs; this step
holds the ones before it, so the series lands in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:29 +01:00
Ludovic Marie 14bbe8bf98 Merge pull request #8278 from MSedgeMC/fix/armor-instances
🤖 Agent: reading the hit record from cached_value keeps the armour instances dirty for a bypasses_armor source, as getDamageAfterArmorAbsorb returns before getArmorValue; the record's two fields only feed hurt_log, so no gameplay changes, and native_scene_motion should get past its first fall hit.
2026-11-03 17:40:26 +01:00
Ludovic Marie 1470ef7f60 Merge pull request #8270 from MSedgeMC/fix/loot-table-containers
🤖 Agent: the 25 names match the 26.3 RandomizableContainer implementors (RandomizableContainerBlockEntity subclasses plus DecoratedPotBlockEntity); both callers place a chest today, so worldgen output does not change.
2026-11-03 17:40:21 +01:00
Ludovic Marie 989f8084c5 Merge pull request #8266 from MSedgeMC/fix/dispenser-loot-table
🤖 Agent: create_dispenser already returns early outside the chunk box or on an existing dispenser, so only a frame's keep list can decline now; the jungle temple and desert pyramid frames keep nothing, so worldgen output does not change and the guard is defensive.
2026-11-03 17:40:16 +01:00
Ludovic MarieandClaude Opus 5.5 622f370fc2 NativeServerPlayer.entity_id: an API number, and the acting guest's id when the hub is out
native_acting_player now takes the id from native_acting_entity, which falls back to the
guest list when lan_acting_entity is unset, as the mount hooks do; a helper fragment gives
the field an API number so mods can require it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:15 +01:00
Ludovic MarieandClaude Opus 5.5 245107d65c Shader packs: keep the flip keys' doc comment on flips
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:15 +01:00
Ludovic Marie d5d7051098 Merge pull request #8254 from MSedgeMC/fix/ocean-ruin-sea-level
🤖 Agent: the drowned arm compares getY() with ServerLevelAccessor.getSeaLevel(), and PieceWorld::sea_level is the same answer blocks() and mineshaft already use; nothing else found.
2026-11-03 17:40:12 +01:00
Ludovic MarieandClaude Opus 5.5 cf5671a3de Merge main into fix/sniffer-hop-nudge
Both sides added tests at the end of mobs/living.rs's test module; both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:08 +01:00
Ludovic Marie fc69982ff8 Merge pull request #8289 from MSedgeMC/fix/sulfur-cube-body-tests
🤖 Agent: sulfur_cube::has_body reads extra.body, so the two tests set a field nothing read and only passed where entity-types.json is absent; nothing else found.
2026-11-03 17:40:08 +01:00
Ludovic Marie a661df2680 Merge pull request #8349 from MSedgeMC/port_map/rest-016-transfer
🤖 Agent: checked on main: execute_command dispatches transfer to cmd_transfer and lan/commands.rs lists it in DEDICATED_ONLY; Hub::transfer sends the packet.
2026-11-03 17:40:04 +01:00
Ludovic Marie 34379152c3 Merge pull request #8348 from MSedgeMC/port_map/rest-015-idletimeout
🤖 Agent: checked on main: execute_command dispatches setidletimeout to cmd_setidle_timeout and lan/commands.rs lists it in DEDICATED_ONLY, so both rows are ports.
2026-11-03 17:39:59 +01:00
Ludovic Marie 7af93bf769 Merge pull request #8271 from MSedgeMC/port_map/client-chunk-cache-rows
🤖 Agent: Streaming::forget no longer exists and forget_columns is the column drop; the per-debris draw lives inside tick_explosions_with, so inline is the right verdict.
2026-11-03 17:39:55 +01:00
Ludovic Marie 4c11af734c Merge pull request #8262 from MSedgeMC/fix/cart-blast-ledger
🤖 Agent: checked on main: explosion.rs exploded_positions spares rails under combat.rail_proof_blast, set from fuse >= 0 in minecarts.rs and saved across nested blasts in native_explosions.rs.
2026-11-03 17:39:51 +01:00
Ludovic Marie 7cacb9417c Merge pull request #8259 from MSedgeMC/fix/enough-food-ledger
🤖 Agent: checked in the 26.3 jar: LocalPlayer is the only class besides Player that references hasEnoughFoodToDoExhaustiveManoeuvres, and sprint.rs Conditions::possible is exactly enough_food || may_fly.
2026-11-03 17:39:46 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79634c271f Add Bahasa Melayu (ms_my) game translation
Bundles Horizon's own Bahasa Melayu strings so ms_my players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:39:44 +01:00
Ludovic Marie da586c1ca2 Merge pull request #8250 from MSedgeMC/port_map/client2-017-accessibility-rows
🤖 Agent: checked on main: options_ui.rs layout puts options.accessibility.link beside Done (Act::AccessibilityHelp) and greys Rotate with Minecarts on in_world && minecart_improvements, so the two rows are ports; init keeps missing for the High Contrast half.
2026-11-03 17:39:41 +01:00
Ludovic Marie 78f292e3a0 Merge pull request #8139 from masa-masa-0314/fix/falling-end-portal
🤖 Agent: matches EndPlatformFeature.createEndPlatform(…, true): each mismatching block goes through destroyBlock(pos, true) (drops + 2001 particles) before the obsidian/air is set; still no regression test, the platform rebuild has none.
2026-11-03 17:39:36 +01:00
Ludovic MarieandClaude Opus 5.5 62f3edb377 Merge #8118 and #8108 (view distance, spawn protection management) into simulation distance management
Stacks the management series so the queue lands them in order; every
handler, setter, port-map row and harness check of the three is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:39:19 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 57a40ae32a Add Bahasa Melayu (ms) launcher translation support
Registers Bahasa Melayu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:39:01 +01:00
Ludovic MarieandClaude Opus 5.5 08fe7a8ca0 Test hooks: the player_chat stub NativeClientHooks now needs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:38:04 +01:00
Ludovic MarieandClaude Opus 5.5 560e63c0ea Merge #8108 (spawn protection management) into view distance management
Stacks the management series so the queue lands them in order: both add
handlers, a lan.rs setter, a port-map row and harness checks at the same
places; all of both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:37:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 31daade750 Add Afrikaans (af_za) game translation
Bundles Horizon's own Afrikaans strings so af_za players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:37:20 +01:00
Ludovic MarieandClaude Opus 5.5 b3017ca6d4 Splitscreen: offline guests join only servers on this computer or its local network
Everyone who plays on another server owns the game (the project's rule,
decided 2026-11-03): P1's offline-profile guests may follow it only to a
server whose resolved addresses (after the SRV redirect) are all loopback,
private or link-local. Public servers, even in offline mode, and friends'
invites refuse them on the profile page with a clear message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:37:20 +01:00
Ludovic MarieandClaude Opus 5.5 ada0c3374f Management harness: move operator level ids to 220-229
They clashed with #8108 (spawn-protection radius), which uses 150-159.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:36:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free bb45e3e267 Add Afrikaans (af) launcher translation support
Registers Afrikaans as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:36:39 +01:00
Ludovic Marie bb1a8aeffb Merge pull request #8360 from NikitaMGrimm/prepare-accept-26.3-republish-6
🤖 Agent: checked against Mojang's live manifest: 26.3 now points at 80608ff6 with the same client jar (e877b6a0) and asset index 331e5c80, so only translations moved; without this, prepare rejects every fresh launcher install.
2026-11-03 17:35:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 35be9810d1 Add Íslenska (is_is) game translation
Bundles Horizon's own Íslenska strings so is_is players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:34:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free cdbda60df6 Add Íslenska (is) launcher translation support
Registers Íslenska as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:34:04 +01:00
Ludovic Marie 27e290b18a Merge pull request #8370 from horizon-rust/feat/launcher-glass-account
🤖 Agent: Account card in the glass look; account_rows is split into microsoft_rows and sign_in_parts, so the light look draws the same rows as before.
2026-11-03 17:32:46 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3b46ba0360 Add Српски (sr_cs) game translation
Bundles Horizon's own Српски strings so sr_cs players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:32:39 +01:00
MSedgeMC 95cba00465 port_map: Entity.playSound names the mobs' voice path 2026-11-03 16:32:24 +00:00
MSedgeMC ce5e42e3dc test: a silent cow makes no ambient sound
The cow's ambient sound goes through the same path a spawned mob's does, so the
test asks one cow with Silent:1b and one without for their ambient sound: the
silent one raises nothing, the loud one entity.cow.ambient at Cow.getSoundVolume
(0.4) and a pitch in the voice range, and both leave the same random state.
2026-11-03 16:32:23 +00:00
MSedgeMC 884536458a fix: a silent mob keeps its voice quiet
Mob.playAmbientSound, and every other sound a mob plays through playSound, ends
in Entity.playSound, which returns early for a silent entity:

  public void playSound(SoundEvent, float, float);
     0: aload_0
     1: invokevirtual #736  // Method isSilent:()Z
     4: ifne          34

Mob::voice pushed its sound event unconditionally, so a mob summoned with
Silent:1b went on humming: an ambient sound, a creaking's death, an armadillo's
roll, a horse's whinny, all of them.

The pitch is still drawn before the check, because Java evaluates the arguments
of playSound(event, getSoundVolume(), getVoicePitch()) before the call; gating
first would leave the random state two draws behind vanilla.
2026-11-03 16:32:23 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a8030fc2bd Add Српски (sr) launcher translation support
Registers Српски as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:32:06 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 024a9dac9f Add Slovenščina (sl_si) game translation
Bundles Horizon's own Slovenščina strings so sl_si players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:30:51 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 110726c5f2 Entity saves: Fire starts at 0, summoned flags survive, every living entity saves its speed instance
Entity.remainingFireTicks starts at 0, so the shared base no longer writes -1.
Orbs and projectiles kept only Tags and Item from a summon; Silent, Glowing,
Invulnerable, CustomName and CustomNameVisible are kept too. AttributeMap.pack
saves the movement_speed instance every living entity makes on its first tick
(LivingEntity.removeFrost), not only zombies and piglins.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 11:30:45 -05:00
razzo715andClaude Opus 5.5 cc5107e8e9 A player's death drops the off hand before the armour (#8367)
Inventory.dropAll drops the main inventory, then EntityEquipment.dropAll,
which walks its EnumMap in EquipmentSlot order: the off hand, then feet,
legs, chest and head. drop_inventory threw the armour first. death_drops
now collects the stacks in that order, with the vanishing-curse filter as
before, so each stack takes the same random throw as in vanilla.

Fixes #8367

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:30:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 302c8a16f4 Add Slovenščina (sl) launcher translation support
Registers Slovenščina as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:30:10 +01:00
Ludovic Marie 72cb603f16 Merge pull request #8157 from Akirabane/feat/tempban-mods170
🤖 Agent: /tempban is a banned-players.json entry with expires, so pardon, banlist and the login check already handle it; parity 140/140 across two runs on this head (proven.txt). Translation follow-up filed separately.
2026-11-03 17:29:36 +01:00
Ludovic Marie a057ca1140 Merge pull request #8159 from Akirabane/fix/8153-boat-yaw-lerp
🤖 Agent: The remote boat's yaw eases over the Lerp's 3 steps like LinearInterpolationHandler; parity 140/140 across two runs on this head, the failures were flakes or main's (proven.txt).
2026-11-03 17:29:32 +01:00
Ludovic Marie 67dad0d1a5 Merge pull request #8168 from Akirabane/fix/8140-mob-climbable
🤖 Agent: Mob climbing matches LivingEntity.onClimbable/handleOnClimbable (26.3 bytecode); parity 140/140 across two runs on this head, the failures were flakes or main's (proven.txt).
2026-11-03 17:29:27 +01:00
MSedgeMC 40ca330b73 The ledger knows the shulker's boarding reset is ported
Shulker.startRiding is no longer missing: it is mobs/shulker.rs::on_start_riding,
called from both rider entry points.
2026-11-03 16:29:16 +00:00
MSedgeMC eb7e87b0ee Test the shulker's attach face on boarding
riders::start_riding on a shulker attached to the block to its east (face 5)
and a pig: the shulker boards and its face is DOWN. A vehicle that already
carries someone refuses the board and the face has still gone back from 3 to
DOWN, with no mount link made. A cow rider keeps the face data it had.
2026-11-03 16:29:16 +00:00
MSedgeMC 311c2f82f3 A shulker that boards a vehicle sits lid up
Shulker.startRiding sets its attach face to Direction.DOWN and only then hands
over to Entity.startRiding, which decides whether the ride happens at all. A
shulker stuck to a wall, a ceiling or the side of a block therefore rides a
boat or a minecart lid up, and one whose board is refused is left lid up too.

mobs/riders.rs had no such reset: extra.variant3 came from Attachable and the
save, so a shulker that boarded kept the face it had and rode on its side. The
reset now runs first in start_riding (before every refusal, matching the
vanilla ordering) and again in link, which the game loop's own mount path
uses.
2026-11-03 16:29:16 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e6aac81666 Add Latviešu (lv_lv) game translation
Bundles Horizon's own Latviešu strings so lv_lv players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:28:35 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e80459c495 Add Latviešu (lv) launcher translation support
Registers Latviešu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:27:55 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c04758dfec Add Eesti (et_ee) game translation
Bundles Horizon's own Eesti strings so et_ee players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:26:19 +01:00
MSedgeMC 5efca3696e test: releasing a column keeps no queued job, and no other
Two jobs queued for (0, 0), one for its neighbour, one far off and a save:
releasing nothing or a column that is not queued leaves all five (the count
vanilla's release answers with is zero too), releasing (0, 0) reports two and
leaves the neighbour's job and the save in their order, and releasing every
column leaves only the save. Without the release the first count comes back 0
instead of 2.
2026-11-03 16:25:39 +00:00
MSedgeMC 42fa57f516 port_map: the two chunk task queue release rows are ported
ChunkTaskPriorityQueue.release and ChunkTaskDispatcher.release were both
missing with a note about queued jobs not being cancelled; both now name
release_columns, which Streaming::forget_columns calls for the columns a view
drop or a server unload removes.
2026-11-03 16:25:39 +00:00
MSedgeMC b28622695d fix: a column that unloads releases the jobs queued for it
Vanilla's ChunkTaskPriorityQueue.release(chunkPos, clearQueue) empties what a
column still has queued when it unloads, and ChunkTaskDispatcher.release calls
it that way from the unload. Horizon's heap had no per-column release: a
teleport, a smaller render distance or a server's forget of a thousand columns
left every generation, decoration, light and mesh job queued for the world the
view had left, and each ran only to have its result dropped on arrival by the
Done::Proto range check.

release_columns drops the heap's jobs whose column is gone, keeping the jobs
without a column (saves, meshes to free) and the order of the rest, and
Streaming::forget_columns - where a remote view's drop and the server's unload
both land - calls it before the columns' other state goes.
2026-11-03 16:25:39 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1a5a195fbb Add Eesti (et) launcher translation support
Registers Eesti as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:25:38 +01:00
k3yhandClaude Opus 5.5 05aa0711e6 Warn when a template pool can't be read
A pool element that fails to decode (a mod's processor list naming a block
state in the pre-26.3 {"Name": ...} form, for one) left the pool out with
no message unless HORIZON_JIGSAW_DEBUG was set: every structure using it
silently stopped generating. Warn once per pool instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 11:25:10 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free efc18e8b0d Add Català (ca_es) game translation
Bundles Horizon's own Català strings so ca_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:23:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1184009b11 Add Català (ca) launcher translation support
Registers Català as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:23:18 +01:00
MSedgeMC f7ac67e929 port_map: the dolphin and axolotl attack sounds name their arm
Dolphin.playAttackSound and Axolotl.playAttackSound had no row, so the two
overrides were only visible by reading do_hurt_target.
2026-11-03 16:22:10 +00:00
Ludovic Marie 0af10f3266 Merge pull request #8351 from horizon-rust/feat/launcher-glass-settings
🤖 Agent: Settings' frame in the glass look; index_row and section_title draw SettingRows only inside glass_ui::flat_rows, so other pages keep their rows.
2026-11-03 17:21:30 +01:00
MSedgeMC 185abc7c52 test: the dolphin, axolotl and panda bite sounds
Pretends nothing without the extracted entity data: the three mobs are built
from the mob types and their blow goes through do_hurt_target, then the sound
events they raised are compared. Without the dolphin arm the list is empty,
and an axolotl with Silent:1b still clicks; the panda is the control that
already honoured both.
2026-11-03 16:18:31 +00:00
MSedgeMC cb7b4d7707 fix: a silent axolotl keeps its attack sound quiet
Axolotl.playAttackSound calls playSound, and Entity.playSound returns early for
a silent entity. The panda arm next to it already checked, the axolotl's did
not, so an axolotl summoned with Silent:1b clicked on every hit.
2026-11-03 16:18:31 +00:00
MSedgeMC cdbad640b6 fix: a dolphin plays its attack sound when its blow lands
Mob.doHurtTarget ends with playAttackSound, which the dolphin overrides with
playSound(DOLPHIN_ATTACK, 1.0, 1.0). Horizon had arms for the panda's bite, the
axolotl's click and the killer bunny's snarl, but none for the dolphin, so a
dolphin that bit a player or a squid was silent. Its goal list does have a
MeleeAttackGoal at 1.2, so the arm is reached in play.
2026-11-03 16:18:31 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 74dc4dc215 Add Lietuvių (lt) launcher translation support
Registers Lietuvių as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:17:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free af70571d9c Add Slovenčina (sk) launcher translation support
Registers Slovenčina as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:17:20 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f77e1df87c Add Български (bg) launcher translation support
Registers Български as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:17:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ef10feee0c Add Norsk (no) launcher translation support
Registers Norsk as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:16:46 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d2133cb5e2 Add Suomi (fi) launcher translation support
Registers Suomi as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:16:30 +01:00
Ludovic Marie 93cc95b510 Merge pull request #8331 from horizon-rust/feat/launcher-glass-notes
🤖 Agent: Patch notes in the glass look; markdown keeps its old look in light, the glass branch only changes spacing, bullets and code corners.
2026-11-03 17:16:15 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 55b1173b07 Add اردو (ur) launcher translation support
Registers اردو as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:16:13 +01:00
arismoko de02700c81 Initialize command registries and permissions in the pitch fixture 2026-11-03 16:16:07 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2b36c5d080 Add Dansk (da) launcher translation support
Registers Dansk as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:15:56 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 523e9b3a3d Add Română (ro) launcher translation support
Registers Română as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:15:39 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 9d76381f66 Add Magyar (hu) launcher translation support
Registers Magyar as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:15:22 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 6c1b80b2a5 Add עברית (he) launcher translation support
Registers עברית as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:15:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d5a61f8f56 Add فارسی (fa) launcher translation support
Registers فارسی as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:14:48 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 87f1e12831 Add Ελληνικά (el) launcher translation support
Registers Ελληνικά as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:14:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5d8c7522b7 Add Svenska (sv) launcher translation support
Registers Svenska as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:14:15 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d81d843209 Add Čeština (cs) launcher translation support
Registers Čeština as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:13:58 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b1cf9f9fa7 Add ไทย (th) launcher translation support
Registers ไทย as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:13:40 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 954f2753bb Add Tiếng Việt (vi) launcher translation support
Registers Tiếng Việt as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:13:23 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e95a5a1066 Add Nederlands (nl) launcher translation support
Registers Nederlands as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:13:06 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5a40f62a94 Add Italiano (it) launcher translation support
Registers Italiano as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:12:48 +01:00
arismoko 5d17cd57d3 Keep command angle wrapping out of the shared teleport writer 2026-11-03 16:12:02 +00:00
MSedgeMC 17f9e72104 port_map: forceSendPlayerSupportBlocks is ported
The row said the blocks under a player standing on air are never sent again. They are now, from
Hub::position_change, so the verdict is `port` with the function that does it.
2026-11-03 16:10:55 +00:00
MSedgeMC f005a6dcd2 test: pin the blocks a guest standing on nothing is sent
The hub test drives the move packets a vanilla client sends: the first 200 ticks of the connection
send nothing, the four corners of the box each get their block with the server's state, a tick
later a second claim sends nothing (one send per 200 ticks), a move the server resolved onto
something is not helped, a client that does not claim the ground is not helped, and a box that sits
across one block gets it once.

Without the send the guest receives no block update at all.
2026-11-03 16:10:55 +00:00
MSedgeMC fa06bc3d72 fix: send a guest standing on nothing the blocks under it
ServerGamePacketListenerImpl.handlePlayerPositionChange: a move packet that claims the ground
while the last move the server resolved came down onto nothing is followed by
forceSendPlayerSupportBlocks, which sends the four blocks under the player's box again
(ClientboundBlockUpdatePacket, plus the block entity's update tag where the block has one), at
most once every 200 ticks of the connection.

The hub only kept the ground claim in `on_ground`, which the accepted move overwrites, so a
guest whose block below went missing without its client noticing stood on air for good: nothing
ever told it what the server has there. It now keeps Entity.move's verticalCollisionBelow of the
last accepted move (`rests_on_ground`) and sends the blocks below on a claim that contradicts it.
2026-11-03 16:10:55 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 195a3a17ce Add Lietuvių (lt_lt) game translation
Bundles Horizon's own Lietuvių strings so lt_lt players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:08:58 +01:00
Kacpherek 41cf95bc1e Report where GPU memory goes in scene_replay
--memory on adds the renderer's largest GPU resources to the report: the
terrain arena pages and what near sections and far tiles use of them,
the translucent arena and sorted indices, the quad indices, the block
sprites with their mips, the LabPBR maps, flat draws' buffers and the
visibility buffer's. On macOS it also runs footprint on the process, so
the arena numbers can be checked against the graphics memory the system
actually charges.
2026-11-03 17:08:44 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b7cc798bcb Add Slovenčina (sk_sk) game translation
Bundles Horizon's own Slovenčina strings so sk_sk players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:06:29 +01:00
MSedgeMC 5d49a985b0 port_map: the ticket rows name the arena ticket and the PORTAL timeout
removeTicketWithRadius is no longer missing: the levels are rebuilt from the
sources, so the fight's arena ticket is the source Game::update_chunk_tickets
sets while EnderDragonFight.tick reports a watcher. addTicketWithRadius now
describes both radius adds (PORTAL 3, DRAGON 9), purgeStaleTickets points at
tick_portal (the PORTAL timeout it already ran), and the two getLowestTicket /
getTicketLevelAt gap notes no longer claim only players and forced chunks.
2026-11-03 16:05:43 +00:00
MSedgeMC 867066a5bb test: the arena ticks out to 7 chunks from its watcher, not the watcher's range
A watcher 12 chunks from (0, 0) whose simulation distance is 2 leaves nothing
around the island ticking without the ticket (checked first), then the fight's
ticket gives entity ticking to 7 chunks in every direction, block ticking to 8,
random ticks where both reach 31, and the watcher's own chunks are unchanged;
dropping the arena stops it again. The random-tick bounds case now holds an
arena with no player near it, and both grid-against-map fuzzes draw one, so the
grid and tile maps have to carry it too.
2026-11-03 16:05:43 +00:00
MSedgeMC 6e6dfa06f2 fix: the end arena ticks while a player watches the dragon fight
EnderDragonFight.tick holds a TicketType.DRAGON ticket on the arena while its
boss bar has a player and removes it when none has:
addTicketWithRadius(DRAGON, new ChunkPos(0, 0), 9) is level FULL - 9 (24) of
both kinds, so the island's chunks are entity ticking out to 7 chunks and block
ticking out to 8, whatever the watcher's simulation distance. ticket_levels
knew players, forced chunks and PORTAL tickets, so a fight watched from the
edge of the arena only ticked the chunks inside the viewer's own simulation
distance: the island's mobs, crystals and scheduled ticks stopped a few chunks
from (0, 0).

Sources gains the arena (Some((0, 0)) while watched, None once nobody is, which
is the removeTicketWithRadius side: the levels are rebuilt from the sources),
the level map lowers it like the PORTAL ticket's ring, and the random-tick
bounding box covers it so the arena still random ticks with no player near it.
2026-11-03 16:05:43 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 04daff56b8 Add Български (bg_bg) game translation
Bundles Horizon's own Български strings so bg_bg players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:04:18 +01:00
MSedgeMC bd143d0b43 test: the scene motion corpus resolves the player against entity boxes
`MotionWorld.entities` was already real — `EntityCollisions` inflates the
entity query by 1e-7 and turns each box into a collision shape — but nothing
ever reached it: `VanillaSceneMotionOracle.Level.getEntityCollisions` returned
`List.of()` and the probe passed `entities: &[]`, so no fixture resolved the
player against another body's box.

`entity_box_corpus` adds 29 fixtures of boxes: a wall whose near face sits on
the player's leading face plus or minus the amounts the 1e-7 inflation decides
between (0, 5e-8, 1e-7, 2e-7, 1e-4, signed zero, half a block), a box low
enough to step onto and ones too tall, a box the player starts inside, a gap
narrower than the player, diagonal and sprinting approaches, and boxes met
while jumping and overhead. The oracle answers
`getEntityCollisions` from them the way `ColliderGetter` does: the
`getSize() < 1e-7` shortcut, `bounds.inflate(1e-7)`, then one
`Shapes.create(box)` per intersecting body.

Dropping the slice again fails at output line 4263: vanilla stops at the wall
with `horizontalCollision` true and z held at 4039800000000000, the probe
walks through with z 40398504816ec226. With the slice the suite matches at
2,853,483 states per side.

The corpus claims collisions only. `LivingEntity.pushEntities` gives the
bodies a velocity change and can cram the player, and the snapshot carries
player state alone, so `getPushableEntities` stays empty and nothing here
claims the pushing half.
2026-11-03 16:03:10 +00:00
MSedgeMC 3157ef700f The ledger knows the sniffer's hop is ported
Sniffer.jumpFromGround is no longer missing: it is
mobs/living.rs::jump_from_ground, which now has the sniffer arm.
2026-11-03 16:03:03 +00:00
MSedgeMC b1070002de Test the sniffer hop that carries it forward
Mob::jump_from_ground on a sniffer whose move control wants to move, from a
standstill: the hop ends with 0.1 on z (yaw 0 faces south) and keeps its
ascent. A sniffer already moving 0.2 keeps that, one the move control did not
ask to move goes straight up, and a cow never takes the push.

The test runs through the jump the AI calls, with a real mob world from the
extracted data, and fails without the sniffer arm: the z velocity is 0.
2026-11-03 16:03:03 +00:00
razzo715andClaude Opus 5.5 8e06e16d37 A player's frozen ticks go out as entity data (#8280)
Entity.DATA_TICKS_FROZEN (index 7) never left the server for a player:
sync_player_state now sends it to the player when the body's ticks_frozen
changes, beside the air and absorption, and frozen_meta puts it in the
player's tracked state for the others, with 0 among its defaults. A
vanilla client on a Horizon server reads its frost overlay from it, and
every client shakes a fully frozen player.

Fixes #8280

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:02:23 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d9688f3096 Add Norsk (no_no) game translation
Bundles Horizon's own Norsk strings so no_no players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:02:04 +01:00
MSedgeMC 2eb87de143 A sniffer's hop from a standstill carries it forward
Sniffer.jumpFromGround hands its super's hop one more push: when the move
control wanted to move this tick (MoveControl.getSpeedModifier above zero)
and the sniffer is moving slower than 0.1 a tick across the ground, it calls
moveRelative(0.1F, (0, 0, 1)), a tenth of a block along its yaw. Mob
jump_from_ground had no sniffer arm, so a sniffer that hopped off the spot it
was sniffing at went straight up and came down where it left.

The sniffer arm sits with the rabbit's and the cubes' own jumps, before the
native hook, so a content mod sees the velocity the hop leaves behind.
2026-11-03 16:01:54 +00:00
Ludovic MarieandClaude Opus 5.5 ccc3ad7c69 launcher: unlock a world's lock explicitly before closing it
A file lock taken with flock belongs to the open file, which a process
started meanwhile shares until it execs: closing our copy alone left the
world locked for that long, so worlds::tests failed now and then while
another test started a process. The launcher's world lock now unlocks
when dropped, and the test unlocks as the game does when it closes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:00:05 +01:00
MSedgeMC 2ab429f046 port map: the startedCleanly protocol is in run_loading and Loading::finish
One row said there was no unclean-start recovery and the other that
startedCleanly is never written after load. It is written false at start,
read by unclean_start_recovery, and written true with the options saved at
the handoff.
2026-11-03 15:59:46 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8ed846b3e3 Add Suomi (fi_fi) game translation
Bundles Horizon's own Suomi strings so fi_fi players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:59:33 +01:00
Ludovic Marie 44a0b9a559 Merge pull request #8313 from horizon-rust/feat/launcher-glass-versions
🤖 Agent: Versions page in the glass look; release dates now read published_at's time, so the backend test constructors gained an empty time.
2026-11-03 16:56:25 +01:00
MSedgeMC e3315e0717 port map: a display's light level is display_light, not the geometry pass
The row pointed at Game::text_display_geometry, which only calls it.
display_light answers the brightness override packed as light coords, and the
world's light at the position when there is no override.
2026-11-03 15:56:08 +00:00
MSedgeMC bc60d450bf port map: getColorForLayer is layer_color, and it reads the dyed colour
The row pointed at Game::armor_layers and said a dyed leather piece keeps
the undyed brown. layer_color takes the stack's dyed colour when it has one
and answers -1 for a layer that is not dyeable.
2026-11-03 15:54:35 +00:00
MSedgeMC 73e1d5b828 port map: a display Horizon holds does save its transformation
Display.addAdditionalSaveData was marked missing as "no local display entity
to save". NativeDisplay::write stores the transformation, both durations and
the start delay into the entity's saved compound; the rest of the synched
data is not written.
2026-11-03 15:54:03 +00:00
MSedgeMC 4c5335c529 port map: a bat's wing beat is flaps, and the flap vibration goes out
The row said a bat's every-tenth-tick flap sends no minecraft:flap. flaps
has the Bat arm and the sculk movement pass posts the event when the mob
flaps over air.
2026-11-03 15:52:52 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 be63126164 Add GET /api/v1/mods: the native mods a dedicated server's game program was built with
Part of #5147.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:52:32 -05:00
MSedgeMC 123323b0e5 port map: a brute's AI step and the furnace cart's push name their fns
Both brute rows pointed at register_brute, which only sets goals: the step is
ai_step's brute branch and the activity switch is update_activity. The
furnace cart's push is push_along, not the slowdown it is called from.
2026-11-03 15:51:45 +00:00
SenjinandClaude Opus 5.5 9efa76d039 Shader packs: End portals, gateways and beams draw with the pack's programs
End portal and gateway faces (position-only quads Horizon's entity
shader fills with its screen-projected starfield) and beacon and End
gateway beams (whose texture that shader wraps per pixel) were left to
Horizon's own entity shader under a pack, drawn into the pack's buffers
as if they were Horizon's frame: under a pack that renders at reduced
resolution they came out black and in the wrong place.

They now go to the pack, by the programs of the pack format: portal
faces to gbuffers_block (block entities) with end_portal.png over each
face, white and at full light, the pack telling them by blockEntityId;
beams to gbuffers_beaconbeam and its fallbacks gbuffers_textured and
gbuffers_basic, after deferred and blended, each face cut where its
texture repeats (at most 64 pieces) so every piece holds one copy of
the sprite in plain atlas coordinates. A block entity's run, and the
beacon beams' own run at stage NONE, is split by part (plain, portal
faces, beams), and under a pack neither portal faces nor beams fall
back to Horizon's entity shader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:50:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a32fe1db93 Prepare: accept Mojang's sixth 26.3 republish (80608ff6, index 331e5c80)
Only assetIndex differs from the last accepted version JSON. The new index
changes 142 minecraft/lang files and adds or removes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:49:52 +01:00
Ludovic Marie 6feb425580 Merge pull request #8298 from horizon-rust/feat/launcher-glass-server-tabs
🤖 Agent: Rule lines are dropped only in the glass look (a local transparent rule); the light tabs keep their borders and rows unchanged.
2026-11-03 16:49:44 +01:00
MSedgeMC f068c11d5c port map: the level-load tracker rows name the tracker
Three rows of client-002 pointed at Game::tick_remote_level or
mark_message_processed. The real homes are LevelLoadTracker's own
start_client_load and is_level_ready, and Game::send_chat_acknowledgement.
2026-11-03 15:49:20 +00:00
MSedgeMC 2d31cdbd6d port map: a dropped item's name and a mob's held ammo are both read
ItemEntity.getName is the Target::Item arm of Game::target_name, and
Monster.getProjectile is Game::mob_vanilla_ammo: off hand then main hand,
the weapon's supported stack, else an arrow.
2026-11-03 15:48:38 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 ad261e3ae4 horizon-ctl: describe the replies of hello and status (#8242)
The command table gains a reply field list per command. hello and status
carry theirs; the generated reference gets a Replies section and the MCP
tool list declares outputSchema (with structuredContent in the call result).
A test requires every command in the group to name its reply fields.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:48:01 -05:00
MSedgeMC 49d2a37c52 port map: /transfer is in the tree, both rows said it was not
The register row claimed no /transfer node and the transfer row that no
command sends a ClientboundTransferPacket. The node comes from the commands
report, Game::cmd_transfer drives it, and Hub::transfer sends the packet.
2026-11-03 15:47:36 +00:00
JoansitohandClaude Sonnet 5.5 71d661a522 Name the stack (count and item) in the known-item no-change failure (#8277)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:47:10 +01:00
JoansitohandClaude Sonnet 5.5 ec4a536a87 Treat empty stacks as empty in the equipment slot gate (#8277)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:47:10 +01:00
JoansitohandClaude Sonnet 5.5 19512add34 Gate /item replace armour slots on the item's equippable slot (#8277)
LivingEntity.createEquipmentSlotAccess refuses a non-empty stack in
chest/legs/feet unless getEquipmentSlotForItem matches.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:47:10 +01:00
MSedgeMC 3f499241d7 port map: two /setidletimeout rows the command now answers
The register row said there was no literal and the setIdleTimeout row said
the timeout could not be changed in game. Both are in the tree: the node
comes from the data generator's commands.json and the command branch is
Game::cmd_setidle_timeout, which stores the minutes and names them.
2026-11-03 15:46:51 +00:00
TeiwazikandClaude Opus 5.5 47c26bc0c7 Shader packs: [displacement]'s relief casts into the shadow map
With [shadow], the pick also lists the quads with relief near the camera
on every side, and the shadow pass draws their grid where the flat quads
leave those texels, from the material's module (it holds displacement
and the vertex stage). The pass's frame group gains the height maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 16:46:35 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d7e29f589f Add Dansk (da_dk) game translation
Bundles Horizon's own Dansk strings so da_dk players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:45:59 +01:00
MSedgeMC 404bb22d32 port_map: the nautilus jump rows name the arm the packet reaches
`AbstractNautilus.onPlayerJump` is `nautilus::on_player_jump` now rather than
an inline note on `charge_mount_jump`, and the `handleStartJump` row says
where the packet's scale lands and that DASH and the dash sound belong to the
dash itself.
2026-11-03 15:45:54 +00:00
MSedgeMC c11630ff47 test: the released jump bar's scale reaches a guest's nautilus
The test goes through `mounts::start_riding_jump`, the arm
`handlePlayerCommand` START_RIDING_JUMP calls: a three-quarter bar leaves
0.733 on the rider, a full bar 1.0, an unsaddled nautilus takes nothing, and a
dash still in its 40-tick cooldown keeps the scale it had. The last part
follows the scale into the mount's travel (`equine::ridden_step`): the rider
stays on with an empty pending jump, the dash clock starts, DASH is raised,
the dash sound is queued for the next tick and the velocity turns along the
rider's look.

The nautilus is built from the trip's own `MobType`
(`sensor_scan_tests::kind`), so the test runs without the prepared resources.
2026-11-03 15:45:54 +00:00
MSedgeMC 914cf3267d fix: a guest's released jump bar arms the nautilus it rides
`AbstractNautilus.onPlayerJump` keeps the released bar's scale on the rider
(`playerJumpPendingScale`), and `tickRidden` dashes with it. A guest's ride is
simulated by the host, so the only thing that reaches the mount is its
START_RIDING_JUMP packet — and `mounts::start_riding_jump` answered every
nautilus with None. The host was left re-deriving the bar from the guest's
`PlayerInput` bits, which it samples once per tick: a press and release that
arrive together are collapsed to the last flags, and that release never
dashes.

The packet's own scale now goes to the nautilus through
`nautilus::on_player_jump`, behind vanilla's gate (saddled, dash cooldown at
zero), and the mount's next travel spends it. The scale out of 100 is
`PlayerRideableJumping.getPlayerJumpPendingScale`, now one function
(`equine::jump_pending_scale`) shared with the host's own bar charge.

Nothing sets DASH here: Horizon's `flag2` gates the dash, so raising it before
the momentum lands would cancel the dash instead of starting it.
2026-11-03 15:45:54 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e7b8d6a0a1 Add Română (ro_ro) game translation
Bundles Horizon's own Română strings so ro_ro players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:45:29 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 89598c575a Add Magyar (hu_hu) game translation
Bundles Horizon's own Magyar strings so hu_hu players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:45:02 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free be7cf6611c Add עברית (he_il) game translation
Bundles Horizon's own עברית strings so he_il players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:44:35 +01:00
Ludovic Marie 73ab09ef53 Merge pull request #8285 from horizon-rust/feat/launcher-glass-server-dialog
🤖 Agent: The dialog's friend chips feed the whitelist; a name failing Minecraft's 3–16 rule stays in the field instead of being added.
2026-11-03 16:40:13 +01:00
razzo715andClaude Opus 5.5 34088bafca Pick Block on a potted plant gives the plant (#8272)
picked_stack returned nothing when the block's own item was air, before
the flower pot lookup ran, and a potted plant's block has no item of its
own. picked_item now looks the potted plant up first, as
FlowerPotBlock.getCloneItemStack does (new ItemStack(potted)), and only an
item that is still air picks nothing.

Fixes #8272

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:38:30 +01:00
JoansitohandClaude Sonnet 5.5 c87d15a28d Sync negative frozen ticks and axolotl air supply in mob entity data
Part of #8279

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:37:55 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 360a7a50b7 Add فارسی (fa_ir) game translation
Bundles Horizon's own فارسی strings so fa_ir players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:36:21 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8236aa329f Add Ελληνικά (el_gr) game translation
Bundles Horizon's own Ελληνικά strings so el_gr players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:35:53 +01:00
Kacper 352aef0008 Merge pull request #8198 from Akirabane/fix/8192-title-entity-draws
Draw no world entity over the title's panorama
2026-11-03 16:35:41 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d4052f7c5c Add Svenska (sv_se) game translation
Bundles Horizon's own Svenska strings so sv_se players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:35:24 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4698f8f423 Add Čeština (cs_cz) game translation
Bundles Horizon's own Čeština strings so cs_cz players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:34:52 +01:00
Kacpherek 796a87f53d Let scene_replay mesh the captured sections again from a save
A capture holds finished meshes, so a mesher change could not be
measured on it. --world SAVE meshes every captured section again from
that save's region files (blocks, biomes and saved light, with ao,
biomeBlendRadius and cutoutLeaves from ./options.txt) on every core,
with the mode --leaf-culling names. A section whose 3x3 columns are not
all saved keeps its captured mesh. The report now also counts solid and
leaf quads.
2026-11-03 16:32:09 +01:00
Kacpherek 3778026966 Add a Hidden Faces leaf culling mode
With fancy leaves every face between two leaf blocks is drawn, so tree
crowns are full of faces that are almost never seen, and they dominate
the cutout quads of a forest. The new mode drops a face between two
leaves when the leaf it faces has another leaf or a full opaque cube
behind it: such a face only shows through two cutout layers, while the
faces just behind a crown's outer layer stay. The faced leaf must be in
the section, so the block behind it is at most a border cell of the
next section, whose edits already remesh this one; a missing chunk or
a Y outside the column never counts as covered.

On the RD 48 forest capture it cuts leaf quads from 7.6M to 4.6M and the
GPU frame by about 1 ms. Off by default; Off meshes exactly as before.
2026-11-03 16:32:09 +01:00
Kacpherek 940d1f9df6 Report the fastest and 10th percentile frame times in scene_replay
Another process on the same GPU, or the GPU's clock state, moves the
median frame time of a whole run by several milliseconds, which hid
changes of a few tenths. The summaries now also give the minimum and
the 10th percentile, which stay put across such runs.
2026-11-03 16:32:09 +01:00
Kacpherek ce9e113d9a Occlusion test far terrain tiles like near sections
Far terrain tiles were culled only by the frustum: every tile in view
was drawn in phase 1, also those behind nearer hills and mountains. At
render distance 32 that was about 1,000 tiles and a third of the solid
pass's time.

Tiles now go through the same two-phase occlusion test as sections. A
test carries its box's corners around the origin (a section's stays its
cell grown by a block); a tile's box is its mesh's bounds grown by a
block, and it has a culling slot of its own, kept while meshes of the
same tile replace one another. Tiles that are fading or have a stand-in
mesh stay in phase 1 untested, as their depth or mesh is not the box's.

The image is the same: the test only leaves out a tile whose every
fragment would fail the depth test, and tiles are drawn a little
farther back than their box. scene_replay reports the culling result.
On the captured RD-32 scene at 1440p with the visibility buffer, the
frame's GPU p50 goes from 8.31 to 7.95 ms looking ahead and from 8.73,
8.76 and 7.99 to 7.64, 7.85 and 6.94 ms turned 90, 180 and 270 degrees;
the screenshots are byte-identical with the visibility buffer, without
it, and with indirect section draws.
2026-11-03 16:32:09 +01:00
Kacpherek d61c1d6b6c Resolve corner by corner, and HDR's inputs only with HDR output
The resolve ran terrain_vertex on its triangle's three corners and
then interpolated every output, the face's local position and light
levels included, and worked out the face's normal for every pixel.
Only HDR output reads those, and fs_opaque computes the normal only
with it on. The corners now go into the sums one at a time, and the
light levels, position and normal are worked out only with HDR output
on.

The sums are the same, taken in the same order: the screenshot is
bit-identical to the previous commit's. The resolve goes from 1.72 to
1.69 ms in the RD-32 capture at 1440p.
2026-11-03 16:32:09 +01:00
Kacpherek e7440c5703 Pick the resolved pixel's triangle by its side of the quad's diagonal
The resolve worked out the barycentrics of both of a quad's triangles
and kept the one whose smallest weight was larger, then built the
chosen triangle's rows again for the gradients. The triangles share
the diagonal 0 2, so the side of it the pixel lies on decides: corner
1's unnormalised weight against the sign of triangle 0 1 2's
determinant. That is one cross product instead of six and one set of
barycentrics instead of two.

Inside a triangle both rules pick the same one, and on the diagonal
both triangles give the same values, so the image is the same up to
float rounding (a few hundred texel-edge pixels of 3.7 million, as many
nearer the normal path as farther). The resolve goes from 1.75 to
1.715 ms in the RD-32 capture at 1440p.
2026-11-03 16:32:09 +01:00
Kacpherek b4c85ed027 Read a resolved quad's words in one switch over its page
The visibility resolve read a quad's six 16-byte words through six
separate switches over the 16 bound pages. Where a GPU thread group's
pixels show quads of different pages, each switch ran once per page
among them, six times over. One switch now loads all six words, so the
group walks its pages once.

The loaded words are the same, so the shading is too; the image differs
from before only by float rounding in a few hundred texel-edge pixels
of 3.7 million, as many nearer the normal path's as farther. The
resolve goes from 2.15 to 1.76 ms in the RD-32 capture at 1440p.
2026-11-03 16:32:09 +01:00
Kacpherek 59ca5112f1 Resolve the visibility buffer in one draw for up to 16 pages
The resolve drew a full-screen triangle per arena page. Pages' sections
lie all over the screen, so every GPU thread group ran the shading once
for each page among its pixels with most lanes idle: 36 ms at 1440p
with nine pages. It now binds up to 16 pages at once and reads a quad
as six vec4 loads; the origin comes through one copy of the drawn
pages' block tables, so the IDs stay 32-bit. The resolve takes 2.1 ms.

Both passes sample only the side of RGSS's blend with the nearest
texel that the blend keeps, one tap where texels are large, which
gives the same value; the cutout alpha test in the ID pass gets cheaper.

scene_replay gets --screenshot PNG, which saves the first view's last
frame, to compare the visibility buffer's image with the normal one.
2026-11-03 16:32:09 +01:00
Kacpherek de2a7fa0cf Visibility buffer: phase 2's opaque and cutout ranges 2026-11-03 16:32:08 +01:00
Kacpherek 453ca8ffdb Add an opt-in visibility buffer for solid terrain
HORIZON_VISBUFFER=1 draws the solid flat draws (near sections and far
tiles, both occlusion culling phases) into a depth and 32-bit quad ID
pass first, then shades each pixel once in a full-screen resolve per
arena page. The resolve reads the quad back from the arena, runs the
terrain vertex code on the triangle the pixel falls in, interpolates
with perspective-correct barycentrics and works out the UV gradients
analytically, so texels, mip levels, light and fog match the normal
path. Cutout and fading quads keep their alpha test in the ID pass.

Only frames drawn flat use it; shader pack terrain, material stages,
shadow passes and surface targets keep the normal solid passes.
2026-11-03 16:32:08 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free cb4573aeb9 Add Brazilian Portuguese (pt_br) game translation
Bundles Horizon's own Brazilian Portuguese strings so pt_br players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:28:22 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2c12bd8e05 Add Arabic (ar_sa) game translation
Bundles Horizon's own Arabic strings so ar_sa players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:27:14 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b9558afaa6 Add Japanese (ja_jp) game translation
Bundles Horizon's own Japanese strings so ja_jp players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:27:04 +01:00
Kacpherek a4f89dde97 RGSS takes one tap where the sprite is a single texel
Past a few hundred blocks, and on every distant tile, RGSS asks for mip
levels at or beyond the sprite's last one, where a 16 pixel sprite is a
single texel (with the default Mipmap Levels of 4) and the sampler clamps
any higher level to it. All eight taps, four at each of the two levels,
then read that same texel, whatever their position under the clamped
sampler, and their mean is that texel.

The globals now carry that level (filtering.z): the views' last mip level
when a sprite is one texel there, else 0, which turns the shortcut off (a
lower Mipmap Levels setting, or anything that leaves the field unset). At
or past it RGSS takes one tap; one level below it, the upper level's four
taps become one. The mirror and crosshair groups keep the full chain the
main group then also has, since the level is only set when the two agree.
The result differs from the eight-tap mean only in how float rounding
falls when summing four equal values, a last-bit difference far below
one step of the frame's colour.
2026-11-03 16:26:58 +01:00
Kacpherek d693267be8 RGSS samples only the side of its blend that has weight
sampleRGSS mixes sample_nearest's one tap with the rotated grid's eight by
how far the texels have shrunk below a screen pixel, and every terrain
pixel paid for both. That weight is 0 wherever a texel covers a screen
pixel or more (all the nearby terrain) and 1 wherever it covers half a
pixel or less (everything from a few dozen blocks out, the distant tiles
included), so most pixels sampled nine times to keep one or eight of them.

Now a weight of 0 returns sample_nearest alone and a weight of 1 the
rotated grid alone; only the band in between still mixes the two.
mix(a, b, 0) is a exactly, so nearby terrain is bit for bit the same. At
a weight of 1 the mix's a + (b - a) can differ from b in the last bit of
a float, far below one step of the frame's colour. The taps that remain
use explicit levels or gradients, so they are fine inside the branch.
2026-11-03 16:26:58 +01:00
Kacpherek b0fa89401d Capture a frame's scene and replay it offscreen for renderer timing
horizon-ctl render-capture on keeps CPU copies of the section and far tile
meshes the renderer receives (HORIZON_RENDER_CAPTURE=1 from the start);
render-capture DIR writes them with the next frame's camera, sky, fog and
lightmap inputs and the settings that change GPU work. The scene_replay
example loads a capture into the real Renderer offscreen and prints GPU,
render-call and per-pass times as JSON, with overrides for A/B toggles.
2026-11-03 16:26:58 +01:00
Kacpherek 1a004befca Look NBT compound keys up without allocating them
Tag::get and get_mut turned the &str key into a new JavaString (a
Vec<u16>) on every call. Far terrain reads saved chunks' palettes and
sections through them, and in a flight over visited ground the key
allocations and their UTF-16 encoding were the biggest part of that
work. The key is now encoded into a buffer kept per thread; the
compound is searched the same way, with the same result.
2026-11-03 16:26:50 +01:00
Kacpherek 5631f912fb Metal: occlusion culling's phase 2 splits its draws by pass too
Phase 1 already gives each solid pass only its own quads, but the sections
occlusion culling leaves to phase 2 still listed every quad once, and both
the opaque and the cutout pipeline drew that whole list, each clipping the
other's quads in the vertex shader. With culling on (render distance 32 on
Metal), every phase 2 vertex ran the vertex shader twice.

Phase 2 now builds its list as phase 1 does: every section's opaque runs,
then its cutout runs (a fading section's quads all in the cutout part),
grouped so the opaque ones come first. A section keeps one occlusion test,
which now names a range of draws in each pass and turns both on when it
sees the box. The indirect path and the flat path (a call break where
phase 2's cutout pass begins) draw only each pass's own range.

The vertex shader still leaves out the other pass's quads: meshes that were
not sorted by side give both passes all of their quads.
2026-11-03 16:26:50 +01:00
Kacpherek e979fbc2ad Skip the solid terrain's HDR lighting without HDR output
Every opaque terrain pixel worked out a face normal from derivatives, the
scene's HDR light and the fog a second time, only to write it to the
frame's alpha, which nothing but HDR output reads. hdr_light.z now says
whether HDR output is on, and without it the shader leaves the alpha at
1 and skips that work. The colour is unchanged.
2026-11-03 16:26:41 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d8e6f2b354 Add ไทย (th_th) game translation
Bundles Horizon's own ไทย strings so th_th players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:22:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a463de44f4 Add Tiếng Việt (vi_vn) game translation
Bundles Horizon's own Tiếng Việt strings so vi_vn players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:22:22 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3ef168d9af Add Dutch (nl_nl) game translation
Bundles Horizon's own Dutch strings so nl_nl players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:21:36 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 c6e3709517 List the bed explosion parity harness in the game-home test's exemptions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:16:21 -05:00
CocofireHDandDeepSeek V4.1 Flash ab49fe48b7 cargo-horizon-mod: prebuilt binaries for cargo-binstall
`cargo install --git ...` compiles the tool and its workspace, which for a
mod developer on a slow machine is a long wait to write one block (#2120).
`cargo binstall` skips the build when the release holds a binary for the
platform.

- crates/cargo-horizon-mod/Cargo.toml gains [package.metadata.binstall]:
  one `cargo-horizon-mod-v<version>` release, one tar.gz per Rust target,
  the binary at the archive's root; a `.sha256` beside each.
- .github/workflows/cargo-horizon-mod.yml builds those targets (Linux
  x86_64 and aarch64, Windows x86_64, macOS x86_64 and arm64), starts
  each binary it can run, and publishes them on the tag. A pull request
  builds them without publishing.
- tools/horizon_mod_binstall.py is the one list of targets: the workflow's
  matrix comes from it, and it checks the built archives and the tag
  against the manifest before anything is published.
- docs/mods.md shows the binstall command first, the source build after.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-03 16:16:07 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 f960294f84 Add the held weapon's attack damage to a mob's melee hit
Mob.doHurtTarget reads the attack_damage attribute, which in vanilla
includes the main-hand item's attack_damage modifier. do_hurt_target used
only the mob's own value, so a vindicator with an iron axe hit for 5
instead of 13 on Normal, and every mob that attacks with a held weapon
(zombie with a sword, wither skeleton, piglin brute, drowned with a
trident) was short by the item's modifier before the difficulty scaling.

Adds gear::attack_damage and a table-driven test over nine mob and
weapon pairs, plus a test of the Hard/Normal/Easy scaling of a mob hit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:15:38 -05:00
MSedgeMC e4504910c0 fix: a campfire's lava loop asks the random for its bound again
Vanilla's `for (int i = 0; i < random.nextInt(1) + 1; i++)` draws the
bound on the check that fails too; Horizon drew it once before the loop,
so every lava event left the level random a draw ahead.
2026-11-03 15:13:51 +00:00
MSedgeMC 33ac800df5 test: the campfire block's crackle and its lava particles
The animateTick loop the suite drives ran over barriers only, so
CampfireBlock.animateTick - the crackle sound, its volume and pitch, and
the lava particle a lit campfire spawns - had no case. Run the whole loop
over a world that is all campfire, with the case's seed driving both the
sampled positions and the block's draws, and compare every sound and
particle as exact bits, then the random's next draw.
2026-11-03 15:13:51 +00:00
MSedgeMC 55e2ab1c3d port_map: the vibration rows point at the provider arm
`VibrationSignalParticle.Provider.createParticle` is `ParticleEngine::make`
now that the arm builds the signal from the option's destination, and
`ParticleEngine.makeParticle` no longer has to admit that vibration options
return nothing.
2026-11-03 15:11:18 +00:00
MSedgeMC ae1168091f test: a vibration option makes the signal its provider draws
`a_vibration_option_makes_the_signal_its_provider_draws` goes through
`ParticleEngine::add_with`, the entry the client's packet handler reaches,
with the destination and arrival time a `VibrationParticleOption` carries.
On the previous commit `make` returns nothing for the type, so the engine
records no vibration particle at all (`left: None, right: Some(1)`); here
the particle has lifetime 20, size 0.3 and no physics, its behaviour holds
the listener, and its initial yaw and pitch are the ones the vanilla
constructor computes. A bare `ParticleOptions::simple(Vibration)` still
makes nothing: only the option names a listener.
2026-11-03 15:11:18 +00:00
MSedgeMC 4e4542f23f fix: the provider path makes the vibration signal a packet carries
`ParticleEngine::make` answered `T::Vibration => return None`, so the only
vibration Horizon ever drew were the ones its own vibration system spawned
in-process through `ParticleEngine::vibration`. A remote server's
`ClientboundLevelParticlesPacket` with a `VibrationParticleOption` was
dropped twice: `Game::received_particle` fell into `Some(_) => return None`,
and the provider had no arm to build it from.

`ParticleOptions` now carries the option's `vibration` destination and
arrival ticks, built by `ParticleOptions::vibration(dest, ticks)`. `make`
builds the particle from it through `vibration_particle`, the body
`vibration` used, and takes the sprite set's own draw from the engine's
random, which is the `sprite.get(random)` the vanilla provider makes.

`received_particle` resolves the option's `PositionSource` in
`vibration_destination`: a block's centre, or the entity's feet raised by
the option's offset. An entity this client has no position for is no
destination and makes no particle, as vanilla's first tick removes the one
it made.

The destination is resolved once, where vanilla's `VibrationSignalParticle`
asks its position source again every tick, so a signal does not yet chase a
moving warden or allay: the particle engine has no entity lookup to give it.
2026-11-03 15:11:18 +00:00
MSedgeMC 0250ed09b6 Set the sulfur cube's body, not its head, in the two tests that hold a block
sulfur_cube::has_body reads extra.body, so a test that sets extra.head only
passes while the prepared resources are absent and the test skips. Both now
set the field the code reads.
2026-11-03 15:08:47 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ce75f3f3fd Add Italian (it_it) game translation
Bundles Horizon's own Italian strings so it_it players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 16:08:44 +01:00
Ludovic Marie 2f0c80826c Merge pull request #8267 from horizon-rust/feat/launcher-glass-servers
🤖 Agent: Servers page in the mockup's grid; the lower tabs still draw their old rows inside a glass plate until the next PR restyles them.
2026-11-03 16:02:54 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 45899355d6 Bed explosion parity harness: fire resistance and no regeneration, so health is exact
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:00:31 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 a700c4d819 horizon-ctl --api: refuse a game on another control protocol line (#8242)
`horizon-ctl --api 0.1` asks the game `hello` before its first command and
refuses one whose protocol is on another line, or one with no hello at all
(exit status 5, kind "api"). `0.1` takes 0.1.x. Docs: the "version that is
checked" gap leaves "Not there yet", changelog and stability table updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:59:28 -05:00
MSedgeMC 47c09e613f test: a bypassing hit leaves the armor instances dirty
`health::tests::a_bypassing_hit_leaves_the_armor_instances_dirty`: with base 7
armour and toughness, a `minecraft:fall` hit must leave both instances dirty
with creation's zero cache and report 0 in the hurt record, while a
`minecraft:mob_attack` hit (not in `#minecraft:bypasses_armor`) must recompute
both to 7 and report that.

On upstream/main the fall hit leaves `(base 7, dirty false, cached 7)`, which
the test prints as `left: [(7.0, false, 7.0), (7.0, false, 7.0)]`.
2026-11-03 14:55:40 +00:00
MSedgeMC 94a62a9d93 fix: a bypassing hit leaves the armor attribute instances dirty
`PlayerMotion::hurt_caused_by` read `minecraft:armor` and
`minecraft:armor_toughness` before the hit, ahead of the damage path's own
`#minecraft:bypasses_armor` gate. Native `AttributeInstance.getValue` is what
recomputes a dirty instance and clears its flag, so a bypassing source (fall,
stalagmite, magic, drown) cleared flags vanilla keeps, and the scene snapshot
compares both the cache and the dirty bit.

`LivingEntity.getDamageAfterArmorAbsorb` is the only reader of these two
instances on a damage path; it returns before `getArmorValue` and
`getAttributeValue(ARMOR_TOUGHNESS)` for a source in the tag, and a hit that
does meet the armour has already recomputed both in `after_armor`. The hurt
record now takes the caches the hit left, through `LiveAttributes::cached_value`
which never recomputes, so the record stays the value vanilla's damage path
read (after `hurtArmor`) and a bypassing hit leaves both instances as their
creation left them.

Found by `tools/test_native_scene_motion.py` on upstream/main: it failed at
output line 2245308, vanilla `minecraft:armor:...:true:...` against Horizon's
`...:false:...`.
2026-11-03 14:55:40 +00:00
Ludovic Marie e4aaced974 Merge pull request #8261 from horizon-rust/fix/launcher-mixins-rev-e8cd0aa
🤖 Agent: MIXINS_REV ddd9700 → e8cd0aa (horizon-mixins#25, the only commit between them): native mods build again with horizon-client's #![recursion_limit]; launcher fmt/clippy/538 tests pass.
2026-11-03 15:51:15 +01:00
MSedgeMC a7539e34d8 port_map: the client chunk cache and explosion rows name live fns
`Streaming::forget` is `Streaming::forget_columns` (the unload packet path takes
the whole set of columns at once), and `ClientExplosionTracker.addParticle` is
not a fn of its own: it is the body of `tick_explosions`'s per-debris loop, so
the row is `inline` at `ParticleEngine::tick_explosions_with`.
2026-11-03 14:49:51 +00:00
MSedgeMC 98f1c5cd5a test: pin the blocks that take a worldgen loot table
`loot_table_nbt` against a registry of the blocks in question: a chest, a
dispenser, a hopper, a shulker box, a coloured shulker box, a crafter and a
decorated pot each get the table and the first long of the random, and a
furnace, an ender chest and air get nothing and leave the random untouched.
2026-11-03 14:49:07 +00:00
MSedgeMC 2fa16379af fix: any randomizable container takes a worldgen loot table
`RandomizableContainer.setBlockEntityLootTable` asks for the block entity at the
position and hands the table to it when it is a `RandomizableContainer`: every
`RandomizableContainerBlockEntity` -- chest, trapped chest, barrel, dispenser,
dropper, hopper, shulker box and the crafter -- and the decorated pot, whose
block entity implements the interface on a plain `BlockEntity`.

`set_block_entity_loot_table` knew only the three blocks its callers place
today. Both of them (the dungeon chest in `cave.rs` and the bonus chest in
`bonus_chest.rs`) place a chest, so this changes nothing a player can reach: the
wider set is defensive, matching the vanilla interface rather than the two calls
that reach it. The NBT the container takes moves into `loot_table_nbt`, next to
the set, so the decision and the seed draw it guards can be read in one place.
2026-11-03 14:49:07 +00:00
Ludovic Marie a217a255d1 Merge pull request #8256 from horizon-rust/feat/launcher-glass-controls
🤖 Agent: dark-only restyle of the shared controls; the light screenshot is pixel-identical to main, so the risk is limited to the dark look of pages not yet rebuilt.
2026-11-03 15:47:56 +01:00
SenjinandClaude Opus 5.5 36e330d36c Shader packs: time compute passes and mip chains
The pass timer (F3, HORIZON_SHADER_PACK_TIMING) left out a pack's
compute dispatches and the mip chains rebuilt before the programs that
ask for them, so their cost never showed. Compute passes now take
timestamps as render passes do, and each mip chain is timed by its first
level, labelled with its buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:45:51 +01:00
JoansitohandClaude Sonnet 5.5 abc4edbf15 Remote arrows emit water bubbles and crit trails (#8265)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 15:45:30 +01:00
MSedgeMC 83080f6bc6 test: pin a dispenser's loot table to the placement
`PieceFrame::create_dispenser` gets two cases with a piece-world double that
records the blocks and block entities written: over air it must write the
dispenser and one block entity carrying the table and a seed, and over a block
the piece keeps it must write neither while still reporting the placement.
2026-11-03 14:43:42 +00:00
MSedgeMC 44c09adb15 fix: a declined dispenser placement writes no loot table
`StructurePiece.createDispenser` places the dispenser and then reads the block
entity back: `level.getBlockEntity(pos) instanceof DispenserBlockEntity` before
`setLootTable(table, random.nextLong())`. `create_dispenser` wrote the tag
unconditionally, so a placement the piece declines -- a block it keeps, or a
position outside its box -- still left a loot-table compound behind and still
drew the seed, which every later draw the piece makes then sees.

`PieceFrame::place` now has the `StructurePiece.placeBlock` half that answers
whether the block was written (`place_block`; `place` keeps its signature for
the 137 callers that do not care), and `create_dispenser` sets the table only
when the dispenser is there.
2026-11-03 14:43:42 +00:00
JoansitohandClaude Sonnet 5.5 1622c53e2c Drop unused lore setup from disc fragment test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 15:42:46 +01:00
JoansitohandClaude Sonnet 5.5 3c9a5524cc Show the Music Disc - 5 line on disc fragments
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 15:40:00 +01:00
MSedgeMC 991c33e49f port_map: a primed cart's blast rows point at the rail sparing
`MinecartTNT.getBlockExplosionResistance` and `MinecartTNT.shouldBlockExplode` both still
said `missing`. Both are in the tree: while a TNT cart is primed, its blast leaves a rail —
and a block with a rail above it — with no blast resistance and out of the destroyed set, in
`crates/horizon-client/src/game/explosion.rs::Game::exploded_positions`. The flag behind it
is `combat.rail_proof_blast`, set from the cart's fuse in `minecarts.rs` and carried through
a nested blast, and the case is covered by the command corpus group `tnt minecart rails`.

Nothing to fix, so no code: the rows now name the function that decides them.
2026-11-03 14:37:52 +00:00
Ludovic MarieandClaude Opus 5.5 b1cd4d04a7 Launcher: build native mods with horizon-mixins e8cd0aa
horizon-mixins#25 accepts the compiler's crate-level attributes (`#![recursion_limit]`
and the like) and the known tool attributes on a module. Since horizon-client's lib.rs
gained `#![recursion_limit = "256"]`, every native mod failed at ddd9700 with
E_UNSUPPORTED_MACRO (#8169). e8cd0aa is that merge and the only commit since ddd9700.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:37:11 +01:00
MSedgeMC 5fe6c42ef8 port_map: the food-or-flying gate is the sprint condition, not a gap
`Player.hasEnoughFoodToDoExhaustiveManoeuvres` is
`foodData.hasEnoughFood() || abilities.mayfly`, and the one place vanilla asks
it is `LocalPlayer.canStartSprinting`. Horizon has that exact test as the
sprint gate's `enough_food || may_fly`, with `enough_food` filled from
`FoodData::has_enough_food` (a food level above 6) in `motion.rs`, and
`player_food::enough_food_for_manoeuvres` is the same predicate as a free
function the food probe already compares against the jar.

The row said `missing`; it now points at the gate that decides it.
2026-11-03 14:36:50 +00:00
TeiwazikandClaude Opus 5.5 c7fbdc6008 Shader packs: [displacement] draws relief as geometry near the camera
Each frame the solid quads of the sections within [displacement]
distance are copied out of the arena, a compute pass picks the ones with
LabPBR relief that face the eye and are in view into an indirect draw,
and the material's displaced pipeline draws each as a grid of cells
moved along the face's normal by the pack's displacement, from the
texture's mean height so that face edges meet. The flat quads leave
those pixels to the grid. The height maps are visible to vertex shaders
now. The shadow map still takes the flat faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 15:36:15 +01:00
CocofireHDandDeepSeek V4.1 Flash e67541686c launcher: keep the launcher's renderer workarounds out of the game
A player who starts the launcher with LIBGL_ALWAYS_SOFTWARE=1 to get a
visible window (transparent window on the radeon driver) had the game
inherit the same variables: horizon-client then rendered on the CPU at
2-3 FPS. The game picks its own backend, so strip those four variables
from the child environment and leave the rest alone.

Fixes #7832

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-03 15:35:01 +01:00
MSedgeMC 5324b1bf4a test: pin an ocean ruin's drowned marker to the piece world's sea level
`ocean_ruin::post_process` now has a test with a piece-world double: a solid
seabed at 77, a sea level the test chooses, and a log of every `set`. The same
piece is post-processed with the sea level at 90, at the marker's own height
and one below it, so the water/air decision has to follow the world and not a
constant.
2026-11-03 14:33:00 +00:00
MSedgeMC abb96b6834 fix: an ocean ruin's drowned marker floods at the world's sea level
`OceanRuinPieces.OceanRuinPiece.postProcess` picks the block a `drowned`
marker leaves by comparing its height with `level.getSeaLevel()`, and
`post_process` compared it with a hardcoded 63. In a world whose sea level is
not 63 -- a datapack dimension, a superflat with its own sea level -- every
marker above 63 is left dry and every marker below it is flooded, whichever
side of the real sea level it is on.

`PieceContext` already carries the world's answer (`PieceWorld::sea_level`,
which `blocks()` and `mineshaft::generate` use), so the piece asks it.
2026-11-03 14:33:00 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 b59777f00b Hosting: a Pterodactyl egg for horizon-server
Adds packaging/server/pterodactyl-egg.json and panel-start.sh, a docs/hosting.md section,
and tests/test_server_panels.py. Part of #8238.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:23:32 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 4c0697a727 Derive the launcher's mod-browser game version from the pinned version
browser::GAME was a literal "26.3" that no generator touched, so a version port
left the mod browser filtering for the old release. It now reads
horizon_core::version::VERSION_ID, which tools/gen_version_consts.py regenerates.
A test checks the two agree.

Part of #4758

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:21:12 -05:00
MSedgeMC 8b08a67b75 port_map: AccessibilityOptionsScreen.init keeps only the High Contrast gap
Rotate with Minecarts is greyed out now, so the row names the one half
that is still missing: High Contrast stays clickable when the
high_contrast pack is unavailable and gets no error tooltip.
2026-11-03 14:20:34 +00:00
MSedgeMC d1de8739df port_map: the accessibility footer link and the minecart grey-out are ported
`AccessibilityOptionsScreen.addFooter` builds the Accessibility Help link
button beside Done, and `Act::AccessibilityHelp` confirms
`CommonLinks.ACCESSIBILITY_HELP` over the options screen, the
`ConfirmLinkScreen.confirmLink` button vanilla adds there.

`AccessibilityOptionsScreen.isMinecartOptionEnabled` greys Rotate with
Minecarts out unless a world is open whose feature flags carry
minecart_improvements, read from the level or the remote server.
2026-11-03 14:20:34 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 22769e76c9 Format and lint fixes for the join-order tick
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:14:45 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 08a168094a horizon-server: restore a single dimension from a backup
`restore NAME --dimension minecraft:the_nether` (API: `dimension` in the POST /api/v1/restore body; MCP: `dimension`) brings back only that dimension's folder and leaves the other dimensions, level.dat and the players alone. The folder it replaces goes to backups/replaced-<time>/.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:08:56 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 732265ffad horizon-server: import-resources DIR copies prepared game files into place (#8240)
Checks the folder (version stamp, derived files and checksums, asset folders), copies it through a temp folder and an atomic rename, keeps the old copy on any failure, and prints the next step.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:07:58 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 80643ef336 install.sh: verify the release signature when OpenSSL 3 is installed
Checks SHA256SUMS.sig against the release key, then the archive against
SHA256SUMS; older OpenSSL, LibreSSL or none says so and keeps the GitHub
SHA-256 check. Closes #8241.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:02:35 -05:00
Med.cssandCodex a668f9fb30 Declare prepared resources required by the mount regression test
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-03 14:59:09 +01:00
TiggerTakkyTTTandCodex 60000e7e9c Server admin: edit player lists while game is stopped
Keep the supervisor and control API alive after a game-issued stop, add an API start endpoint, and route file-backed operator and whitelist edits through the existing vanilla list handling. Cover the stopped lifecycle in unit and end-to-end tests.

Fixes #8232

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 20:57:08 +07:00
HrafnaValkyrjaandClaude Sonnet 5.5 19db9ef223 Dragon breath cloud: vanilla lifetime, wait-time radius growth, per-entity hit cadence
Part of #6771.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:47:26 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 6483aa3498 Send the explosion push to a hurt player too, as ServerExplosion.hitPlayers does (untested)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:47:24 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 79ea3cec34 Route every mob attribute command to the store the mob reads (#7877)
/attribute on a mob now handles base, modifier add, remove and value get for
every attribute the mob's gameplay reads from finalize, not only max health,
movement speed and attack damage. Modifier ids typed at run time are kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:46:44 -05:00
oxi-does-dev 7a6c1518fb the tooltip test counts the clicked frame against its own pointer state
Whole-frame equality kept meeting offsets that were no tooltip: the
click cycles the extra (one glyph of a value label), the key-bound rows
swap in their capture markers, and the focus sprite is drawn for hover
anyway. Instead the same drawn UI counts the frame with the pointer on
the clicked widget — description at the cursor — against the one after
it moved off: with the fix fewer vertices; while the old draw kept the
description, the anchored tooltip made the two counts equal.
2026-11-03 16:43:59 +03:00
itarqos5andClaude Opus 5 7402b17949 perf(startup): parse the model library beside the registry, facts from a slice
Two changes to the head of the core bundle, which every other table waits
on.

The model library reads every file under `assets/` and needs no block
registry, but it sat inside `Assets::reload`, behind both the registry read
and the block facts. It now runs beside `Assets::load` instead, so its
reading overlaps the registry's rather than following it. A server draws
nothing and parses no models, so it skips this and is unaffected.

`facts.json` is about 10 MB and is parsed before anything else in the
bundle can begin. `serde_json` cannot borrow out of a reader, so
`from_reader` copies every string it parses; over one slice it borrows.
`BlockRegistry::from_facts_json_bytes` reads the file whole and parses from
that, and `from_facts_json_with_flags` keeps its reader for callers that
have one.

Steps are added inside the model and sprite work (`core.models_parse`,
`core.models_bake`, `core.bake_states`, `core.bake_standalone`,
`core.item_library`, `core.fusion`), which is what the above was found
with: before them the whole 2.5 s of a cold `core.block_models_sprites` was
one opaque span.

Measured on 12 threads, five warm runs each, the cold first run excluded:

    core, median    448 ms before    404 ms after    -10%

Of that, `core.assets` falls from 106 ms to 73 ms and the model parse
(65 ms) moves off the critical path entirely.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 13:43:18 +00:00
itarqos5andClaude Opus 5 893fc9ae96 perf(lod): give the far-terrain sampler two thirds of the cores
Sampling is the cold-load bottleneck and already had half the cores rather
than a quarter. On a 12-thread machine that is six samplers beside five
meshers, and the mesher has little to do until tiles are sampled, so its
threads sit idle through the part of a cold build that waits on sampling.

Measured on 12 threads, building 4336 tiles at LOD distance 256, from the
game answering the control socket until every target tile is sampled, four
runs a configuration:

    samplers/meshers   sampling   whole build
    6/5 (before)         15.0 s        23.9 s
    8/3                  11.8 s        18.6 s
    9/2                  10.6 s        19.1 s

Two thirds, so eight here. Nine finishes sampling sooner still but leaves
the mesher two threads and the whole build ends later. The frame and the
ticks keep their core at every size, which the test now checks directly
rather than only through the expected numbers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 13:40:32 +00:00
Med.cssandCodex 649d0c580a Share powder snow particles across block-affected entities
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-03 14:37:52 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 4efd21aef2 Shader packs: convert struct constructor arguments to the member types
Same missing conversion as array constructors: S(1u, 2) with an int and a
float member composed unconverted and failed naga validation.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:34:59 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 b6a8a45460 Format array_constructors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:33:49 -05:00
JoansitohandClaude Sonnet 5.5 167ebfc2af Fire Aspect ignites the player it hits in PvP (#8225)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:32:12 +01:00
JoansitohandClaude Sonnet 5.5 01b9a33298 Fix clippy useless_vec in spin target test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:30:04 +01:00
TiggerTakkyTTT 87c075391b Expose online player positions in the admin API
Include each dedicated player's current coordinates in server-status so the server-admin HTTP API and MCP players tool can report them. Update the generated API reference and server documentation, with focused serialization coverage.\n\nFixes #8224\n\nCo-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 20:29:11 +07:00
JoansitohandClaude Sonnet 5.5 b549f9555a Riptide spin hits, bounces off and ends on the first mob it touches (#8223)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:28:35 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 9d7d671e0b Shader packs: convert array constructor arguments to the element type
Drivers accept int[2](1u, 2); naga composed the arguments unconverted and its
validator refused the program (Composing 0's component type is not expected).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:27:20 -05:00
AkirabaneandClaude Sonnet 5.5 b26ee56a70 Fonts: draw a glyph whose advance is fishy, as vanilla does
#7550 skipped a provider glyph whose advance leaves 0..32 in Font::resolve
(drawing) and provider_advance (the by-width table), so every glyph of a
server pack's `space` provider (kerning, -512 to 512) or of a tall bitmap
(Nexo's, wider than 31 px) stopped drawing.

Vanilla's FontSet.computeGlyphInfo draws the first provider's glyph
(SelectedGlyphs.any) whatever its advance, and only the glyph obfuscated
text draws (SelectedGlyphs.nonFishy, from getRandomGlyph) moves on to the
first provider with a good advance, else the missing glyph. FontSet.reload
files each character in glyphsByWidth by its first glyph's advance, fishy
or not.

Font::resolve now skips a fishy glyph only for nonFishy
(Font::glyph_non_fishy, cached apart), provider_advance takes the first
glyph's advance, and the table is keyed by i32 so a negative advance does
not land in the 0 bucket. #7550's test claimed font.glyph('a').advance is 3
for a first glyph of 65, contrary to vanilla; it now covers what vanilla
does, and the fall-through #7550 wanted stays for obfuscated text.

Fixes #8222

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:25:44 +01:00
oxi-does-dev 8c67fd63dd the tooltip test's comparison UI keeps the click's extra value
The fresh comparison UI drew the same already-cycled settings; cycling
again gave it the next value and the label widths drifted for text, not
tooltips (verify: 34896 vs 34926).
2026-11-03 16:16:19 +03:00
CocofireHDandHermes 3764b7d9d6 Protocol: pin the SetTime clock key to the registry id
A packet captured from a vanilla 26.3 server at join carries two clocks:
`Long 13071`, `VarInt 2`, then `(0, 66678, 0.0, 1.0)` and
`(1, 13071, 0.0, 1.0)`. The key is therefore a plain registry id —
`WorldClock.STREAM_CODEC` is `ByteBufCodecs.holderRegistry`, not `holder`,
so there is no inline form and no +1. Reading it as a holder id shifts the
first key to "inline" and the whole packet stops decoding.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 14:16:13 +01:00
TiggerTakkyTTT 0932e968bd Fix integrated world shutdown worker cleanup
Abort the client-side streaming workers when a world close or native application termination starts. The integrated server already stops its own workers, but the window-side registration otherwise waits during drop after extended sessions.\n\nFixes #8178\n\nCo-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 20:10:55 +07:00
HrafnaValkyrjaandClaude Sonnet 5.5 9e9b277609 Ticks of fresh TNT and falling blocks wait for their spawn packets
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:09:38 -05:00
Med.cssandCodex 6bd36e517e Add powder snow particles for minecarts and mobs
Match vanilla's movement gate, random order, and snowflake velocity for minecarts, living mobs, and controlled mounts. Relay the same particle parameters to LAN clients.

Co-Authored-By: Codex (GPT-6 Luna) <[email protected]>
2026-11-03 14:07:52 +01:00
oxi-does-dev 3a19fe597a the tooltip test compares the same extra value
Clicking an extras button cycles the extra, so the clicked frame's labels
differed from the fresh one and the vertex counts drifted for text, not
tooltips (verify: 34884 vs 34878). The comparison UI now cycles the same
extra directly, leaving the click's focus the only difference; a tooltip
would still add its vertices.

The clicked widget is also picked among the non-key extras, whose click
selects a key instead of cycling.
2026-11-03 15:58:27 +03:00
HrafnaValkyrjaandClaude Sonnet 5.5 78499843d3 WIP: tick orbs, TNT and falling blocks in join order; wind push waits by join number
Not ready: anvil_turtle_egg and server_entities parity regress (see notes). Not pushed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 07:57:41 -05:00
rdbtCVSandClaude Opus 5.5 4f2d0fce4a Comments and docs: the network threads, not a writer thread per player
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:57:14 +00:00
rdbtCVSandClaude Opus 5.5 2a1cfe65bc A fixed pool of network threads instead of two per player
Every play connection had a reader and a writer thread (plus a post thread
waking the writers): 100 bots meant 200 threads fighting over the cores.
They now live on a fixed pool, one thread per two cores, each polling its
players' sockets through mio (epoll, kqueue, AFD). The game thread only
queues bodies in the player's outbox, bounded by MAX_PENDING bytes; framing,
compression, encryption, socket writes, reading, keep-alives and timeouts
run on the pool. The integrated server's own player goes through the same
threads: its in-memory pipe tells the thread when bytes arrive.

horizon-protocol: Connection::detach gives the stream up, ConnReader reads
from any source without blocking (read_from, buffered), ConnWriter frames
into a buffer (frame_into), and Pipe gains non-blocking reads and on_ready.

SharedFrames hashes a body once (XXH3-128 keyed by a random secret) where it
hashed it twice with SipHash, and a body being compressed is marked: a
second sender waits for that frame instead of compressing it again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:57:14 +00:00
AkirabaneandClaude Sonnet 5.5 7bac913ec3 Server: config set server.motd and server.max_players apply live
On a running server, `horizon-server config set` and PATCH /api/v1/config sent
only console commands, so the message of the day and the player limit were
always reported as "restart required", although the game takes both live
through the management settings (`serversettings/motd/set` and
`serversettings/max_players/set`).

`live_line` now returns the control line to send: the same management
request for these two settings, `console ...` for the ones vanilla changes
through its console, and nothing for the supervisor's own settings. The
CLI talks to the game's control endpoint the way the API already did, and
a reply with an error leaves the setting for the next start.

Fixes #8206

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:51:06 +01:00
JoansitohandClaude Sonnet 5.5 5639aee01e Scoreboard air criterion follows the player's real air supply
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:50:28 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 7cd36228aa Add a bed explosion parity harness (untested): four facings, three positions each, against vanilla
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 07:41:42 -05:00
oxi-does-dev d5c46f91bb a tooltip a mouse click left behind stays while the pointer is gone
A click leaves the clicked widget focused (focus_from_keyboard false),
and the draw loop drew the tooltip for hover OR focused. So after
clicking a button, walking the pointer away (or off the screen) left its
description up indefinitely: blocking the button — the report's
"description doesn't disappear", over the label that does scroll.

Vanilla draws the tooltip while the widget is hovered, or against the
widget while the focus came from the keyboard or a controller
(MenuTooltipPositioner's focus tooltip); a mouse-only focus does not.

Draw it for the pointer, or for keyboard focus only: the description
follows the pointer back out.
2026-11-03 15:35:48 +03:00
CocofireHDandHermes 3e13c1a8cb Player skins: evict the least recently used 3D skin model
The cache cleared all 512 entries when it filled, so a server with more
distinct skins than the cap would rebuild every model on screen in a single
frame. `game/skin_models.rs` keeps a use counter per entry and an insert
evicts exactly one least recently used model; the cap and the
(512 entries) behaviour are otherwise unchanged.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 13:35:20 +01:00
TiggerTakkyTTTandCodex fa310f522d Use free status replies parity ids
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:34:46 +07:00
AkirabaneandClaude Opus 5.5 ba3cd22c7c Draw no world entity over the title's panorama
The GPU path for entities (skin_frame, since #7801) starts each frame's draws
in begin_skinning, which only entity_geometry runs. render skips
entity_geometry when the frame shows the panorama (the title screen, a
server's loading screen), so the draws stayed those of the last world frame,
and the renderer drew them again every frame: after Save and Quit to Title,
the mobs that were in view stood over the panorama. The panorama's frames now
clear those draws; the models the renderer stores stay, so the next world
draws its entities as before.

Fixes #8192

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:29:51 +01:00
AkirabaneandClaude Sonnet 5.5 fa22f0e6ea Renderer: keep every pipeline within 15 inter-stage variables, and test it
The entity shader's vertex output had 15 user-defined locations, and the
fragment stage's front_facing counts as one more: 16 against the 15 an
OpenGL 3.3 GPU (a GeForce 9100M G) allows, so 'Breaking cracks' (and every
entity-shader pipeline) failed to build at start-up.

The spherical and cylindrical fog distances now share one vec2 location
(fog_distance); each component is the same expression, interpolated on its
own, so the output is unchanged. A naga test audits the vertex outputs and
fragment inputs of every assembled shader and every standalone .wgsl file
against that limit, front_facing / sample_index / sample_mask /
primitive_index included, and fails for a .wgsl file it cannot place.

Fixes #8195

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:29:26 +01:00
AkirabaneandClaude Opus 5.5 4cbd817683 Parity harnesses: name sources under a linked .local within the repository
source_hashes named each tested file by resolving it, then taking it relative
to the repository. In a worktree whose `.local` is a link to another
checkout's (a junction on Windows, as the contributor docs suggest for the
prepared resources), resolving leaves the repository, and every harness that
hashes its inputs under `.local` stopped with "is not in the subpath of".

repo_path tries the path's absolute spelling first, which stays under the
link, and the resolved one after, so a repository reached through a link
(macOS's /var) is still found. The test makes a `.local` link (a symlink, or a
junction where symlinks need a privilege) and fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:29:12 +01:00
TiggerTakkyTTTandCodex a4f76cc3c2 Move status replies parity ids
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:26:44 +07:00
TeiwazikandClaude Opus 5.5 eb58c6bb30 Shader API: [displacement], relief as geometry near the camera
The manifest's [displacement] (distance 4..32 blocks, cells 4..64 a
side, and the options that turn it on, pick the cell count and make
cells steps), the material stage's
fn displacement(input: DisplacementInput) -> f32, checked with its
signature and composed with an entry point that calls it, and
MaterialInput.displaced. The renderer passes 0 for now; the grid that
draws the relief comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 13:24:44 +01:00
AkirabaneandClaude Opus 5.5 33dd3825e1 Shader packs: 1D images are made and bound as 1D textures
A pack's 1D image is now a D1 texture with a D1 view, sized against the
device's 1D limit; `image1D`/`uimage1D` bindings and their stand-ins are D1,
and its `sampler1D`/`usampler1D` reads it through the image's own dimension.

Fixes #8177

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:21:43 +01:00
AkirabaneandClaude Opus 5.5 80f0a25624 Shader packs: an image.* with one size is a 1D image
An Iris-format `image.<name>` gives its size in pixels after `relative`: two
for a 2D image, three for a 3D one, and one for a 1D image (a table read by
index, such as a per-block lookup a setup program fills). CustomImage::parse
refused the one-size form, so the image was "not an image" and its sampler and
`image1D` reads got nothing.

ImageSize::Line holds that width; `extent` is [width, 1, 1] and `is_1d` tells
the renderer. An `image1D` declaration without a format gets the image's, as
2D and 3D ones do.

Refs #8177

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:21:43 +01:00
TiggerTakkyTTTandCodex 76fdd95603 Merge upstream main into simulation distance management
Preserve simulation-distance parity alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:21:16 +07:00
TiggerTakkyTTTandCodex f823c66dae Merge upstream main into view distance management
Preserve view-distance parity alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:18:11 +07:00
rdbtCVSandCodex gpt-6.1-sol 78ef64dbb7 Use shared scratch environment for relocated benchmark
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 12:13:41 +00:00
rdbtCVSandCodex gpt-6.1-sol f41d7a6f43 Move standalone benchmark script into tools
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 12:12:26 +00:00
AkirabaneandClaude Sonnet 5.5 7aa91682b6 docs: say that a pack's post chain owns anti-aliasing
While a pack's post chain draws the frame, `taa`, `fxaa` and `upscaling` have no
effect and the projection is not jittered (gfx.rs turns TAA off under a pack
post or a native post, and the resolve that does TAA, FXAA and the upscale is
the step the pack's last pass replaces). `resolutionScale` still applies. The
reference said nothing, so pack authors assumed the settings still worked, and
a temporal pass of their own, which has no jitter to work with, did nothing.
Say it, and what a pack can do instead.

Refs #8173

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:12:04 +01:00
TiggerTakkyTTTandCodex 52c5740b56 Merge upstream main into spawn protection management
Preserve the spawn-protection parity path alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:08:09 +07:00
AkirabaneandClaude Opus 5.5 6e26955fd5 A gliding mob is not held by vines
LivingEntity.onClimbable answers false for an entity that isFallFlying in a
#minecraft:can_glide_through block (vines, twisting and weeping vines, cave
vines) before it looks at #minecraft:climbable or a trapdoor over a ladder.
Mob::on_climbable skipped that test. A ladder is not in the tag, so a glider
still holds on to it. Spider.onClimbable overrides the whole method and is
unchanged.

Refs #8140

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:05:23 +01:00
AkirabaneandClaude Opus 5.5 67b1d9fc4a server_knockback: put the half-shield case's zombie where Bob's shield always faces it
zombie-shield-partial gave vanilla itself two results from one script
(#8164): blocked in the 2026-11-03 audit (Bob -0.08191 0.31362), not blocked
in a local run (-0.21840 0.27516, as Horizon). The zombie stood one block east
of Bob, behind him while his position packets face him west, and at exactly 90
degrees once KnockbackClient's use packet turns him south
(ServerGamePacketListenerImpl.handleUseItem snaps the player to the packet's
rotation, 0 0). Which rotation counts at the tick of the hit is timing.

The zombie now stands south-west of Bob, facing him: 45 degrees from his view
either way, so the half-blocking shield blocks every run. No other case
changes.

Fixes #8164

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:00:56 +01:00
AkirabaneandClaude Sonnet 5.5 359d60f55f fix(shaders): a flat varying with centroid or sample is flat
GLSL (4.60, 4.5) lets `centroid` and `sample` stand beside `flat` and means
nothing by them, since a flat varying is not interpolated. naga validates the
pair and refuses it ("Cannot combine Flat interpolation with the Centroid
sample type"), so a pack that writes `flat centroid out vec2 uv;` or a block
member `flat sample vec2 uv;` lost the whole program. The declaration scanner
now keeps `flat` alone when it is there, in either order and on either side of
the storage qualifier.

Tests translate the varyings and the interface block members of #8174 through
the whole pipeline: 3 profiles, 7 qualifier combinations, both qualifier
orders, and 11 block member shapes. The block cases are the ones whose
"Expected Void, Struct or a type, found Interpolation(Flat)" #6875 fixed; they
had no test above the function that flattens a block. Before #6875 they fail
with exactly the message of the issue.

Refs #8174

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:59:03 +01:00
TeiwazikandClaude Opus 5.5 0829fdd82a Shader API: a pack may chain 12 post passes
Eight leave no room for ambient occlusion (a pass and its apply) and
TAA: reimagined_horizon chains 11, bloom (4), ambient occlusion (2),
screen-space reflections, light shafts (march and apply), TAA and the
final pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 12:51:08 +01:00
rdbtCVSandClaude Opus 5.5 34e8edaa45 Random ticks: ask for the next section's mask, not a list of all of them
A chunk's random ticks asked the section cache for every ticking section
and cloned each mask into a list, again after every block change, building
masks for sections the pass had already left. The cache now answers with
the next ticking section from a Y up, building and sharing only that mask;
an answer stands while no block changes and the asked Y stays below it.
Draws, positions and the ticks they reach are unchanged.

Fixes #7886

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 11:51:06 +00:00
janibert1andClaude Sonnet 5.5 13e3636ff2 horizon-server: whitelist-bypass.txt lets UUID prefixes (Floodgate) pass the whitelist
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:36:55 +01:00
janibert1andClaude Sonnet 5.5 00d32ff93a Native mods: NativeServerPlayer carries the player's entity id
Hooks that only know entity ids (living_incoming_damage, living_knockback) can now be
matched to players.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:36:35 +01:00
TiggerTakkyTTTandCodex 921d5c7a18 Move hide online players parity ids
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 18:33:38 +07:00
CocofireHDandHermes e2d38b13d5 World: a moving piston cell supports like the block arriving in it
`PistonBaseBlock.moveBlocks` writes `moving_piston` into the destination cells
and then updates the neighbours of every source cell. A cell that is both a
source and a destination of a chain push still holds `moving_piston` when that
update runs, so a rail on top of it was asked whether it could survive against
the bare `moving_piston`, found no rigid support and dropped as an item
(#8111).

`support_state` already stood in for a retracting piston base's block-entity
collision shape (#4728); any other moving cell now reads as `moved_state`,
which is what `PistonMovingBlockEntity.getCollisionShape` returns.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 12:12:52 +01:00
CocofireHDandHermes 6938bc45ef Protocol: read the SetTime clock key as a Holder<WorldClock>
`ClientboundSetTimePacket` keys its clocks with `WorldClock.STREAM_CODEC`,
which is `ByteBufCodecs.holderRegistry`: the wire form is `varint(registry id
+ 1)`, with 0 reserved for an inline definition. Read as a bare VarInt, a
vanilla server's overworld clock (1 on the wire) landed on the End's entry, so
the name lookup in the `C::SetTime` handler never matched
`minecraft:overworld` and `day_time` stayed where it was — `/time set day`
updated the game time but the client's sky and time never moved (#7542).

Horizon's own writers already pass 0-based registry ids (the LAN hub and the
1.21 adapter), so they now emit the holder form vanilla expects instead of a
bare 0, which vanilla would decode as an inline holder.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 12:12:06 +01:00
AkirabaneandClaude Opus 5.5 4b3c4f853a Test that a mob in vines slides down at 0.15 a tick
A cow let go at rest drops 0, 0.0784, then 0.155232 in open air; in a column
of vines every drop from the third tick on is 0.15F and its fall distance
never grows past one move.

Refs #8140

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:10:40 +01:00
AkirabaneandClaude Opus 5.5 c63db5f701 Slow every mob down in vines and on ladders, not only spiders
LivingEntity.handleRelativeFrictionAndCalculateMovement calls
handleOnClimbable for every living entity: when onClimbable() (the block it
stands in is #minecraft:climbable, or an open trapdoor over a ladder facing
the same way), the fall distance is reset and the motion is clamped to 0.15F
across and to -0.15F down before the move; after the move, a mob pressed
against a wall or jumping while still on a climbable goes up at 0.2. Only
Spider overrides onClimbable (isClimbing).

Mob::travel_in_air did this for spiders only, so a cow or a villager let go
in vines fell freely and took fall damage (a cow from 10 to 3 health over 10
blocks). The spider keeps its own test, and now clamps to the float 0.15F as
vanilla does instead of the double 0.15. The pusher's onClimbable in ai.rs
uses the same test.

Refs #8140

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:10:09 +01:00
TiggerTakkyTTTandCodex 37d0888489 Add live status replies management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 18:08:41 +07:00
AkirabaneandClaude Sonnet 5.5 add4366765 Boats: fix the rot_lerp test's 180-degree case
A 180-degree gap is ambiguous (Mth.wrapDegrees maps it to -180), so the test
now uses -90 to 80, whose short arc is unambiguous.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:08:20 +01:00
AkirabaneandClaude Sonnet 5.5 36eddf9710 docs: describe /tempban for server operators
Refs horizon-rust/horizon-mods#170

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:03:29 +01:00
AkirabaneandClaude Sonnet 5.5 8c10244e45 feat(server): /tempban, a ban that ends by itself
Vanilla only has /ban, which is permanent, so an operator who wants to
send a player away for an hour or a week has to remember to pardon them.
`/tempban <player> <duration> [reason]` (durations as /tempmute: 30m, 12h,
7d, 2w) writes a banned-players.json entry with `expires` set, as the chat
filter's automatic temporary ban already does, so the login check,
/banlist and /pardon treat it like any ban and it survives a restart.

Operators of level 3 (/ban's level) run it in game on dedicated servers;
the console takes the same words. A player who is online is kicked with
the reason and the time the ban ends, the message they read again when they
try to join (now one function, `ban_message`, shared with the login check).
A player whose ban is still in force is left as they are, as /ban does;
a ban that has ended no longer counts. It is logged as `[tempban]` and goes
to the event stream as a `ban` with `reason`, `by` and `until`. Like the
mute commands it is handled as raw words rather than in the vanilla tree,
so it has no completions.

Refs horizon-rust/horizon-mods#170

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:03:29 +01:00
AkirabaneandClaude Sonnet 5.5 b59836d46f Boats: ease a remote boat's yaw over three ticks, as AbstractBoat's LinearInterpolationHandler
A client that plays on a server (the window of a single-player world is
one) put a boat's yaw at once when a move packet came, every third tick,
while its position was eased over the three ticks. A boat pulled on a lead
turns every tick, so it turned in steps. AbstractBoat creates
LinearInterpolationHandler.create(this, 3): position and rotation both move
a 1/steps share of the way each tick (Mth.rotLerp, the short arc).

The yaw of the boat now follows the lerp's rotation target with the same
steps as its position. A driven boat and minecarts are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:03:03 +01:00
Arslan ArmanulyandClaude Opus 5.5 82e8967c10 Shader packs: clear both textures of a cleared buffer every frame
A cleared colortex buffer had only its "main" texture cleared at the start
of the frame. The main/alt mapping carries over frames, so with an odd
number of flips a frame the texture a program writes first was "main" the
frame before, and still held what a later program wrote after that frame's
clear: pixels the first write discards, or a blended first write (with
#8044), read the previous frame's data. Iris 1.11.7 reads the clear value
there (probed with our own packs: a discarding first write reads 0 where
Horizon read the previous frame's 0.25; a compute imageStore then a blended
write (with #8044) blends onto 0 where Horizon blended onto 0.25).

The frame-start clear now also clears the other texture of every cleared
buffer, grouped like the existing clears. Buffers with Clear=false keep
both textures.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:58:13 +05:00
arismoko 387d2d5855 Test non-finite teleport rotations 2026-11-03 10:57:15 +00:00
LokiandAntigravity 14f4b9d332 game/spawner: overflow-safe remote spawner distance scanning (fixes #7730)
In vanilla Java Edition 26.3 (SpawnerBlockEntity.clientTick and BaseSpawner.clientTick),
client ticking is only performed for authentic spawners, trial spawners and vaults.
Non-spawner block entities (including dummy container menus at i32::MIN) never reach
spawner distance calculation or client ticking.

In Horizon, Game::tick_remote_spawners previously iterated through all block entities
and performed signed coordinate subtraction (pos.0 - center[0]).abs(). When a dummy
container menu at (i32::MIN, 0, 0) was present, this panicked on signed integer
overflow in debug mode (i32::MIN - 1024 underflow or i32::MIN.abs() overflow).

Filter upfront for Spawner, TrialSpawner and Vault block entities in nearby_remote_spawner_keys
and compute Chebyshev distance using unsigned integer absolute difference (abs_diff)
to avoid any possibility of signed integer overflow at coordinate boundaries.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 19:55:45 +09:00
TiggerTakkyTTTandCodex 0fe2117572 Add hide online players management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 17:54:58 +07:00
arismoko 233e61d62f Guard non-finite teleport rotations 2026-11-03 10:38:20 +00:00
masa-masa-0314andCodex 8b758ff22e World: preserve drops when rebuilding the End platform
Destroy replaced platform blocks with drops before setting obsidian or air, matching EndPlatformFeature. This preserves sand and container contents on portal arrival. Fixes #8127.

Co-Authored-By: Codex <[email protected]>
2026-11-03 18:31:58 +08:00
TiggerTakkyTTTandCodex bb991d6f15 Merge upstream/main into operator permission level
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 17:29:46 +07:00
Loki a9f4573d1b Merge remote-tracking branch 'origin/main' into fix/8123-cycle-backwards 2026-11-03 19:23:02 +09:00
LokiandAntigravity 4f9fdf8bc7 render: cycle backwards through button options with shift (fixes #8123)
In vanilla Java Edition 26.3, CycleButton.onPress checks input.hasShiftDown()
and calls cycleValue(-1) when Shift is held down, cycling options backwards
with positive modulo wrapping.

In Horizon, OptionsUi.click previously ignored self.shift and passed right
(which was always false due to earlier early return), preventing cycling backwards
on Act::Cycle, Act::Extra, and in GraphicsUi::click.

Pass self.shift into cycle_option, item.cycle, and graphics.click, allowing
Shift-clicking to cycle options backwards with wrap-around across all options pages.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 19:22:19 +09:00
TiggerTakkyTTTandCodex 09496d8f2a Add operator permission level management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 17:21:41 +07:00
HrafnaValkyrjaandClaude Sonnet 5.5 3b7fe12fe2 WIP: tick orbs, primed TNT and falling blocks in join order between the other kinds
Not ready: the wind burst cells got worse (see the proof runs). Not pushed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 05:21:17 -05:00
rdbtCVSandClaude Opus 5.5 d4365151d0 Spawning candidates: rebuild only the squares a chunk crossing changes
The candidate union is kept as one segment per player (the chunks of its
square that no earlier player's square holds). A border crossing rebuilds
the mover's segment and the later overlapping ones, with no hash set, so
clustered and mixed groups no longer rebuild every player's square. Each
level keeps its own cache, so players in several dimensions stop
invalidating it every tick. Order, count and live proximity are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:54:19 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 626cf0542d rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:54:06 -05:00
masa-masa-0314andCodex 3eda0605cf Projectiles: restore fire on player-thrown tridents
Share arrow environment fire effects with player tridents, retain grounded and return timing, persist Fire, and synchronize and render the burning flag. Fixes #7500.

Co-Authored-By: Codex <[email protected]>
2026-11-03 17:53:15 +08:00
TiggerTakkyTTTandCodex aa9341243e Add live simulation distance management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 16:46:28 +07:00
JanandClaude Sonnet 5.5 23c7618560 Native mods: a player's chat message, cancellable (player_chat, #8120)
Adds the hook point player_chat(player, name, message, replies, cancel): heard once for each chat message a player sends on a server, after the secure-chat chain and the chat filter and before anyone sees it. cancel drops it for everybody; lines in replies go to the sender. The text is read-only (a rewritten signed message would not verify).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:46:11 +01:00
Arslan ArmanulyandClaude Opus 5.5 e00ffd6092 Shader packs: SNORM colortex buffers clamp and quantise
A colortexNFormat of RGBA8_SNORM, RG16_SNORM and the other SNORM formats was
stored in a half float, so a buffer read back -1.5 or 1000 unclamped and
0.0999756 for 0.1. Iris 1.11.7 reads them clamped to [-1, 1] and quantised
to 8 or 16 bits, as probe packs of our own show. Where the adapter can
render to the SNORM wgpu format, the buffer now uses it; elsewhere the half
float stays.

Part of #8106

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:46:05 +05:00
Arslan ArmanulyandClaude Opus 5.5 7b9b688fa4 Shader packs: three-channel colortex buffers read alpha 1
A colortex buffer of a three-channel format (RGB8, RGB16, RGB16F, RGB32F,
RGB32UI, RGB565, R3_G3_B2, R11F_G11F_B10F, RGB8_SNORM, ...) is stored in a
four-channel wgpu format, so it read back whatever alpha was written or
cleared. In Iris 1.11.7 it reads alpha 1 in every case, as probe packs of
our own show. Passes of colortex programs now leave such a buffer's alpha
alone, its clears and first contents set it to 1, and the world blit loads
a three-channel colortex0 with alpha 1.

Part of #8106

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:46:05 +05:00
Arslan ArmanulyandClaude Opus 5.5 8247cdbcb7 Shader packs: shadowcolor2 to shadowcolor7 with HIGHER_SHADOWCOLOR
A pack that names the HIGHER_SHADOWCOLOR feature (iris.features.required or
.optional) gets six more shadow buffers, shadowcolor2 to shadowcolor7, as
Iris's documentation describes them: identical to the first two. Shadow and
shadowcomp programs write them through RENDERTARGETS or DRAWBUFFERS, every
program samples them, and each has its own format, clear switch and clear
colour (white by default). Without a directive a shadow program still writes
shadowcolor0 and 1 only, as Iris 1.11.7 does with the feature in probe packs
of our own. The feature is defined as IRIS_FEATURE_HIGHER_SHADOWCOLOR for an
optional request and no longer noted as missing when required.

Not included: the shadowcolorimg2 to 7 images, which need image access to the
shadow buffers in the first place (#7945).

Part of #7951

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:46:05 +05:00
Arslan ArmanulyandClaude Opus 5.5 5073caa885 Copied signs keep their text when placed
A sign picked with Ctrl + Pick Block carries its text as the item's
sign_text_front/sign_text_back components (SignBlockEntity
.collectImplicitComponents). Vanilla's client places the copy itself
(BlockItem.place inside MultiPlayerGameMode.useItemOn), and
updateBlockEntityComponents gives the predicted sign that text, so the
editor the server opens (SignBlock.setPlacedBy) shows the copied lines and
Done sends them back unchanged.

Horizon lost the text on the way: the server never wrote the two
components on the wire, the client read past them, and the client's
predicted sign never took the stack's components. The editor opened on an
empty sign, and Done wrote the empty lines back.

- item stacks write and read SignText.STREAM_CODEC for both components;
- the client applies the stack's components (not block_entity_data, which
  updateCustomBlockEntityTag leaves to the server) to the block it predicts;
- lan_e2e gains a sign_copy check, for the host and a guest.

Fixes #8102

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:45:32 +05:00
HrafnaValkyrjaandClaude Sonnet 5.5 4cac42c4ef Weather follows the dimension type, not the Overworld id
Rain, thunder and lightning rules ask one canHaveWeather helper (sky light,
no ceiling, not the End) instead of comparing the dimension id. Adds a unit
test for the rule.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:42:07 -05:00
TiggerTakkyTTTandCodex 8aab0488dc Add live view distance management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 16:35:17 +07:00
Slavik-85andClaude Opus 5.5 3dbe994f44 resource packs: a pack's core/rendertype_lines draws the block outline
A pack that replaces core/rendertype_lines (an RGB outline animated from
GameTime, say) left the selection outline black: only core/text was run.
Its shaders now go through the same naga translation (pack_text's loader,
generalised to load_core) and draw both strokes of the outline, as
vanilla's LINES and secondaryBlockOutline both use rendertype_lines.

The glue feeds vanilla's lines vertex format from the outline's edge
instances: Position (the start for vertices 0 and 1, the end for 2 and
3), Color, Normal (the direction) and LineWidth, the side from
gl_VertexIndex % 2, as vs_line draws them. The blocks are vanilla's:
Globals (GameTime, ScreenSize), Projection (the lens with the bob and
TAA jitter, 0 to 1 depth like 26.3's), DynamicTransforms (the rotation
with the PERSPECTIVE layering scale) and Fog, so ProjMat * ModelViewMat
is vs_line's view_proj. A pack shader the backend rejects, or whose
blocks outgrow vanilla's, falls back to the built-in outline. Shader
packs that draw the outline still take it.

Fixes #8020.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 11:26:22 +02:00
HrafnaValkyrjaandClaude Sonnet 5.5 6d214c14c2 Merge origin/main into dimension-type weather branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:25:56 -05:00
Teiwazik 0ec7c93ad0 Merge remote-tracking branch 'fork/older-servers/v1-21-10' into older-servers/v1-21-10 2026-11-03 10:21:25 +01:00
Teiwazik 84b959af89 Merge remote-tracking branch 'origin/main' into older-servers/v1-21-10 2026-11-03 10:21:12 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 8d0c2bbd55 Merge remote-tracking branch 'origin/main' into fix/projectile-vehicle-hits
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:19:25 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 44c9719f41 Eggs and ender pearls strike boats and minecarts; vehicles are targets for every projectile kind
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:19:19 -05:00
Arslan ArmanulyandClaude Opus 5.5 9c74bbeace Shader packs: a program without RENDERTARGETS writes colortex0 only
Without a RENDERTARGETS or DRAWBUFFERS directive Horizon bound buffers 0 to 7
to a program's outputs, so gl_FragData[n] or layout(location = n) wrote
colortex n. Iris 1.11.7 writes only colortex0 for gbuffers, begin, prepare,
deferred and composite programs, and shadowcolor0 and 1 for shadow and
shadowcomp programs, as probe packs of our own show. Non-shadow programs
now default to buffer 0; shadow programs keep their two buffers.

Fixes #8107

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:07:27 +05:00
JoansitohandClaude Sonnet 5.5 1a14bdfcde Slide overlong Horizon-styled button, slider and cycler labels
Part of #8100

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:03:31 +01:00
TiggerTakkyTTTandCodex ccda2b69eb Add live spawn protection management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 15:57:20 +07:00
CocofireHDandHermes 072cd038c5 Mobs: an AvoidEntityGoal target needs sight, visibility and a non-Peaceful level
`AvoidEntity::can_use` picked what to flee with a distance and height test,
where `AvoidEntityGoal` targets through `TargetingConditions.forCombat()`:
`canAttack` (no player is a target at all in Peaceful), the range scaled by
`getVisibilityPercent` with a floor of 2, and line of sight. Fish, ocelots,
foxes, rabbits, pandas and illusioners fled a player standing behind a wall or
on the other side of a hill, a sneaking or invisible one from the full 8 or 16
blocks, and still fled in Peaceful; creepers fled cats and skeletons fled
wolves through walls.

The player arm now runs `Mob::can_target_player` (the existing port of
`TargetingConditions.test`) after the goal's own predicate, and the species arm
needs `Mob::sees`. The target lookup moved into `AvoidEntity::avoid_target`, so
the conditions are testable without the random retreat position and the path.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 09:45:29 +01:00
JoansitohandClaude Sonnet 5.5 bc4fffc20e Minecarts: air display shows nothing, unreadable DisplayState is dropped
Part of #8050.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:44:17 +01:00
Florian ERNSTandClaude Opus 5.5 3238d09f66 Borrow jigsaw connector strings from their template
Template jigsaws cloned each connector's name, pool and target, plus
the block state's orientation property, every time a piece's
connectors were shuffled. Borrow them from the cached template and
registry instead. Palette selection, rotation, shuffle draws and the
priority sort are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:41:31 +01:00
HrafnaValkyrjaandClaude Opus 5.5 19a7803283 Skip the block speed test when the vanilla data is missing
CI has no extracted vanilla data; the test now skips like its neighbours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:33:48 -05:00
JoansitohandClaude Sonnet 5.5 4ee1b9859f Minecarts: fall back to the kind's block, cull with the shown block
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:29:59 +01:00
JoansitohandClaude Sonnet 5.5 eabb9ba84e Minecarts: read and save DisplayState and DisplayOffset
Part of #8050. AbstractMinecart.read/addAdditionalSaveData: a saved custom
display block and offset now draw and survive a save; the offset is omitted
when it is the kind's default.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 09:27:34 +01:00
Slavik-85andClaude Opus 5.5 c557d53446 Credit a guest's raider kill to the guest's raid hero, not the host
Raider.die made the world owner the hero of the village for any player's
kill, so on LAN the host got Hero of the Village for a guest's kills and
the guest never did, and on a dedicated server nobody did. The killer's
connection now resolves to its own UUID, as villager_murdered already does.

Same mix-up in two commands: /fetchprofile entity @s in a guest's command
reported the host's profile, and /execute on owner missed guest owners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:15:16 +02:00
masa-masa-0314andCodex b3259b8b37 Tools: keep dimension pacing scenarios alive in flight
Synchronize creative flight through normal movement before End and Nether measurements, validate each dimension hop, and reject dead portal fixtures instead of reporting a false portal failure. Refs #7702.

Co-Authored-By: Codex <[email protected]>
2026-11-03 16:06:34 +08:00
HrafnaValkyrja c91fbfd777 Merge remote-tracking branch 'origin/main' into fix/entity-move-block-speed 2026-11-03 03:06:15 -05:00
Slavik-85andClaude Opus 5.5 9897e6bc6c game: a guest's tames, smashes and sweeps go by its own UUID
A nautilus a guest tamed, a horse a guest tamed by riding it, and the
allies a guest's mace smash and sword sweep spare all went by the world
owner's UUID (level.player_uuid, or a placeholder on a dedicated server)
instead of the acting player's.

The nautilus, the smash and the sweep run in the guest's turn, so they
take the acting player's UUID, as wolves and cats already do. A horse is
tamed in the mob tick, so its tame now runs in its rider's turn, found
the way Buck finds it: the rider owns it, then gets the tame_animal
trigger (AbstractHorse.tameWithName(player)), not the host.

Fixes #8047.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:02:36 +02:00
CocofireHDandHermes 92d02a0e84 Player skins: 3D skin layers
The outer skin layer is drawn as one box inflated by 0.25 px, so a hat,
jacket or sleeve reads as a flat skin over the body. With
threeDSkinLayers (Options -> Skin Customization, off by default) every
texel the skin paints becomes a slab one texel deep, with its sides filled
in where the next texel is transparent: the layer gets a silhouette of its
own and the gaps between painted texels stay open.

The geometry is built once per skin (and slim/wide) from the 64x64 image
the game already decodes and cached with it; the model tree and UVs are
the ones the renderer already poses, so the CPU and GPU skin paths draw it
unchanged.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 08:43:44 +01:00
LokiandAntigravity e9fd2edf5d menu: enforce limited_crafting game rule for unknown recipes (fixes #8052)
According to vanilla Java Edition 26.3 (RecipeCraftingHolder.setRecipeUsed,
CraftingMenu.slotChangedCraftingGrid, and MinecraftServer.onGameRuleChanged):
- When the limited_crafting game rule is active, crafting recipes that are
  not special and not contained in the player's recipe book are refused,
  leaving the crafting result empty (None). Special recipes (Recipe::Special)
  bypass the recipe book check.
- When limited_crafting changes, MinecraftServer sends ClientboundGameEventPacket
  type 12 (LIMITED_CRAFTING) to all players, and ClientboundLoginPacket carries
  the rule on join.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 16:35:09 +09:00
JoansitohandClaude Sonnet 5.5 46fb47af41 Collapse the reduced_debug_info event arm (clippy)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:33:29 +01:00
JoansitohandClaude Sonnet 5.5 fc16743cf3 Mobs never target players in Peaceful
LivingEntity.canAttack and TargetingConditions.test refuse a player target
when the difficulty is PEACEFUL; spares_players now does too.

Fixes #8046

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:33:29 +01:00
OlaHullebergandClaude Opus 5.5 da8eb9e43b Content blocks may occlude nothing (properties.occludes)
A content block occluded as its template does, so a thin panel or a block
drawn by its block entity on a full-cube template hid the faces of the
blocks beside it, leaving a hole in the floor under it.
`properties.occludes: false` is vanilla's `noOcclusion()`: the block's
gameplay state can't occlude and isn't a solid render, and its render
definition can't occlude (the faces beside it are drawn). A solid render's
full light dampening becomes a non-solid block's, as vanilla's data has it
for leaves and spawners: 1, or 0 when skylight passes down.

Item 2 of #5550.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ChJKHFsqiw1XFe27mMdroL
2026-11-03 07:28:03 +00:00
JoansitohandClaude Sonnet 5.5 11c50fd0d9 Send the reduced_debug_info rule to players (Login and entity events 22/23)
Fixes #8051

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 08:20:28 +01:00
OlaHullebergandClaude Opus 5.5 dd26d27880 Food effects keep their ambient and show_particles flags
A consumable's `apply_effects` read only each effect's id, duration and
amplifier, so a data pack's or content mod's food that asked for a quiet
effect (`"ambient": true`, `"show_particles": false`) gave a normal one.
Each `MobEffectInstance` now keeps both, with vanilla's codec defaults
(false and true), and the effect the player gets carries them.

Fixes #8080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ChJKHFsqiw1XFe27mMdroL
2026-11-03 06:48:06 +00:00
rdbtCVSandCodex gpt-6.1-sol 0d969ae57c Merge parrot flight-speed parity prerequisite (#8077)
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 06:44:46 +00:00
ClickingMaster e644192a1c Merge origin/main 2026-11-02 22:20:55 -08:00
LokiandAntigravity 0d29ccecac potions: collect dragon's breath from ender dragon clouds (fixes #8054)
In vanilla Java Edition 26.3 (BottleItem.use), using a glass bottle
within reach of an active Ender Dragon breath cloud shrinks the cloud
radius by 0.5, plays item.bottle.fill_dragonbreath, emits FLUID_PICKUP,
and turns the bottle into dragon_breath. In MultiPlayerGameMode.useItem,
the client forwards the use item packet without requiring a local target.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 15:14:29 +09:00
Ludovic MarieandClaude Opus 5.5 c2bf88085a Merge origin/main into #7968 after #8011: the mod API is generated at build time
generated.rs is no longer tracked, and xtask's generator moved to
horizon-mod-api-gen, so the dispatch tests' stand-in types get NativeConfig
and NativeConfigFile there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:48:28 +01:00
ClickingMasterandClaude Sonnet 5.5 e1197bfb6e Format the baby armour test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:39:41 -08:00
ClickingMaster 80fcd18c2e Merge origin/main 2026-11-02 21:37:30 -08:00
HrafnaValkyrjaandClaude Sonnet 5.5 813143c35b Slow boats, derailed minecarts and mobs by the block speed factor
Entity.move ends by multiplying the horizontal velocity by
getBlockSpeedFactor. Only the player did it; zombies, husks, boats and
derailed minecarts skipped soul sand and honey. The step is now one
function, entity_move::scale_by_block_speed, called by the player's move
and by the mob, boat and derailed-cart moves; the block lookup for kinds
without a motion world is motion::block_speed_factor_at.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:32:18 -05:00
LokiandAntigravity 244eb0c347 game/lan: defer point-blank fresh arrow in_ground update (fixes #7461)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 14:31:53 +09:00
Ludovic MarieandClaude Opus 5.5 24f8b0cfc5 Merge origin/main into #8032 (after #8008 landed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:30:47 +01:00
Ludovic MarieandClaude Opus 5.5 873782c24d Merge origin/main into #8065; keep both uniform tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:28:58 +01:00
rdbtCVSandCodex gpt-6.1-sol 33cd293811 Merge current main before validating spawning cache
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 05:27:10 +00:00
Arslan ArmanulyandClaude Opus 5.5 fc93a1d48d Shader packs: fog and per-draw uniforms as Iris gives them per program kind
Read back exactly with a probe pack in Iris 1.11.7 and Horizon, same scenes:
- fogStart/fogEnd are vanilla's environmental fog (0 to 1024 on a clear day,
  -160 to 768 in rain, -8 to 96 under water, 10 to 96 in the Nether), not the
  render distance's (172.8 to 192); fogDensity is 0.
- Composite and final programs read fogMode 0, fogShape -1, alphaTestRef 0.1,
  blockEntityId -1; gbuffers programs read fogMode 9729 (GL_LINEAR), fogShape
  1 (cylinder) and centerDepthSmooth 0.

Fixes #8060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:06:30 +05:00
Ludovic Marie a45326c556 Merge remote-tracking branch 'origin/main' into perf/slow-tick-culprit 2026-11-03 05:56:42 +01:00
Ludovic MarieandClaude Opus 5.5 60accdc5d5 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:55:22 +01:00
NerfPhaselock 3e48866617 Merge remote-tracking branch 'upstream/main' into perf/gpu-culling-ii 2026-11-02 23:51:56 -05:00
arismoko 868bc02e8e Merge remote-tracking branch 'upstream/main' into v2/ses_01M6FN1T2FS6T4M8P7GFXJ6WTR-tp-pitch-clamp 2026-11-03 04:51:06 +00:00
ClickingMaster 5900585aac Merge origin/main 2026-11-02 20:43:16 -08:00
ClickingMasterandClaude Sonnet 5.5 a696e54055 Baby zombie armour: use the humanoid_baby equipment textures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:43:16 -08:00
HrafnaValkyrjaandClaude Sonnet 5.5 4dc208a416 Rules read the dimension type, not the dimension id
A level's weather, natural spawning, map colouring, shader hasCeiling and
spawn search now read the type's has_ceiling, ambient_light and sky light
(Level.canHaveWeather, NaturalSpawner, MapItem.update, PlayerSpawnFinder),
carried in LevelShape from data packs, remote servers and LAN guests.
overworld_caves gets its ceiling. Fixes #7864.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 23:34:41 -05:00
Arslan ArmanulyandClaude Opus 5.5 0582226502 Shader packs: blend.<program> blends composite-style programs
`blend.<program>` was read for gbuffers programs only, so a begin,
prepare, deferred or composite program with one wrote its source over the
buffer. Iris 1.11.7 blends them (probed with our own pack: 0.25 over 0.75
with SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO reads 0.625) and ignores
`blend.<program>.<buffer>` for deferred and composite programs (probed;
begin, prepare, setup and final are assumed to match). These passes
already load the buffers they write, so what the buffer holds is the
destination.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:31:02 +05:00
arismoko 18ae21bcca Wrap teleport rotations like vanilla 2026-11-03 04:19:06 +00:00
arismoko 4ac46cd511 Wrap teleport pitch before clamping 2026-11-03 04:02:19 +00:00
NerfPhaselockandClaude Opus 5.5 85b95500f2 Renderer: GPU culling applies cave culling on the GPU
GPU frames now leave out the near solid sections that cave culling's graph
does not reach, as CPU frames do: cull also asks reached, which answers as
Occlusion::contains from a copy of the graph the frame's walk reads, the
clamped box and its sky band included. Since the previous commit they drew
every near solid section in view: the same pixels, but the GPU also shaded
every section the graph hides.

The graph keeps a bit per reached cell beside its cells (set_reached writes
both), and the occlusion store a process-wide generation that moves
whenever an answer of contains may change: a search lands, a partial update
reaches more, or the graph goes (clear, a teleport's remove_columns, a lost
camera). The pass keeps a copy of the bits and sends it again through the
staging belt only when the generation moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:53:48 -05:00
rdbtCVSandCodex gpt-6.1-sol bb80d6cce7 Cite the spawning candidate cache reference
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 03:45:33 +00:00
Ludovic MarieandClaude Opus 5.5 457da5d383 Dedicated server: a slow tick's line names whose packets or event took it
The slow-tick log said a 39.5 s freeze of the official server (2026-11-03)
was all tick_lan_packets, but not whose packets. Time each guest's packets
in the hub and each hub event in the game while spans are on (always on a
dedicated server), keep the three slowest of at least 10 ms, and end the
slow-tick line with them ("; slowest: Alex's 40 packets 39000.0").
tick_lan_packets also gets spans for its poll, drain, events and the
mods' commands, grants and menus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:43:15 +01:00
rdbtCVSandCodex gpt-6.1-sol 9836b0f0f2 Cache ordered spawning candidates across player chunk movements
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 03:40:04 +00:00
TeiwazikandClaude Opus 5.5 9027ef0a54 Older servers: join 1.21.9 and 1.21.10 servers
1.21.9 and 1.21.10 (protocol 773) get their packet and registry tables;
generate_packet_id_adapter.py learns --rename for horse_screen_open,
which 1.21.11 renamed mount_screen_open. Its packets are otherwise
1.21.11's; entity data takes 1.21.11's types for the bee's and the
wolf's anger end (a LONG, an INT before) and an avatar's main arm (a
HUMANOID_ARM, a BYTE before), and the serializers lack 1.21.11's zombie
nautilus variant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 04:25:11 +01:00
NerfPhaselockandClaude Opus 5.5 9952a8539f Renderer: GPU culling culls the near solid sections itself
The pass now decides which near solid sections it draws instead of drawing
the ones the walk chose: cull keeps a record whose column is within the
render distance (streaming's in_view_distance) and whose box passes an
integer frustum test. The box is the section's bounds rounded out to whole
blocks; the test adds integer terms the CPU makes each frame from the
camera's planes, each rounded up, with a margin for the walk's own f32
rounding, so it keeps every box the walk keeps. A camera so far above or
below the level that the terms could overflow makes the frame a CPU frame.

The walk lists only what it still draws within the render distance: the
sections with translucent quads or without a record (Resident's marks, kept
through every insert, removal and remove_columns), and past it everything as
before. Freed records are written to the GPU again, as the pass now reads
every record.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:49:45 -05:00
ErikKalikasandClaude Opus 5.5 ceadedaa0f Native mods: mod config files, loaded and reloaded (ModConfigEvent)
native_register_configs lists a mod's configs: a kind (Client, Common,
Server), a file in config/ (<mod id>-<kind>.toml unless named) and the
text written there when it is missing. Client configs load on the
window when the game starts, Common once per process, Server before
native_server_about_to_start for each world, from the world's
serverconfig/ copy when there is one. native_config_loading hands over
the text, which the mod parses. The files' modification times are read
once a second; changed text fires native_config_reloading. A server's
configs fire native_config_unloading when it stops. Fixes #7950.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:05:59 +02:00
gigamans442andCodex GPT-6.1 Sol 0f983e34d1 fix(shaders): reset GLSL type for each declarator
Keep one array declarator from changing the base type of following
comma-separated variables.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 19:17:48 -05:00
0a499bde42 Splitscreen: use the launcher's other Microsoft accounts
Hand off private per-account sessions when Splitscreen is enabled, retain unavailable accounts in the picker, and connect guests with their own authentication, skins and secure-chat keys. Preserve per-launch account selection and validate the metadata UUID before accepting a session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-02 15:33:02 -05:00
Kacpherek fa2de56df1 Sort only the entity-sensing blocks entities touch
Every tick tick_blocks gathered the blocks inside every mob's, item's
and other entity's box, sorted the whole list by position and called
entityInside at each one. With hundreds of mobs that is thousands of
positions sorted per tick, and the list cannot be kept ordered between
ticks: it is each entity's own sorted run, in entity order, and the
entities move.

entityInside only acts on pressure plates, detector rails, buttons and
tripwires; at any other block it just reads the state. The positions
are now split by what is there first, and only the sensing ones are
sorted and visited. A non-sensing position is skipped only while the
world's block revision (and chunk membership) is unchanged since it was
looked at, so it still holds the same block and the call would have
done nothing. If a call changes any block, every later position goes
in sorted order as before, so the calls and their order are the same
as sorting the whole list.
2026-11-02 21:23:43 +01:00
Kacpherek c27d6a22a9 Make players' block effects sweep cheaper
Entity.applyEffectsFromBlocks runs for every player every tick, and on
a server it was the biggest part of a guest's tick. Most of its cost
was bookkeeping around the blocks, not the blocks: both visited sets
(the sweep's and checkInsideBlocks') hashed positions with SipHash and
grew from empty on every sweep, and every block the path crossed, air
included, was kept in a list and read from the world a second time.

The sets now use the fast hash (they are only asked whether they hold
a position, never iterated) and the sweep's starts sized for a short
move. Each block is read once as the sweep visits it and air is passed
over there; nothing in the loop changes a block, so the states read
are the same, and the blocks reach the effects in the same order with
the same steps and iteration count as before.
2026-11-02 21:20:51 +01:00
Kacpherek b66c44849b Keep the far sea floor as dark as near water leaves it
Visited chunks lit their sea floor and water as open sky
(light_open_water), to match the seed's sea when it was fully lit. The
seed now shades water a level per block (shade_sky), so every place the
player had passed showed a bright, shallow-looking floor through the far
water, in a patchwork against the dark seed sea. The chunk's own light is
kept now, for saved chunks (decode_lod_surfaces) as for loaded ones.

Heightmap tiles (level 6 and up) took the span under the top one as the
ground; with shaded water that is another water span, so the far sea had
no bed and showed fog through it. The ground is the top solid span.
2026-11-02 21:18:03 +01:00
Kacpherek f14b413dde Write chunk bodies' light on the warm-up threads
Columns were already encoded once per tick however many players take
them, and their new sections went to the warm-up threads, but each
column's body (scanning every section's light layers and copying them
and the section bytes into the packet) was still written one by one on
the tick thread, about a third of the hub's time with walking players.

A body is now split in two: what reads the level and the caches (the
section bytes, heightmaps, block entities and which light sections
exist) stays on the tick thread, column by column as before; the light
layers and the write, which read only the column's own immutable
sections, are shared out to the warm-up threads, the tick thread taking
bodies too. The bodies come back in the columns' order and the sending
loop is unchanged, so every connection gets the same bytes in the same
order. A batch of fewer than four columns is written inline.
2026-11-02 21:15:55 +01:00
Kacpherek e0bded6fa7 Hold split tiles 5% past the detail ring and the world threshold
A tile split by the last plan stays split until its distance is 5% past
the ring of full detail around the camera, or past the world-wide
threshold (3 tile widths). Those two had no hysteresis, unlike the 8 to
10 width rule, so a tile on the line changed level each time the camera
crossed it by a block.
2026-11-02 21:15:52 +01:00
Kacpherek a9e638a4db Far terrain: an unknown neighbour is not empty
A tile meshed next to a planned tile that was not drawn yet treated it
as empty air and drew its full side, so tall walls stood at the edge of
the filled area and vanished once the neighbour arrived.

mesh_input now marks an edge unknown when nothing is drawn across it but
the finest planned tile there is a leaf (Snapshot::planned_leaf), and the
mesher draws no side against an unknown edge. Past the planned range, or
past a split tile's out-of-range child, the edge is still empty. The
neighbour's arrival already remeshes the tile through the selection
change; a planned tile that leaves the range before it was drawn now
remeshes its neighbours in replan.
2026-11-02 21:15:04 +01:00
Kacpherek 9cfac94b06 Lower far terrain threads on macOS and Windows too
behind_server lowers helper threads only on Linux and Android; on macOS and Windows the far terrain threads (sampling, the far terrain thread and meshing) ran at normal priority and competed with the threads that mesh and light the chunks around the player, most visibly while a world opens. far_terrain_current keeps behind_server on Linux and lowers the thread one step elsewhere: utility quality of service on macOS (background would throttle it on battery) and below-normal priority on Windows. The far decoration threads already go to idle.
2026-11-02 21:13:10 +01:00
martipopsandClaude Opus 5.5 45d56f3d8a Merge upstream main into servers
Lang conflict: main moved "Culling"; this branch's two online-mode strings kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:45:54 -05:00
ElyTheCatandOpenAI Codex ae1a4ec23c Merge origin/main into particle culling branch
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 20:10:55 +03:30
ElyTheCatandOpenAI Codex 740b215a99 Fix particle culling Clippy findings
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 19:47:53 +03:30
ElyTheCat 32c30eb98f Merge remote-tracking branch 'origin/main' into review/particle-only 2026-11-02 18:49:44 +03:30
ElyTheCatandOpenAI Codex 5d5dd03eb8 Add ordinary-scene particle culling measurements
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 18:37:35 +03:30
ElyTheCatandOpenAI Codex 7fc42e8269 Add standalone particle culling worker
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 18:25:52 +03:30
martipopsandClaude Opus 5.5 c784009d45 Merge upstream main (controller setup merged) into servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:46:25 -05:00
martipops 493a315a44 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 22:14:41 -05:00
martipopsandClaude Opus 5.5 fa76a40d45 Merge controller setup (main merged) into servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:10:28 -05:00
martipopsandClaude Opus 5.5 1a5c943591 Merge split/fresh-controller-setup (main merged) into split/fresh-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:12:07 -05:00
martipopsandClaude Opus 5.5 26af67b082 Merge controller setup (main merged) into servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:54:14 -05:00
martipops 4e4ae63d40 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 19:32:48 -05:00
martipops 77c58d477d Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 19:19:06 -05:00
martipopsandClaude Opus 5.5 b11536f39a Merge controller Setup (main merged) into servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:58:45 -05:00
martipops 018f9005c3 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 16:20:20 -05:00
martipops 2cce47fc9d Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 16:14:47 -05:00
martipopsandClaude Opus 5.5 b41cedea4b Localize the online-mode messages for splitscreen players on P1's server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:50:34 -05:00
martipops 262ed43366 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 15:49:24 -05:00
martipopsandOpenAI Codex 78ca40d8a5 Merge current main through server and LAN joins
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:36:26 -05:00
martipopsandOpenAI Codex 73f9ba613c Cover native input before reclick and foreground-window selection
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:00:02 -05:00
martipopsandOpenAI Codex 81ad72d464 Keep native window handoff test aware of per-player menus
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:58:30 -05:00
martipopsandOpenAI Codex a74c13032b Use position stability in native input regression checks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:57:24 -05:00
martipopsandOpenAI Codex 7441ce96da Merge portable native mouse fixture updates
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:47 -05:00
martipopsandOpenAI Codex c00651d4d0 Keep native desktop tests opt-in with cleanup on setup failure
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:24 -05:00
martipopsandOpenAI Codex 77cce1d217 Extend native input coverage to both players cameras
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:55:14 -05:00
martipops ddacea4d6f Merge native mouse ownership fixes and native input regressions
Co-Authored-By: OpenAI Codex <[email protected]>

# Conflicts:
#	tools/splitscreen_e2e.py
2026-11-01 14:54:11 -05:00
martipops 1e3317a9e2 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 14:35:44 -05:00
martipops 3a539aa588 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 14:33:37 -05:00
martipopsandOpenAI Codex 77b7eae96d Validate every splitscreen admission before opening a connection
Apply duplicate-name and online-mode checks to automatic test joins as well as picker selections.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:31:28 -05:00
martipops f7920f30f7 Merge branch 'split/fresh-controller-setup' into split/fresh-servers 2026-11-01 14:31:23 -05:00
martipopsandClaude Opus 5.5 bd7849a6cb Splitscreen: the others join the server P1 is on
From the full feature (#3541): on a server P1 joined over the network (dedicated, LAN,
invite) the other players connect to the same address as their own profiles. Names in the
server's player list can't be picked; an online-mode server refuses offline profiles before
they try; a player turned away while coming in picks again, told why (an online-mode refusal
says each player needs its own account); P1 leaving the server takes everyone with it; the
server's resource packs are asked once, of P1, and the others follow its answer.
Live scenarios: server, packs, dedicated_profiles, integrated_profiles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:28:21 -05:00
668 changed files with 147732 additions and 4144 deletions

No files matched your search

+2 -2
View File
@@ -9,9 +9,9 @@ permissions:
actions: write
jobs:
cancel:
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# One of our small runners (horizon-light), out of GitHub's pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
+107
View File
@@ -0,0 +1,107 @@
name: cargo-horizon-mod
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120): binstall reads
# [package.metadata.binstall] from crates/cargo-horizon-mod/Cargo.toml and downloads the archive this workflow
# attaches to the tag `cargo-horizon-mod-v<version>`, whose version must be the manifest's. The targets come from
# tools/horizon_mod_binstall.py, the same file the manifest's template is checked against, so a platform added there
# is built here and one nobody publishes is caught before it ships. A pull request builds the archives and publishes
# nothing.
on:
push:
tags: ['cargo-horizon-mod-v*']
pull_request:
paths:
- 'crates/cargo-horizon-mod/**'
- 'tools/horizon_mod_binstall.py'
- '.github/workflows/cargo-horizon-mod.yml'
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
needs: plan
name: build ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Build
run: cargo build --release --locked -p cargo-horizon-mod --target ${{ matrix.target }}
- name: It starts
if: matrix.run
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
out=$("$binary" 2>&1 || true)
case "$out" in
*"usage: cargo horizon-mod"*) ;;
*) echo "$out"; echo "::error::$binary does not start"; exit 1 ;;
esac
- name: Package
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
retention-days: 3
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: cargo-horizon-mod-*
path: dist
merge-multiple: true
- name: The tag and the manifest name the same release and archives
run: python3 tools/horizon_mod_binstall.py dist "$GITHUB_REF_NAME"
- name: Checksums
run: |
set -euo pipefail
cd dist
for file in *.tar.gz; do sha256sum "$file" | cut -d' ' -f1 > "$file.sha256"; done
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes="\`cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod\`"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* --latest=false --title "cargo-horizon-mod ${GITHUB_REF_NAME#cargo-horizon-mod-v}" --notes "$notes"
fi
+28 -9
View File
@@ -27,13 +27,18 @@ jobs:
# 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.
#
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -63,6 +68,16 @@ jobs:
else
python tools/check_public_tree.py --history
fi
# Our runners as GitHub's: some Python tests compile Rust with a bare `rustc` (GitHub's image has a default
# toolchain, our runners' own rustup homes may not), and the GPU tests find no adapter there, so they return
# early. Ours have Mesa's software Vulkan for parity; a backend Linux lacks keeps the tests from it, so a pull
# request's verify doesn't depend on which pool took it (#8627: a GPU test that flakes on lavapipe).
- name: Our runners as GitHub's (a default Rust toolchain, no GPU)
if: runner.environment != 'github-hosted'
run: |
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -104,7 +119,11 @@ jobs:
# 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).
launcher:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -145,8 +164,8 @@ jobs:
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# A git diff of this repository's own branch: one of our small runners (horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+4 -4
View File
@@ -29,8 +29,8 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@@ -56,8 +56,8 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
concurrency:
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ concurrency:
jobs:
check:
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script, Mojang's public manifest.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+2 -1
View File
@@ -253,7 +253,8 @@ jobs:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
# One comment, no checkout and no secret: one of our small runners (label horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
issues: write
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+14 -3
View File
@@ -22,17 +22,24 @@ env:
jobs:
bench:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md): the branch and the base on the same machine, as
# the comparison needs. SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
- name: Bench the branch
@@ -46,11 +53,15 @@ jobs:
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
# Our runners keep the folder beside the checkout between jobs.
git worktree remove --force ../base 2> /dev/null || rm -rf ../base
git worktree prune
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
target=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/target}
(cd ../base && CARGO_TARGET_DIR="$target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
concurrency:
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script only, as above.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+34 -33
View File
@@ -5,7 +5,8 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
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.
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.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
@@ -14,23 +15,25 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
- **Commands:**
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -41,11 +44,10 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
`tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
@@ -54,24 +56,23 @@ another project's source may also be read and used, **only when that project's l
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
## Before asking for review
@@ -87,8 +88,8 @@ your change can affect. Without the vanilla data on your machine it lists those
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies.
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
@@ -123,8 +124,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Generated
+14
View File
@@ -1429,6 +1429,7 @@ dependencies = [
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"mio",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -1445,6 +1446,7 @@ dependencies = [
"ureq",
"wgpu",
"winit",
"xxhash-rust",
"zip",
]
@@ -2315,6 +2317,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
+3 -3
View File
@@ -7,7 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+3
View File
@@ -14,6 +14,9 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
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`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
@@ -0,0 +1,27 @@
# Particle culling review measurements — 2026-11-02
Ordinary-scene captures and measurements for PR #7054. The scene contains 24 mobs, about 9,400–10,100 rain particles, and about 50–100 campfire smoke particles. Two opposite-order passes were run with 2 s warmup and 8 s measurement per condition. Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap.
These are means of the two samples. CPU is the harness's average CPU time per frame; p99 is frame time. The normal scene is not expected to show large gains because most particles are visible.
| Condition | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, culling Off | 88.39 | 1.930 | 23.85 |
| Particle branch, culling Off | 88.55 | 1.828 | 24.88 |
| Main, Entity Culling Synchronous | 89.12 | 1.887 | 23.07 |
| Particle branch, Entity Culling Synchronous, Particle Culling Off | 89.06 | 1.897 | 25.18 |
| Particle Culling Synchronous | 88.87 | 1.774 | 24.94 |
| Particle Culling Asynchronous* | 89.00 | 1.754 | 24.90 |
*The asynchronous samples predate the final switch to a dedicated particle worker. They are retained only as historical context and are not a measurement of the final worker implementation. No FPS improvement is claimed from these ordinary-scene runs. The Off path delegates directly to `render`; `disabled_culled_render_matches_the_regular_particle_path` checks that it produces the same vertices.
The FPS and CPU differences are within run-to-run variability for this GPU-limited scene. The first four conditions are the reviewer's requested Main-versus-branch default and existing synchronous-mode comparisons. Particle Sync/Async rows are branch-internal mode comparisons.
## Captures
The screenshots show the same ordinary test world and FPS overlay:
- `main-off.png`
- `particle-off.png`
- `particle-sync.png`
- `particle-async.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

+8
View File
@@ -6,6 +6,14 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
# `cargo-horizon-mod`'s workflow attaches to the `cargo-horizon-mod-v<version>` release for the runner's target,
# holding the binary at its root. tools/horizon_mod_binstall.py expands it for the workflow's targets.
[package.metadata.binstall]
pkg-url = "https://github.com/horizon-rust/horizon/releases/download/cargo-horizon-mod-v{ version }/{ name }-{ target }.tar.gz"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
+216
View File
@@ -100,6 +100,222 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/fa_ir.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fa_ir.json")
}
"assets/horizon/lang/he_il.json" => {
include_str!("../../horizon-client/assets/horizon/lang/he_il.json")
}
"assets/horizon/lang/hu_hu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hu_hu.json")
}
"assets/horizon/lang/ro_ro.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ro_ro.json")
}
"assets/horizon/lang/da_dk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/da_dk.json")
}
"assets/horizon/lang/fi_fi.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fi_fi.json")
}
"assets/horizon/lang/no_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/no_no.json")
}
"assets/horizon/lang/bg_bg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bg_bg.json")
}
"assets/horizon/lang/sk_sk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sk_sk.json")
}
"assets/horizon/lang/lt_lt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lt_lt.json")
}
"assets/horizon/lang/it_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/it_it.json")
}
"assets/horizon/lang/nl_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/nl_nl.json")
}
"assets/horizon/lang/vi_vn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vi_vn.json")
}
"assets/horizon/lang/th_th.json" => {
include_str!("../../horizon-client/assets/horizon/lang/th_th.json")
}
"assets/horizon/lang/ja_jp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ja_jp.json")
}
"assets/horizon/lang/ar_sa.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ar_sa.json")
}
"assets/horizon/lang/pt_br.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_br.json")
}
"assets/horizon/lang/cs_cz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cs_cz.json")
}
"assets/horizon/lang/sv_se.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sv_se.json")
}
"assets/horizon/lang/el_gr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/el_gr.json")
}
"assets/horizon/lang/ca_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ca_es.json")
}
"assets/horizon/lang/et_ee.json" => {
include_str!("../../horizon-client/assets/horizon/lang/et_ee.json")
}
"assets/horizon/lang/lv_lv.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lv_lv.json")
}
"assets/horizon/lang/sl_si.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sl_si.json")
}
"assets/horizon/lang/sr_cs.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_cs.json")
}
"assets/horizon/lang/is_is.json" => {
include_str!("../../horizon-client/assets/horizon/lang/is_is.json")
}
"assets/horizon/lang/af_za.json" => {
include_str!("../../horizon-client/assets/horizon/lang/af_za.json")
}
"assets/horizon/lang/ms_my.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ms_my.json")
}
"assets/horizon/lang/be_by.json" => {
include_str!("../../horizon-client/assets/horizon/lang/be_by.json")
}
"assets/horizon/lang/sr_sp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_sp.json")
}
"assets/horizon/lang/az_az.json" => {
include_str!("../../horizon-client/assets/horizon/lang/az_az.json")
}
"assets/horizon/lang/uz_uz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uz_uz.json")
}
"assets/horizon/lang/kk_kz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kk_kz.json")
}
"assets/horizon/lang/ka_ge.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ka_ge.json")
}
"assets/horizon/lang/hy_am.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hy_am.json")
}
"assets/horizon/lang/bs_ba.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bs_ba.json")
}
"assets/horizon/lang/mk_mk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mk_mk.json")
}
"assets/horizon/lang/sq_al.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sq_al.json")
}
"assets/horizon/lang/cy_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cy_gb.json")
}
"assets/horizon/lang/ga_ie.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ga_ie.json")
}
"assets/horizon/lang/eu_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eu_es.json")
}
"assets/horizon/lang/gl_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gl_es.json")
}
"assets/horizon/lang/fil_ph.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fil_ph.json")
}
"assets/horizon/lang/ta_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ta_in.json")
}
"assets/horizon/lang/mn_mn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mn_mn.json")
}
"assets/horizon/lang/lo_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lo_la.json")
}
"assets/horizon/lang/so_so.json" => {
include_str!("../../horizon-client/assets/horizon/lang/so_so.json")
}
"assets/horizon/lang/fy_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fy_nl.json")
}
"assets/horizon/lang/gd_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gd_gb.json")
}
"assets/horizon/lang/lb_lu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lb_lu.json")
}
"assets/horizon/lang/ky_kg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ky_kg.json")
}
"assets/horizon/lang/kn_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kn_in.json")
}
"assets/horizon/lang/mt_mt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mt_mt.json")
}
"assets/horizon/lang/eo_uy.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eo_uy.json")
}
"assets/horizon/lang/haw_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/haw_us.json")
}
"assets/horizon/lang/ig_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ig_ng.json")
}
"assets/horizon/lang/la_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/la_la.json")
}
"assets/horizon/lang/tt_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tt_ru.json")
}
"assets/horizon/lang/yi_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yi_de.json")
}
"assets/horizon/lang/yo_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yo_ng.json")
}
"assets/horizon/lang/ba_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ba_ru.json")
}
"assets/horizon/lang/br_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/br_fr.json")
}
"assets/horizon/lang/cv_cu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cv_cu.json")
}
"assets/horizon/lang/fo_fo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fo_fo.json")
}
"assets/horizon/lang/fur_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fur_it.json")
}
"assets/horizon/lang/li_li.json" => {
include_str!("../../horizon-client/assets/horizon/lang/li_li.json")
}
"assets/horizon/lang/lmo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lmo.json")
}
"assets/horizon/lang/oc_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/oc_fr.json")
}
"assets/horizon/lang/sah_sah.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sah_sah.json")
}
"assets/horizon/lang/se_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/se_no.json")
}
"assets/horizon/lang/szl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/szl.json")
}
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
+4
View File
@@ -63,6 +63,10 @@ ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
# The network threads of a server's players: epoll, kqueue or Windows' AFD (lan/io_pool.rs).
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
@@ -64,6 +64,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
@@ -0,0 +1,827 @@
//! Draws a scene `horizon-ctl render-capture DIR` wrote, offscreen, from its camera, with the
//! real `Renderer`, and prints GPU and CPU milliseconds per frame and Horizon's own passes'
//! GPU times as JSON (docs/graphics.md, "Render capture and replay").
//! `cargo run --release -p horizon-client-render --example scene_replay -- DIR [--frames 300]
//! [--warmup 60] [--yaw-offsets 0,90,180,270] [--texture-filtering 0|1|2] [--anisotropy-bit N]
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, block_strategy},
fast_hash::{FxHashMap, FxHashSet},
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
panorama::Faces,
},
graphics::LeafCullingMode,
hud::HudBatch,
lod::TileKey,
mesher::{MeshScratch, MeshTables, Mesher, SectionMesh, packed},
models,
};
use horizon_core::{
biome::{BiomeZoom, obfuscate_seed},
paletted::PalettedContainer,
};
use horizon_world::{
chunk::BlockState,
nbt::{Limits, NamedTag, Tag},
region::RegionFile,
registry::BlockRegistry,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
#[derive(Default)]
struct Options {
dir: PathBuf,
frames: usize,
warmup: usize,
yaw_offsets: Vec<f32>,
root: Option<PathBuf>,
texture_filtering: Option<i32>,
anisotropy_bit: Option<i32>,
mipmaps: Option<u32>,
lod_distance: Option<u32>,
render_distance: Option<i32>,
size: Option<[u32; 2]>,
flat: Option<bool>,
cull: Option<bool>,
sides: Option<bool>,
gpu_culling: Option<bool>,
screenshot: Option<PathBuf>,
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
match value {
"on" | "flat" => Ok(true),
"off" | "indirect" => Ok(false),
other => Err(format!("expected on or off, got {other}")),
}
}
fn options() -> Result<Options, String> {
let mut o = Options {
frames: 300,
warmup: 60,
yaw_offsets: vec![0.0],
..Default::default()
};
let mut args = std::env::args().skip(1);
let mut dir = None;
while let Some(arg) = args.next() {
if !arg.starts_with("--") {
dir = Some(PathBuf::from(arg));
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |v: &str| v.parse::<i64>().map_err(|e| format!("{arg}: {e}"));
match arg.as_str() {
"--frames" => o.frames = number(&value)?.max(1) as usize,
"--warmup" => o.warmup = number(&value)?.max(0) as usize,
"--yaw-offsets" => {
o.yaw_offsets = value
.split(',')
.map(|v| v.trim().parse::<f32>().map_err(|e| format!("{arg}: {e}")))
.collect::<Result<_, _>>()?
}
"--root" => o.root = Some(value.into()),
"--texture-filtering" => o.texture_filtering = Some(number(&value)? as i32),
"--anisotropy-bit" => o.anisotropy_bit = Some(number(&value)? as i32),
"--mipmaps" => o.mipmaps = Some(number(&value)?.max(0) as u32),
"--lod-distance" => o.lod_distance = Some(number(&value)?.max(0) as u32),
"--render-distance" => o.render_distance = Some(number(&value)?.max(0) as i32),
"--size" => {
let (w, h) = value.split_once('x').ok_or("--size takes WxH")?;
o.size = Some([number(w)? as u32, number(h)? as u32]);
}
"--section-draws" => o.flat = Some(on_off(&value)?),
"--section-cull" => o.cull = Some(on_off(&value)?),
"--section-sides" => o.sides = Some(on_off(&value)?),
"--gpu-culling" => o.gpu_culling = Some(on_off(&value)?),
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
.find(|mode| mode.name() == value)
.ok_or(format!("no leaf culling mode {value}"))?;
}
other => return Err(format!("unknown option {other}")),
}
}
o.dir = dir.ok_or("the capture directory is required")?;
Ok(o)
}
/// Milliseconds: average, median, 95th percentile and slowest, over `samples`.
fn summary(samples: &[f32]) -> Value {
if samples.is_empty() {
return Value::Null;
}
let mut sorted = samples.to_vec();
sorted.sort_by(f32::total_cmp);
let at = |q: f32| sorted[((sorted.len() - 1) as f32 * q).round() as usize];
json!({
"avg": samples.iter().sum::<f32>() / samples.len() as f32,
"min": sorted[0],
"p10": at(0.1),
"p50": at(0.5),
"p95": at(0.95),
"max": sorted[sorted.len() - 1],
"samples": samples.len(),
})
}
fn ms(d: Duration) -> f32 {
d.as_secs_f32() * 1000.0
}
fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let o = options()?;
let scene = Scene::read(&o.dir)?;
let settings = &scene.settings;
let root = o.root.clone().unwrap_or_else(|| settings.resources.clone());
let loading = Instant::now();
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
if AtlasCheck::of(&render) != settings.atlas {
return Err(format!(
"the block sprites at {} are not the ones the capture was made with (resource packs \
or mods?): pass --root with the game's resource root",
root.display()
)
.into());
}
let one = [255u8; 4];
let zero = [0u8; 4];
let prepared = PreparedTextures::new(
&render.pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
(&zero[..], 1),
)
.with_pbr(
&render.pbr_normals,
&render.pbr_specular,
render.pbr_size,
render.texture_layers,
);
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
&render.sprite_mips,
&render.animations,
(&one[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1),
Arc::new(Faces::colour([0, 0, 0, 255])),
Some(prepared),
None,
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
renderer.gamma = state.gamma;
renderer.fullbright = state.fullbright;
renderer.improved_transparency = state.improved_transparency;
renderer.animated_textures = state.animated_textures;
let filtering = o.texture_filtering.unwrap_or(settings.texture_filtering);
let anisotropy = o.anisotropy_bit.unwrap_or(settings.anisotropy_bit);
renderer.set_texture_filtering(filtering, anisotropy);
renderer.set_mipmap_levels(o.mipmaps.unwrap_or(settings.mipmap_levels));
renderer.set_world_post(
settings.resolution_scale,
settings.fxaa,
settings.taa,
settings.upscale,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
if let Err(e) = result {
notes.push(format!("{what}: {e}"));
}
};
note(
"section-draws",
renderer.set_flat_draws(o.flat.unwrap_or(state.flat_draws)),
);
note(
"section-cull",
renderer.set_section_cull(o.cull.unwrap_or(state.section_cull)),
);
note(
"gpu-culling",
renderer.set_gpu_culling(o.gpu_culling.unwrap_or(state.gpu_culling)),
);
renderer.set_section_sides(o.sides.unwrap_or(state.section_sides));
renderer.set_pass_timing(true);
let layout = ColumnLayout::of_level(state.min_y, state.sections as i32 * 16);
if layout != ColumnLayout::VANILLA {
renderer.clear_sections(layout);
}
// Near sections first: with no far tile resident yet, none fades in.
let camera = scene.frame.camera;
let column = |x: f64| (x.floor() as i32) >> 4;
let (cx, cz) = (column(camera[0]), column(camera[2]));
let near = |x: i32, z: i32| {
o.render_distance
.is_none_or(|r| (x - cx).pow(2) + (z - cz).pow(2) <= r * r)
};
let (mut remeshed, mut world_report) = (FxHashMap::default(), Value::Null);
if let Some(world) = &o.world {
let keys: Vec<[i32; 3]> = section_keys(&o.dir)?
.into_iter()
.filter(|&[x, _, z]| near(x, z))
.collect();
let started = Instant::now();
remeshed = remesh(world, &root, &assets, &render, o.leaf_culling, &keys)?;
world_report = json!({
"save": world.display().to_string(),
"leaf_culling": o.leaf_culling.name(),
"remeshed": remeshed.len(),
"kept": keys.len() - remeshed.len(),
"remesh_ms": ms(started.elapsed()),
});
}
let leaf_layers = leaf_layers(&render);
let (mut sections, mut quads, mut leaf_quads) = (0, 0usize, 0usize);
let mut packed_quads = 0usize;
capture::read_meshes(&o.dir, "sections", |[x, y, z], captured| {
if near(x, z) {
let mesh = remeshed.remove(&[x, y, z]).unwrap_or(captured);
quads += mesh.solid.len() / 4;
packed_quads += packed::packed_quads(&mesh.packed, mesh.solid.len() / 4);
leaf_quads += mesh
.solid
.iter()
.filter(|v| leaf_layers.contains(&v.layer))
.count()
/ 4;
renderer.upload((x, y, z), &mesh);
sections += 1;
}
})?;
let mut tiles: Vec<(TileKey, SectionMesh)> = Vec::new();
capture::read_meshes(&o.dir, "tiles", |[x, z, level], mesh| {
let key = TileKey {
x,
z,
level: level as u8,
};
let [ox, _, oz] = key.origin();
let half = f64::from(key.size()) / 2.0;
let (dx, dz) = (
f64::from(ox) + half - camera[0],
f64::from(oz) + half - camera[2],
);
let near = o
.lod_distance
.is_none_or(|d| (dx * dx + dz * dz).sqrt() <= f64::from(d) * 16.0);
if near {
tiles.push((key, mesh));
}
})?;
let tile_count = tiles.len();
if !tiles.is_empty() {
let cutout: FxHashSet<(i32, i32)> = scene.lod_cutout.iter().copied().collect();
renderer.queue_lods(tiles, Vec::new(), Some(cutout));
renderer.upload_lods(Duration::from_secs(3600));
}
let loaded_ms = ms(loading.elapsed());
let hud = HudBatch::new([width, height]);
let mut views = Vec::new();
for &offset in &o.yaw_offsets {
let mut frame = scene.frame(&hud);
frame.yaw += offset;
frame.eye_rotation[0] += offset;
if let Some(r) = o.render_distance {
frame.distance = (r * 16) as f32;
frame.detail_distance = frame.distance;
}
let draw = |renderer: &mut Renderer| -> horizon_client_render::Result<Duration> {
if frame.cave_culling {
renderer.start_occlusion(frame.camera, frame.distance);
}
let started = Instant::now();
renderer.render(&frame)?;
Ok(started.elapsed())
};
for _ in 0..o.warmup {
draw(&mut renderer)?;
}
renderer.gpu_times.clear();
let (mut call, mut encode, mut gpu) = (Vec::new(), Vec::new(), Vec::new());
for _ in 0..o.frames {
call.push(ms(draw(&mut renderer)?));
encode.push(ms(renderer.last_encode));
gpu.append(&mut renderer.gpu_times);
}
// Timestamps arrive a few frames late: a few more of the same frame bring them in.
for _ in 0..16 {
if gpu.len() >= o.frames {
break;
}
draw(&mut renderer)?;
gpu.append(&mut renderer.gpu_times);
}
gpu.truncate(o.frames);
if let Some(path) = o.screenshot.as_ref().filter(|_| views.is_empty()) {
renderer.screenshot_result = None;
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
if renderer.screenshot_result.is_some() {
break;
}
}
match renderer.screenshot_result.take() {
Some(Ok(_)) => {}
Some(Err(e)) => notes.push(format!("screenshot: {e}")),
None => notes.push("screenshot: not saved".into()),
}
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
"render_call_ms": summary(&call),
"cpu_encode_ms": summary(&encode),
"gpu_passes_median_ms": renderer
.own_pass_times()
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect::<serde_json::Map<_, _>>(),
"drawn_sections": renderer.drawn_sections,
"section_draws": renderer.section_draws,
"section_draw_calls": renderer.section_draw_calls,
// Occlusion culling's last result: boxes hidden, boxes tested, share of solid
// indices hidden.
"culled": renderer.culled_sections(),
}));
}
let section_draws = if renderer.flat_draws() {
"flat"
} else {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
"captured_on": state.adapter,
"size": [width, height],
"sections": sections,
"solid_quads": quads,
"leaf_quads": leaf_quads,
// Solid quads uploaded as one record each (`mesher::packed`).
"packed_quads": packed_quads,
"world": world_report,
"tiles": tile_count,
"load_ms": loaded_ms,
"frames": o.frames,
"warmup": o.warmup,
"settings": {
"texture_filtering": filtering,
"anisotropy_bit": anisotropy,
"mipmaps": o.mipmaps.unwrap_or(settings.mipmap_levels),
"lod_distance": o.lod_distance,
"render_distance": o.render_distance,
"section_draws": section_draws,
"section_cull": renderer.section_cull(),
"section_sides": renderer.section_sides(),
"gpu_culling": renderer.gpu_culling(),
"shader_pack_left_out": state.shader_pack,
},
"notes": notes,
"views": views,
"memory": memory,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn mb(bytes: u64) -> f64 {
(bytes as f64 / 1048576.0 * 10.0).round() / 10.0
}
/// `--memory`: the renderer's census in MiB and, on macOS, `footprint`'s summary of this
/// process (GPU buffers and textures count as IOAccelerator memory there).
fn memory_report(renderer: &Renderer) -> Value {
let gpu: serde_json::Map<String, Value> = renderer
.gpu_census()
.into_iter()
.map(|(name, bytes)| (format!("{name}_mb"), json!(mb(bytes))))
.collect();
let footprint = std::process::Command::new("/usr/bin/footprint")
.args(["-f", "bytes", "-p", &std::process::id().to_string()])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
let footprint = footprint.map(|text| {
let mut total = None;
let mut categories = Vec::new();
for line in text.lines() {
if let Some(at) = line.find("Footprint: ") {
total = line[at + 11..]
.split_whitespace()
.next()
.and_then(|v| v.parse::<u64>().ok());
}
// " <dirty> B <clean> B <reclaimable> B <regions> <category>"
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() >= 8
&& words[1] == "B"
&& let Ok(dirty) = words[0].parse::<u64>()
&& dirty >= 1 << 20
{
categories.push(json!([words[7..].join(" "), mb(dirty)]));
}
}
json!({ "total_mb": total.map(mb), "dirty_by_category_mb": categories })
});
json!({ "gpu": gpu, "footprint": footprint })
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = replay(window) {
eprintln!("scene_replay: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
/// The captured sections' keys, from the index alone.
fn section_keys(dir: &Path) -> Result<Vec<[i32; 3]>, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize)]
struct Key {
key: [i32; 3],
}
let records: Vec<Key> = serde_json::from_reader(std::io::BufReader::new(std::fs::File::open(
dir.join("sections.json"),
)?))?;
Ok(records.into_iter().map(|r| r.key).collect())
}
/// Sprite layers of every leaves block, as terrain vertices carry them (with or
/// without `OPAQUE_LAYER`).
fn leaf_layers(render: &RenderAssets) -> FxHashSet<u16> {
render
.sprite_layers
.iter()
.filter(|(name, _)| name.ends_with("_leaves"))
.flat_map(|(_, &layer)| {
[
layer as u16,
layer as u16 | horizon_client_render::mesher::OPAQUE_LAYER,
]
})
.collect()
}
/// `--world`: the sections `keys` meshed again from `save`'s overworld region
/// files, on every core, as the game's workers mesh them.
fn remesh(
save: &Path,
root: &Path,
assets: &Assets,
render: &RenderAssets,
leaf_culling: LeafCullingMode,
keys: &[[i32; 3]],
) -> Result<FxHashMap<[i32; 3], SectionMesh>, Box<dyn std::error::Error>> {
let facts = BlockFacts::load(root, assets.registry)?;
let visibility = models::load_visibility(root, assets.registry)?;
let tables = MeshTables::new(assets, render, &facts, visibility)?;
let biomes = Biomes::load(root)?;
let seed = world_seed(save).unwrap_or_else(|| {
eprintln!(
"scene_replay: no seed in {}, biome blending uses 0",
save.display()
);
0
});
let options = std::fs::read_to_string("options.txt").unwrap_or_default();
let option = |key: &str| {
options
.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix(':').map(str::to_owned))
};
let smooth_lighting = option("ao").is_none_or(|v| v == "true");
let cutout_leaves = option("cutoutLeaves").is_none_or(|v| v == "true");
let blend_radius = option("biomeBlendRadius")
.and_then(|v| v.parse().ok())
.unwrap_or(2);
let mut columns: Vec<(i32, i32)> = keys
.iter()
.flat_map(|&[x, _, z]| {
(-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)))
})
.collect();
columns.sort_unstable();
columns.dedup();
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let region_dir = save.join("dimensions/minecraft/overworld/region");
let next = AtomicUsize::new(0);
let chunks: FxHashMap<(i32, i32), Chunk> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut files: FxHashMap<(i32, i32), Option<RegionFile>> = FxHashMap::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&(x, z)) = columns.get(i) else {
break;
};
let file = files.entry((x >> 5, z >> 5)).or_insert_with(|| {
RegionFile::open(region_dir.join(format!(
"r.{}.{}.mca",
x >> 5,
z >> 5
)))
.ok()
});
let root = file
.as_mut()
.and_then(|f| {
f.read(horizon_core::position::ChunkPos::new(x, z))
.ok()
.flatten()
})
.and_then(|record| record.nbt(Limits::default()).ok());
if let Some(chunk) =
root.and_then(|root| decode(&root, assets.registry, &biomes))
{
out.push(((x, z), chunk));
}
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect()
});
let meshable: Vec<[i32; 3]> = keys
.iter()
.copied()
.filter(|&[x, _, z]| {
(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
})
.collect();
let next = AtomicUsize::new(0);
let meshes = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables: &tables,
biomes: &biomes,
visibility: tables.visibility.clone(),
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling,
};
let mut scratch = MeshScratch::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&[x, y, z]) = meshable.get(i) else {
break;
};
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
})
});
let mut mesh =
mesher.mesh_reusing(&region, y, SectionMesh::default(), &mut scratch);
mesh.sort_solid();
out.push(([x, y, z], mesh));
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect::<FxHashMap<_, _>>()
});
Ok(meshes)
}
/// The world seed: `data/minecraft/world_gen_settings.dat`, or level.dat's
/// `Data.WorldGenSettings` in older saves.
fn world_seed(save: &Path) -> Option<i64> {
let read = |path: PathBuf| {
horizon_world::datafix::files::read_dat(&path)
.ok()
.flatten()
};
let seed = |tag: Option<&Tag>| match tag?.get("seed")? {
Tag::Long(seed) => Some(*seed),
_ => None,
};
seed(
read(save.join("data/minecraft/world_gen_settings.dat"))
.as_ref()
.and_then(|t| t.get("data")),
)
.or_else(|| {
seed(
read(save.join("level.dat"))?
.get("Data")?
.get("WorldGenSettings"),
)
})
}
/// A saved full chunk's blocks, biomes and light, as the client's own save
/// loader reads them (`save::decode_lit`, in horizon-client).
fn decode(root: &NamedTag, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
let tag = &root.tag;
let text = |t: Option<&Tag>| match t {
Some(Tag::String(s)) => Some(s.to_string_lossy().to_string()),
_ => None,
};
if text(tag.get("Status")).is_some_and(|s| s != "minecraft:full" && s != "full") {
return None;
}
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return None;
}
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (tag.get("xPos"), tag.get("zPos")) else {
return None;
};
let mut chunk = Chunk::with_layout(*x, *z, ColumnLayout::VANILLA);
let air = registry.default_state("minecraft:air")?.0;
let Some(Tag::List(sections)) = tag.get("sections") else {
return Some(chunk);
};
let longs = |t: Option<&Tag>| match t {
Some(Tag::LongArray(data)) => Some(data.iter().map(|&w| w as u64).collect::<Vec<_>>()),
_ => None,
};
let mut layers = vec![(None, None); chunk.sections.len()];
for section in sections {
let Some(Tag::Byte(y)) = section.get("Y") else {
continue;
};
let Some(si) = chunk.section_index(i32::from(*y) * 16) else {
continue;
};
let target = Arc::make_mut(&mut chunk.sections[si]);
if let Some(states) = section.get("block_states")
&& let Some(Tag::List(palette)) = states.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| {
BlockState::from_nbt(p)
.ok()
.and_then(|s| {
registry.id(&s).or_else(|| {
let default = registry.default_state(&s.name)?;
let mut full = registry.state(default)?.clone();
for (k, v) in &s.properties {
if full.properties.contains_key(k) {
full.properties.insert(k.clone(), v.clone());
}
}
registry.id(&full).or(Some(default))
})
})
.map_or(air, |id| id.0)
})
.collect();
let data = longs(states.get("data"));
if let Ok(blocks) =
PalettedContainer::from_packed(block_strategy(), palette, data.as_deref())
{
target.blocks = blocks;
}
}
if let Some(b) = section.get("biomes")
&& let Some(Tag::List(palette)) = b.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| match p {
Tag::String(s) => u32::from(biomes.id(&s.to_string_lossy())),
_ => 0,
})
.collect();
let data = longs(b.get("data"));
if let Ok(container) =
PalettedContainer::from_packed(BIOME_STRATEGY, palette, data.as_deref())
{
target.biomes = container;
}
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(a.clone()),
_ => None,
};
layers[si] = (layer("SkyLight"), layer("BlockLight"));
}
// `save::load_light`: a section without saved sky light takes the light
// of the blocks above it, from the top of the column down.
let mut above = [15u8; 256];
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
let mut data = Box::new([0u8; 4096]);
for (i, v) in data.iter_mut().enumerate() {
let nibble =
|layer: &Option<Vec<u8>>| layer.as_ref().map(|l| l[i >> 1] >> ((i & 1) << 2) & 15);
let s = nibble(&sky).unwrap_or(above[i & 255]);
let b = nibble(&block).unwrap_or(0);
*v = s << 4 | b;
}
if sky.is_some() {
for (i, a) in above.iter_mut().enumerate() {
*a = data[i] >> 4;
}
}
let section = Arc::make_mut(&mut chunk.sections[si]);
section.light = Light::Full(data);
section.compact();
}
chunk.lit = true;
chunk.light_loaded = true;
Some(chunk)
}
@@ -121,6 +121,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
+37 -5
View File
@@ -207,6 +207,20 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] with the model library already parsed.
///
/// Parsing it reads every model file under `assets/`, which needs no
/// block registry, so a caller that has the files to spare can start
/// it before `Assets::load` finishes and hand the result in here. On
/// a cold cache that is most of a second of reading moved off the
/// critical path.
pub fn reload_with_models(
&self,
root: &Path,
parsed: crate::model_library::ParsedModels,
) -> Result<RenderAssets> {
RenderAssets::load_with_parsed(root, self, true, Some(parsed))
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
@@ -218,6 +232,16 @@ impl RenderAssets {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
Self::load_with_parsed(root, assets, draws, None)
}
/// `parsed`: the model library when a caller parsed it ahead of this
/// (see [`Assets::reload_with_models`]); otherwise it is parsed here.
fn load_with_parsed(
root: &Path,
assets: &Assets,
draws: bool,
parsed: Option<crate::model_library::ParsedModels>,
) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -225,17 +249,24 @@ impl RenderAssets {
}
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
let parsed = match parsed {
Some(parsed) => parsed,
None if draws => {
let _step = crate::spans::startup::step("core.models_parse");
crate::model_library::ParsedModels::load(root)
}
None => crate::model_library::ParsedModels::default(),
};
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
let model_library = {
let _step = crate::spans::startup::step("core.item_library");
parsed.into_item_library()
};
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -251,6 +282,7 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
@@ -379,14 +379,17 @@ pub fn generate(
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
let key = argb(&key);
let target = argb(&target);
apply_palette(&mut mapped, &key, &target)?;
let out: Vec<u8> = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
drop(mapped);
Some((w, h, out))
}
}
+11 -8
View File
@@ -61,6 +61,8 @@ struct Mixer {
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -544,9 +546,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
(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(
mixer.cameras.clear();
mixer.cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
@@ -565,10 +566,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
)
}),
);
let shared = cameras.len() > 1;
let shared = mixer.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();
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -586,7 +588,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
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
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
@@ -617,8 +620,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -648,6 +650,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
mixer.voices = voices;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
+104 -11
View File
@@ -101,6 +101,22 @@ impl AuthSession {
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
/// says.
pub fn expired(&self) -> bool {
let exp = self
.access_token
.split('.')
.nth(1)
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
.and_then(|v| v.get("exp")?.as_u64());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
exp.is_some_and(|exp| exp <= now + 60)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
@@ -436,11 +452,8 @@ struct ChatKeys {
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
@@ -449,7 +462,14 @@ pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
prepare_chat_keys_for(session, now);
}
/// Prepare keys for this player, independently of the other local accounts.
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
let uuid = session.uuid;
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
@@ -463,12 +483,13 @@ pub fn prepare_chat_keys(now: bool) {
return;
}
keys.fetching = true;
drop(keys);
drop(accounts);
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();
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
@@ -476,19 +497,24 @@ pub fn prepare_chat_keys(now: bool) {
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().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_for(current()?.uuid)
}
/// This account's key pair; another local player's keys can never be returned.
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
CHAT_KEYS
.lock()
.unwrap()
.get(&uuid)?
.current
.clone()
.filter(|k| !k.public.has_expired(now))
.filter(|k| !k.public.has_expired(now_millis()))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
@@ -632,6 +658,42 @@ mod tests {
}
}
/// base64url without padding, as a JWT's parts are.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n =
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
for k in 0..=chunk.len() {
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
}
}
out
}
#[test]
fn a_token_past_its_exp_is_expired() {
// header.payload.signature, the payload base64url without padding.
let token = |exp: u64| {
let json = format!(r#"{{"exp":{exp}}}"#);
let payload = base64url(json.as_bytes());
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(token(now - 10).expired());
assert!(
token(now + 30).expired(),
"a minute from its end: as good as gone"
);
assert!(!token(now + 3600).expired());
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -795,6 +857,37 @@ mod tests {
.to_string()
}
#[test]
fn local_accounts_use_their_own_chat_keys() {
let pair = |signature: &[u8]| {
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
keys.public.expires_at = i64::MAX;
keys.refreshed_after = i64::MAX;
keys.public.signature = signature.to_vec();
Arc::new(keys)
};
let ann = pair(b"ann");
let bea = pair(b"bea");
{
let mut accounts = CHAT_KEYS.lock().unwrap();
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
accounts.insert(
uuid,
ChatKeys {
current: Some(keys),
..Default::default()
},
);
}
}
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
assert!(chat_keys_for(103).is_none());
CHAT_KEYS.lock().unwrap().remove(&101);
CHAT_KEYS.lock().unwrap().remove(&102);
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
+20 -19
View File
@@ -50,8 +50,9 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) spherical: f32,
@location(3) cylindrical: f32,
// The distances fog reads: .x spherical, .y cylindrical. One vec2 rather than two floats, so that
// the fragment stage stays within 14 user-defined inputs plus front_facing (GL's limit of 15, #8195).
@location(2) fog_distance: vec2<f32>,
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
@location(5) overlay_color: vec4<f32>,
@location(4) @interpolate(flat) overlay: u32,
@@ -157,11 +158,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
out.back_color = back;
out.overlay_color = overlay_texel(overlay);
// Sky geometry (clouds, celestial bodies) is exempt from render-distance fog.
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.fog_distance.x = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.fog_distance.y = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
if overlay == 4u { out.fog_distance.x = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@@ -293,7 +294,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
color = vec4(c, 1.0);
} else if in.overlay == 4u {
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
@@ -312,8 +313,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -327,8 +328,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -347,8 +348,8 @@ fn shadow_color(in: Out) -> vec4<f32> {
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
@@ -379,7 +380,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
let a = in.color.a * (1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
@@ -388,8 +389,8 @@ fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let fog = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
@@ -428,8 +429,8 @@ fn energy_swirl_base(in: Out) -> vec4<f32> {
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
@@ -572,7 +573,7 @@ fn weather_color(in: Out) -> vec4<f32> {
var color = textureLoad(atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -120,7 +120,7 @@ pub struct Environment {
tracks: HashMap<String, Track>,
base: HashMap<String, [f32; 4]>,
}
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Sky {
pub sky_color: [f32; 3],
pub fog_color: [f32; 3],
+29 -1
View File
@@ -8,6 +8,8 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -157,6 +159,10 @@ pub struct Extras {
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_auras: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
pub three_d_skin_layers: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -206,6 +212,7 @@ impl Default for Extras {
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_auras: false,
three_d_skin_layers: false,
}
}
}
@@ -307,6 +314,7 @@ impl Extras {
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
_ => {}
}
}
@@ -314,7 +322,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -365,8 +373,28 @@ impl Extras {
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
bool_text(self.cosmetics_auras),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
)
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
{
self.three_d_skin_layers = value == "true";
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
+1 -1
View File
@@ -21,7 +21,7 @@ pub enum FogType {
}
/// The fog uniform (`FogData`).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FogData {
pub color: [f32; 4],
pub environmental_start: f32,
+146 -41
View File
@@ -104,8 +104,11 @@ struct FontSet {
providers: Vec<Provider>,
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use).
by_width: Option<HashMap<u32, Vec<char>>>,
/// `SelectedGlyphs.nonFishy` of the characters whose first glyph has a fishy advance (the
/// others use `glyphs`): what obfuscated text draws.
non_fishy: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use), by `ceil(advance)`.
by_width: Option<HashMap<i32, Vec<char>>>,
}
/// The atlas region glyphs are rasterized into, filled in rows.
struct Region {
@@ -258,10 +261,11 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
})
.map_or(0, |x| x + 1)
}
/// `FontSet.hasFishyAdvance`: a provider glyph whose advance is under 0 or
/// over 32 is not used. `FontSet.computeGlyphInfo` moves on to the next
/// provider that has the character, so a pack's absurd advance cannot widen
/// a line; Horizon keeps one advance per glyph and tests it here.
/// `FontSet.hasFishyAdvance`: an advance under 0 or over 32. Such a glyph is still the one drawn
/// (`SelectedGlyphs.any` is the first provider's); only `SelectedGlyphs.nonFishy`, what obfuscated
/// text draws, moves on to the next provider that has the character. Packs rely on it: a `space`
/// provider's kerning runs from -512 to 512, and a tall bitmap's glyphs pass 32. Horizon keeps one
/// advance per glyph and tests it here.
fn fishy_advance(advance: f32) -> bool {
!(0.0..=32.0).contains(&advance)
}
@@ -674,6 +678,7 @@ impl Font {
FontSet {
providers,
glyphs: Default::default(),
non_fishy: Default::default(),
by_width: None,
}
}
@@ -682,6 +687,7 @@ impl Font {
let mut cache = self.cache.borrow_mut();
for (_, set) in &mut cache.sets {
set.glyphs.clear();
set.non_fishy.clear();
set.by_width = None;
}
cache.region.cursor = (0, 0);
@@ -735,7 +741,7 @@ impl Font {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
let found = self.resolve(set, region, c, false);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
@@ -747,13 +753,46 @@ impl Font {
set.glyphs.insert(c, glyph);
glyph
}
fn resolve(&self, set: &mut FontSet, region: &mut Region, c: char) -> Option<Glyph> {
/// `FontSet.getRandomGlyph`'s `SelectedGlyphs.nonFishy`: the glyph of the first provider whose
/// advance is not fishy, else the missing glyph. A character whose first glyph is fine has
/// the same one for both.
fn glyph_non_fishy(&self, id: &str, c: char) -> Glyph {
let any = self.glyph_of(id, c);
if !fishy_advance(any.advance) {
return any;
}
let mut cache = self.cache.borrow_mut();
let Cache { sets, region, .. } = &mut *cache;
let Some((_, set)) = sets.iter_mut().find(|(k, _)| k == id) else {
return self.missing;
};
if let Some(g) = set.non_fishy.get(&c) {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c, true);
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.non_fishy.insert(c, glyph);
glyph
}
fn resolve(
&self,
set: &mut FontSet,
region: &mut Region,
c: char,
non_fishy: bool,
) -> Option<Glyph> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
&& !(non_fishy && fishy_advance(*a))
{
return Some(Glyph {
rect: None,
@@ -768,10 +807,12 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
Some(BitmapGlyph::Atlas(g)) if !(non_fishy && fishy_advance(g.advance)) => {
return Some(*g);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
Some(BitmapGlyph::Sheet(cell))
if !(non_fishy && fishy_advance(cell.glyph.advance)) =>
{
let (stride, pixels) = (&cell.sheet.0, &cell.sheet.1);
let mut rgba = Vec::with_capacity((cell.w * cell.h * 4) as usize);
for y in cell.y..cell.y + cell.h {
@@ -791,14 +832,14 @@ impl Font {
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !fishy_advance(hex_advance(&h))
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
return self.rasterize_hex(region, h);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
{
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
@@ -851,7 +892,7 @@ impl Font {
pub fn random_glyph(&self, style: &Style, advance: f32) -> Glyph {
use horizon_core::random::RandomSource;
let id = style.font.as_deref().unwrap_or(DEFAULT);
let width = advance.ceil() as u32;
let width = advance.ceil() as i32;
let pick = {
let mut cache = self.cache.borrow_mut();
let Cache { sets, random, .. } = &mut *cache;
@@ -869,9 +910,9 @@ impl Font {
chars.get(i as usize).copied()
})
};
pick.map_or(self.missing, |c| self.glyph_of(id, c))
pick.map_or(self.missing, |c| self.glyph_non_fishy(id, c))
}
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<u32, Vec<char>> {
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<i32, Vec<char>> {
let (uniform, japanese) = (self.uniform, self.japanese);
let mut chars = std::collections::BTreeSet::new();
let mut in_atlas = false;
@@ -893,33 +934,31 @@ impl Font {
// Nothing in the atlas: the glyphs below U+0250, rasterized as used.
chars.extend((0..0x250u32).filter_map(char::from_u32));
}
let mut out: HashMap<u32, Vec<char>> = HashMap::new();
let mut out: HashMap<i32, Vec<char>> = HashMap::new();
for c in chars {
if let Some(advance) = self.provider_advance(set, c) {
out.entry(advance.ceil() as u32).or_default().push(c);
out.entry(advance.ceil() as i32).or_default().push(c);
}
}
out
}
/// A character's advance in `set` (its first provider that has it),
/// without rasterizing it.
/// A character's advance in `set` (its first provider that has it, fishy or not, as the
/// by-width table of `FontSet.reload` takes it), without rasterizing it.
fn provider_advance(&self, set: &mut FontSet, c: char) -> Option<f32> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
if let Some(a) = advances.get(&c) {
return Some(*a);
}
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
Some(BitmapGlyph::Atlas(g)) => {
return Some(g.advance);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
Some(BitmapGlyph::Sheet(cell)) => {
return Some(cell.glyph.advance);
}
_ => {}
@@ -927,16 +966,12 @@ impl Font {
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c))
&& !fishy_advance(hex_advance(h))
{
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
if let Some(id) = ttf.ids.get(&c) {
return Some(ttf.advance(*id));
}
}
@@ -1406,12 +1441,13 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// `FontSet.computeGlyphInfo`: a provider glyph with an advance outside
/// 0..32 is skipped, so the next provider that has the character is used.
/// `FontSet.computeGlyphInfo`: `SelectedGlyphs.any` is the first provider's glyph, fishy
/// advance or not, and is what is drawn and measured; `nonFishy`, what obfuscated text draws,
/// is the first provider whose advance is within 0..32, else the missing glyph.
#[test]
fn a_fishy_advance_falls_through_to_the_next_provider() {
// 64 opaque columns: the first provider's 'a' would advance 65, past
// the 32 vanilla allows, so the narrow provider's 'a' is the one used.
fn a_fishy_glyph_is_drawn_and_only_obfuscated_text_falls_through() {
// 64 opaque columns: the first provider's 'a' advances 65, past the 32 vanilla
// calls fishy, and the narrow provider's 'a' advances 3.
let wide = png(64, 8, |_, _| true);
let narrow = png(16, 8, |x, _| x < 2);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/wide.png","ascent":7,"chars":["a"]},{"type":"bitmap","file":"minecraft:font/narrow.png","ascent":7,"chars":["a"]}]}"#;
@@ -1425,11 +1461,80 @@ mod tests {
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "the narrow provider wins");
assert_eq!(font.advance('a', &Style::default()), 3.0);
// Obfuscated text picks a glyph by its own advance: nothing is 65 wide.
let obfuscated = font.random_glyph(&Style::default(), 65.0);
assert_eq!(obfuscated.advance, 6.0, "the missing glyph");
let drawn = font.glyph('a');
assert_eq!(drawn.advance, 65.0, "the first provider's glyph is drawn");
assert!(drawn.rect.is_some());
assert_eq!(font.advance('a', &Style::default()), 65.0);
// The by-width table files 'a' under its first glyph's 65; the glyph it then draws is
// the narrow provider's (`nonFishy`).
assert_eq!(font.random_glyph(&Style::default(), 65.0).advance, 3.0);
// Nothing else is 3 wide in the table, and the missing glyph is 6.
assert_eq!(font.random_glyph(&Style::default(), 3.0).advance, 6.0);
}
/// A `space` provider's shift glyphs (kerning in server packs: -512 to 512) are drawn and
/// measured as they are, and `nonFishy` has nothing else to draw for them.
#[test]
fn space_glyphs_with_any_advance_are_kept() {
let shifts: Vec<i32> = (0..10)
.map(|i| -(1 << i))
.chain((0..10).map(|i| 1 << i))
.collect();
let advances: Vec<String> = shifts
.iter()
.enumerate()
.map(|(i, a)| format!("\"\\ue{:03x}\": {a}", i))
.collect();
let def = format!(
r#"{{"providers":[{{"type":"space","advances":{{{}}}}}]}}"#,
advances.join(",")
);
let root = root(
"space-shifts",
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
for (i, a) in shifts.iter().enumerate() {
let c = char::from_u32(0xe000 + i as u32).unwrap();
assert_eq!(font.glyph(c).advance, *a as f32, "{c:?}");
assert_eq!(font.advance(c, &Style::default()), *a as f32);
}
// Obfuscated text asks for a glyph of a given width: a fishy space has none to give.
let c = char::from_u32(0xe000 + 19).unwrap();
assert_eq!(font.glyph(c).advance, 512.0);
assert_eq!(
font.glyph_non_fishy(DEFAULT, c).advance,
6.0,
"the missing glyph"
);
assert_eq!(font.random_glyph(&Style::default(), 512.0).advance, 6.0);
}
/// Nexo's GUI glyphs: a bitmap `height` of 287 with no advance, so every glyph wider than 31
/// pixels has a fishy advance, and is still drawn.
#[test]
fn a_wide_bitmap_glyph_is_drawn() {
let sheet = png(40, 287, |_, _| true);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/gui.png","height":287,"ascent":65,"chars":["a"]}]}"#;
let root = root(
"wide-bitmap",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/gui.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let g = font.glyph('a');
assert_eq!(g.advance, 41.0, "the glyph's 40 columns and one");
assert!(g.rect.is_some(), "drawn, not the missing glyph");
assert!(fishy_advance(g.advance));
assert_eq!(
font.glyph_non_fishy(DEFAULT, 'a').advance,
6.0,
"excluded from what obfuscated text draws"
);
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
File diff suppressed because it is too large. Load diff
+12 -6
View File
@@ -877,6 +877,8 @@ mod tests {
start,
translucent_at,
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -930,11 +932,12 @@ mod tests {
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
// The sections with solid quads have records, on page 0.
// The sections with solid quads have records, on page 0, each with
// its cell's box.
let mut records = gpu_culling::Records::default();
for (i, s) in sections.iter_mut().enumerate() {
if s.solid > 0 {
s.gpu_id = Some(records.insert((i as i32, 0, 0), s.page, s.start, s.solid));
s.gpu_id = Some(records.insert(s.record((i as i32, 0, 0), None)));
}
}
let recorded = records.live() as u32;
@@ -957,9 +960,8 @@ mod tests {
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
// Its record names its solid quads where they are now.
let record = (s.solid > 0)
.then(|| gpu_culling::Record::new((i as i32, 0, 0), s.page, s.start, s.solid));
// Its record names its solid quads where they are now, its box kept.
let record = (s.solid > 0).then(|| s.record((i as i32, 0, 0), None));
let held = s.gpu_id.and_then(|id| records.at(id));
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
@@ -1014,7 +1016,7 @@ mod tests {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -1071,6 +1073,8 @@ mod tests {
start: 0,
translucent_at: (0, at),
solid: 64,
solid_slots: 64 * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1295,6 +1299,8 @@ mod tests {
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -0,0 +1,574 @@
//! `horizon-ctl render-capture`: what the renderer needs to draw the current frame again
//! without the game, for `examples/scene_replay.rs` (docs/graphics.md, "Render capture and
//! replay").
//!
//! The renderer keeps no CPU copy of the meshes it uploads, so while capturing is on
//! (`Renderer::keep_capture_copies`) it keeps one of every section and far tile mesh as it is
//! handed over, at the mesher-to-renderer seam. A capture writes those of the sections and
//! tiles it holds, the frame's camera and inputs, and the settings that change GPU work. Only
//! the build that wrote a capture reads it.
//!
//! Files: `scene.json` (`Scene`), `sections.json` and `tiles.json` (one `MeshRecord` per mesh,
//! in order), `sections.bin` and `tiles.bin` (each mesh's solid then translucent
//! `TerrainVertex`es), `celestial.bin` (the sun and moon `Vertex`es).
use super::{Frame, Renderer, SectionKey};
use crate::{
Result,
environment::Sky,
fast_hash::{FxHashMap, FxHashSet},
fog::FogData,
graphics::GraphicsApi,
hud::HudBatch,
lod::TileKey,
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
};
use glam::{DVec3, Mat4, Vec3};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io::{BufReader, BufWriter, Read, Write},
path::{Path, PathBuf},
};
/// CPU copies of the meshes uploaded since capturing was turned on.
#[derive(Default)]
pub(super) struct Copies {
sections: FxHashMap<SectionKey, SectionMesh>,
tiles: FxHashMap<TileKey, SectionMesh>,
}
impl Copies {
pub(super) fn section(&mut self, key: SectionKey, mesh: &SectionMesh) {
self.sections.insert(key, copy(mesh));
}
/// Columns unloaded (`Renderer::remove_columns`).
pub(super) fn remove_columns(&mut self, columns: &[(i32, i32)]) {
if columns.is_empty() {
return;
}
let gone: FxHashSet<(i32, i32)> = columns.iter().copied().collect();
self.sections
.retain(|&(x, _, z), _| !gone.contains(&(x, z)));
}
pub(super) fn tile(&mut self, key: TileKey, mesh: &SectionMesh) {
self.tiles.insert(key, copy(mesh));
}
pub(super) fn forget_tile(&mut self, key: TileKey) {
self.tiles.remove(&key);
}
pub(super) fn clear_tiles(&mut self) {
self.tiles.clear();
}
}
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
packed: mesh.packed.clone(),
solid_tints: Vec::new(),
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh.bounds,
keep: mesh.keep,
}
}
/// A capture asked for: where to write it, and the game's settings the renderer does not hold.
pub struct Request {
pub dir: PathBuf,
pub settings: Settings,
}
/// The game's settings that change GPU work, as the game applied them to the renderer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Settings {
/// `GraphicsApi`'s variant name.
pub graphics_api: String,
pub translucent_batching: bool,
/// `textureFiltering` and `maxAnisotropyBit` (`Renderer::set_texture_filtering`).
pub texture_filtering: i32,
pub anisotropy_bit: i32,
pub mipmap_levels: u32,
/// `Renderer::set_world_post`'s arguments.
pub resolution_scale: f32,
pub fxaa: bool,
pub taa: bool,
pub upscale: f32,
/// Chunks.
pub render_distance: i32,
pub lod_distance: u32,
/// The game's prepared resources, where the replay loads the block sprites from.
pub resources: PathBuf,
/// The block sprites the meshes were made against.
pub atlas: AtlasCheck,
}
impl Settings {
pub fn graphics_api(&self) -> GraphicsApi {
GraphicsApi::ALL
.into_iter()
.find(|api| format!("{api:?}") == self.graphics_api)
.unwrap_or_default()
}
}
/// Which block sprite layers the meshes' vertices name: the replay's must be the same.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtlasCheck {
pub sprite_size: u32,
pub texture_layers: u32,
/// FNV-1a of every sprite's name and layer.
pub layers: u64,
}
impl AtlasCheck {
pub fn of(render: &crate::assets::RenderAssets) -> Self {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for (name, layer) in &render.sprite_layers {
for byte in name.bytes().chain(layer.to_le_bytes()) {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3);
}
}
Self {
sprite_size: render.sprite_size,
texture_layers: render.texture_layers,
layers: hash,
}
}
}
/// The renderer's own state that changes GPU work.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RendererState {
pub adapter: String,
pub backend: String,
/// The frame's size in pixels.
pub size: [u32; 2],
/// The column layout: lowest block y and sections.
pub min_y: i32,
pub sections: usize,
pub flat_draws: bool,
pub section_cull: bool,
pub section_sides: bool,
pub gpu_culling: bool,
pub gamma: f32,
pub fullbright: bool,
pub improved_transparency: bool,
pub animated_textures: bool,
/// A shader pack was running; the replay draws without it.
pub shader_pack: bool,
}
/// The cloud inputs of the frame (`clouds::Input`), with the clouds texture.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Clouds {
pub texture: (u32, u32, Vec<bool>),
pub uv: [f32; 2],
pub camera: [f64; 3],
pub time: i64,
pub partial_tick: f64,
pub range: i32,
pub fast: bool,
pub color: [f32; 4],
}
/// The frame's camera and the inputs of its globals, sky, fog and lightmap.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FrameInputs {
pub camera: [f64; 3],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
pub first_person: bool,
pub bob: [f32; 16],
pub spin: [f32; 16],
pub sky: Sky,
pub distance: f32,
pub detail_distance: f32,
pub depth_far: f32,
pub underwater: bool,
pub water_plane: Option<f32>,
pub cave_culling: bool,
pub sky_blocked: bool,
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
pub dark_disc: bool,
pub nether_lighting: bool,
pub glint_alpha: f32,
pub glint_ticks: f32,
pub hand_fov_degrees: f32,
pub stars: Option<([f32; 16], f32)>,
pub clouds: Option<Clouds>,
pub game_time: f32,
pub shader_game_time: f32,
pub end_portal_sprites: [[f32; 4]; 2],
pub crumbling_sprite: [f32; 4],
pub ticks: u64,
pub darkness: (f32, f32),
pub night_vision: f32,
pub block_flicker: f32,
pub boss_darken: f32,
}
/// `scene.json`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Scene {
pub settings: Settings,
pub renderer: RendererState,
pub frame: FrameInputs,
pub sections: usize,
pub tiles: usize,
/// The chunk columns far tiles leave out for full-detail sections.
pub lod_cutout: Vec<(i32, i32)>,
/// `celestial.bin`.
#[serde(skip)]
pub celestial: Vec<Vertex>,
}
/// One mesh of `sections.json` or `tiles.json`; its vertices follow the previous mesh's in
/// the `.bin` file. `key` is (x, section y, z) for a section, (x, z, level) for a tile.
#[derive(Serialize, Deserialize)]
struct MeshRecord {
key: [i32; 3],
solid: usize,
translucent: usize,
sides: Sides,
visibility: u64,
block_entities: Vec<([i32; 3], u32)>,
lights: Vec<([i32; 3], u32)>,
sprite_layers: Vec<u16>,
bounds: Option<[[f32; 3]; 2]>,
keep: bool,
}
/// Writes `name.json` and `name.bin`: (meshes, vertex bytes).
fn write_meshes<'m>(
dir: &Path,
name: &str,
meshes: impl Iterator<Item = ([i32; 3], &'m SectionMesh)>,
) -> Result<(usize, u64)> {
let mut blob = BufWriter::new(File::create(dir.join(format!("{name}.bin")))?);
let mut records = Vec::new();
let mut bytes = 0u64;
for (key, mesh) in meshes {
for part in [&mesh.solid, &mesh.translucent] {
let raw: &[u8] = bytemuck::cast_slice(part.as_slice());
blob.write_all(raw)?;
bytes += raw.len() as u64;
}
records.push(MeshRecord {
key,
solid: mesh.solid.len(),
translucent: mesh.translucent.len(),
sides: mesh.sides,
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh
.bounds
.map(|[min, max]| [min.to_array(), max.to_array()]),
keep: mesh.keep,
});
}
blob.flush()?;
let mut list = BufWriter::new(File::create(dir.join(format!("{name}.json")))?);
serde_json::to_writer(&mut list, &records)?;
list.flush()?;
Ok((records.len(), bytes))
}
/// Reads `name.json` and `name.bin`, handing each mesh to `each` as it is read.
pub fn read_meshes(
dir: &Path,
name: &str,
mut each: impl FnMut([i32; 3], SectionMesh),
) -> Result<()> {
let records: Vec<MeshRecord> = serde_json::from_reader(BufReader::new(File::open(
dir.join(format!("{name}.json")),
)?))?;
let mut blob = BufReader::new(File::open(dir.join(format!("{name}.bin")))?);
for record in records {
let mut solid = vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.solid];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
solid.as_mut_slice(),
))?;
let mut translucent =
vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.translucent];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
translucent.as_mut_slice(),
))?;
each(
record.key,
SectionMesh {
solid,
sides: record.sides,
translucent,
visibility: record.visibility,
block_entities: record.block_entities,
lights: record.lights,
sprite_layers: record.sprite_layers,
bounds: record
.bounds
.map(|[min, max]| [Vec3::from_array(min), Vec3::from_array(max)]),
keep: record.keep,
..Default::default()
},
);
}
Ok(())
}
impl Renderer {
/// Keeps CPU copies of the meshes uploaded from now on, for `render-capture`, or drops
/// them. They start empty: the caller meshes everything again for a complete capture.
pub fn keep_capture_copies(&mut self, on: bool) {
if on != self.capture_copies.is_some() {
self.capture_copies = on.then(Copies::default);
}
}
pub fn keeps_capture_copies(&self) -> bool {
self.capture_copies.is_some()
}
/// Writes the scene `frame` draws to `request.dir` (`render` calls it for `capture`).
pub(super) fn write_capture(
&self,
request: &Request,
frame: &Frame,
) -> Result<serde_json::Value> {
let copies = self
.capture_copies
.as_ref()
.ok_or("no mesh copies kept: render-capture on first")?;
let dir = &request.dir;
fs::create_dir_all(dir)?;
let (sections, section_bytes) = write_meshes(
dir,
"sections",
copies
.sections
.iter()
.map(|(&(x, y, z), mesh)| ([x, y, z], mesh)),
)?;
let standing = self.standing_lods();
let (tiles, tile_bytes) = write_meshes(
dir,
"tiles",
copies
.tiles
.iter()
.filter(|(key, _)| standing.contains(*key))
.map(|(key, mesh)| ([key.x, key.z, i32::from(key.level)], mesh)),
)?;
fs::write(
dir.join("celestial.bin"),
bytemuck::cast_slice::<Vertex, u8>(frame.celestial),
)?;
let scene = Scene {
settings: request.settings.clone(),
renderer: RendererState {
adapter: self.info.name.clone(),
backend: format!("{:?}", self.info.backend),
size: [self.config.width, self.config.height],
min_y: self.layout.min_y(),
sections: self.layout.sections(),
flat_draws: self.flat_draws(),
section_cull: self.section_cull(),
section_sides: self.section_sides(),
gpu_culling: self.gpu_culling(),
gamma: self.gamma,
fullbright: self.fullbright,
improved_transparency: self.improved_transparency,
animated_textures: self.animated_textures,
shader_pack: self.pack_post.is_some() || self.native.is_some(),
},
frame: FrameInputs {
camera: frame.camera.to_array(),
yaw: frame.yaw,
pitch: frame.pitch,
fov_degrees: frame.fov_degrees,
eye: frame.eye.to_array(),
eye_rotation: frame.eye_rotation,
first_person: frame.first_person,
bob: frame.bob.to_cols_array(),
spin: frame.spin.to_cols_array(),
sky: frame.sky,
distance: frame.distance,
detail_distance: frame.detail_distance,
depth_far: frame.depth_far,
underwater: frame.underwater,
water_plane: frame.water_plane,
cave_culling: frame.cave_culling,
sky_blocked: frame.sky_blocked,
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
dark_disc: frame.dark_disc,
nether_lighting: frame.nether_lighting,
glint_alpha: frame.glint_alpha,
glint_ticks: frame.glint_ticks,
hand_fov_degrees: frame.hand_fov_degrees,
stars: frame
.stars
.map(|(rotation, brightness)| (rotation.to_cols_array(), brightness)),
clouds: frame.clouds.as_ref().map(|c| Clouds {
texture: c.texture.clone(),
uv: c.uv,
camera: c.camera.to_array(),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
game_time: frame.game_time,
shader_game_time: frame.shader_game_time,
end_portal_sprites: frame.end_portal_sprites,
crumbling_sprite: frame.crumbling_sprite,
ticks: frame.ticks,
darkness: frame.darkness,
night_vision: frame.night_vision,
block_flicker: frame.block_flicker,
boss_darken: frame.boss_darken,
},
sections,
tiles,
lod_cutout: self.lod_cutout.iter().copied().collect(),
celestial: Vec::new(),
};
let mut out = BufWriter::new(File::create(dir.join("scene.json"))?);
serde_json::to_writer_pretty(&mut out, &scene)?;
out.flush()?;
Ok(serde_json::json!({
"ok": true,
"dir": dir.display().to_string(),
"sections": sections,
"tiles": tiles,
"vertex_bytes": section_bytes + tile_bytes,
"shader_pack_left_out": scene.renderer.shader_pack,
}))
}
}
impl Scene {
/// Reads `scene.json` and `celestial.bin` (the meshes: `read_meshes`).
pub fn read(dir: &Path) -> Result<Self> {
let mut scene: Scene =
serde_json::from_reader(BufReader::new(File::open(dir.join("scene.json"))?))?;
let bytes = fs::read(dir.join("celestial.bin"))?;
let size = std::mem::size_of::<Vertex>();
let mut celestial = vec![<Vertex as bytemuck::Zeroable>::zeroed(); bytes.len() / size];
let len = celestial.len() * size;
bytemuck::cast_slice_mut::<Vertex, u8>(celestial.as_mut_slice())
.copy_from_slice(&bytes[..len]);
scene.celestial = celestial;
Ok(scene)
}
/// The captured frame: its camera, sky, fog, clouds, sun, moon and stars, without
/// entities, particles, weather, the hand, the outline or the HUD.
pub fn frame<'a>(&'a self, hud: &'a HudBatch) -> Frame<'a> {
let f = &self.frame;
Frame {
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
pitch: f.pitch,
fov_degrees: f.fov_degrees,
eye: DVec3::from_array(f.eye),
eye_rotation: f.eye_rotation,
first_person: f.first_person,
bob: Mat4::from_cols_array(&f.bob),
spin: Mat4::from_cols_array(&f.spin),
sky: f.sky,
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: f.distance,
detail_distance: f.detail_distance,
depth_far: f.depth_far,
underwater: f.underwater,
water_plane: f.water_plane,
cave_culling: f.cave_culling,
title: false,
sky_blocked: f.sky_blocked,
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
dark_disc: f.dark_disc,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: f.glint_alpha,
glint_ticks: f.glint_ticks,
nether_lighting: f.nether_lighting,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: f.clouds.as_ref().map(|c| crate::clouds::Input {
texture: &c.texture,
uv: c.uv,
camera: DVec3::from_array(c.camera),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &self.celestial,
stars: f
.stars
.map(|(rotation, brightness)| (Mat4::from_cols_array(&rotation), brightness)),
hand: &[],
hand_fov_degrees: f.hand_fov_degrees,
screen: &[],
pack: Default::default(),
border: None,
game_time: f.game_time,
shader_game_time: f.shader_game_time,
end_portal_sprites: f.end_portal_sprites,
crumbling_sprite: f.crumbling_sprite,
ticks: f.ticks,
darkness: f.darkness,
night_vision: f.night_vision,
block_flicker: f.block_flicker,
boss_darken: f.boss_darken,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
menu_blur: None,
}
}
}
@@ -0,0 +1,486 @@
//! A resource pack's `core/rendertype_lines` shader on the block outline (`RenderTypes.LINES`,
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
/// `gl_VertexIndex % 2`, as `vs_line` draws them.
pub(crate) const GLUE: Glue = Glue {
inputs: &[
("Position", "vec3"),
("Color", "vec4"),
("Normal", "vec3"),
("LineWidth", "float"),
],
prelude: "layout(location = 0) in vec3 hz_start;\n\
layout(location = 1) in vec3 hz_end;\n\
layout(location = 2) in vec4 hz_argb;\n\
layout(location = 3) in float hz_width;",
// ARGB in a little-endian u32 reads as B, G, R, A. ShapeRenderer's normal: the direction.
setup: "Position = hz_start; if (int(gl_VertexIndex) >= 2) Position = hz_end; \
Color = hz_argb.zyxw; Normal = normalize(hz_end - hz_start); LineWidth = hz_width;",
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
pub(super) struct CoreLines {
lines: wgpu::RenderPipeline,
secondary: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl CoreLines {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
program: &CoreProgram,
) -> Option<Self> {
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = |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 lines (vertex)", &program.vertex);
let fragment = module("Resource pack lines (fragment)", &program.fragment);
// Vanilla's LINES pipeline has no samplers: a pack that reads one is not used. The
// blocks' sizes are checked here too, not at the draw: a pack's own larger block fails
// in this scope.
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack lines"),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack lines uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack lines"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack lines"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
// The built-in outline's state: a strip per edge, reversed-Z depth test, translucent.
let pipeline = |label, depth_write| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(super::pack_lines::layout())],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(depth_write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let lines = pipeline("Resource pack block outline", true);
let secondary = pipeline("Resource pack block outline secondary", false);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's rendertype_lines shader is not used: {e}");
return None;
}
Some(Self {
lines,
secondary,
group,
uniform,
})
}
pub(super) fn uniform(&self) -> &wgpu::Buffer {
&self.uniform
}
/// Draw `edges` (instances of the outline buffer bound at slot 0) with the pack's shader:
/// the secondary stroke without depth writes. The caller sets its own group 0 again after.
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
secondary: bool,
edges: std::ops::Range<u32>,
) {
pass.set_pipeline(if secondary {
&self.secondary
} else {
&self.lines
});
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..4, edges);
}
}
/// The std140 blocks: `Globals` (`post_chain::Globals::bytes`), `Projection` (`jitter`, the lens
/// and the view bob `pose`, as vanilla's projection matrix carries it), `DynamicTransforms` (the
/// camera's rotation with the PERSPECTIVE `layering` scale, colour modulator white) and `Fog`
/// (`fog`: environmental start and end, render distance start and end, sky end, clouds end).
/// `ProjMat · ModelViewMat` is `vs_line`'s `view_proj` with its layering scale.
pub(super) fn uniforms(
globals: [u8; 48],
view: &super::View,
pose: Mat4,
jitter: Mat4,
layering: f32,
fog_color: [f32; 4],
fog: [f32; 6],
) -> Vec<u8> {
let projection = jitter * view.projection * pose;
let model_view = view.view * Mat4::from_scale(glam::Vec3::splat(layering));
let mut data = vec![0f32; 256];
data[64..80].copy_from_slice(&projection.to_cols_array());
let transforms = 128;
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
data[transforms + 32..transforms + 36].fill(1.0);
data[192..196].copy_from_slice(&fog_color);
data[196..202].copy_from_slice(&fog);
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
bytes[..48].copy_from_slice(&globals);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
use glam::{Vec3, Vec4};
const INCLUDES: [(&str, &str); 3] = [
(
"minecraft:shaders/include/globals.glsl",
"layout(std140) uniform Globals {\n ivec3 CameraBlockPos;\n float GlintAlpha;\n vec3 CameraOffset;\n float GameTime;\n vec2 ScreenSize;\n int MenuBlurRadius;\n int UseRgss;\n};\n",
),
(
"minecraft:shaders/include/dynamictransforms.glsl",
"layout(std140) uniform DynamicTransforms {\n mat4 ModelViewMat;\n mat4 TextureMat;\n vec4 ColorModulator;\n vec3 ModelOffset;\n};\n",
),
(
"minecraft:shaders/include/projection.glsl",
"layout(std140) uniform Projection {\n mat4 ProjMat;\n};\n",
),
];
fn include(id: &str) -> Option<String> {
INCLUDES
.iter()
.find(|(path, _)| *path == id)
.map(|(_, text)| (*text).to_owned())
}
/// 26.3's `rendertype_lines.vsh` in shape: a stroke `LineWidth` pixels wide across the
/// screen-space direction of `Normal`, its side from `gl_VertexIndex % 2`.
const LINES_VSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
#include <minecraft:globals.glsl>\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
layout(location = 2) in vec3 Normal;\nlayout(location = 3) in float LineWidth;\n\
layout(location = 2) out vec4 vertexColor;\n\
const mat4 SHRINK = mat4(0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 1.0);\n\
void main() {\n vec4 from = ProjMat * SHRINK * ModelViewMat * vec4(Position, 1.0);\n\
vec4 to = ProjMat * SHRINK * ModelViewMat * vec4(Position + Normal, 1.0);\n\
vec2 along = normalize((to.xy / to.w - from.xy / from.w) * ScreenSize);\n\
vec2 side = vec2(-along.y, along.x) * LineWidth / ScreenSize;\n\
if (gl_VertexIndex % 2 == 0) {\n side = -side;\n }\n\
gl_Position = vec4(from.xy / from.w + side, from.z / from.w, 1.0) * from.w;\n\
vertexColor = Color;\n}\n";
/// An RGB outline pack's fragment shader in shape: the colour from `GameTime`.
const RGB_FSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
#include <minecraft:globals.glsl>\n\
layout(location = 2) in vec4 vertexColor;\nlayout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = vec4(GameTime, 1.0 - GameTime, vertexColor.b, 1.0);\n}\n";
/// ProjMat · ModelViewMat (with the vertex shader's shrink between them) is `vs_line`'s
/// view_proj on the layered, shrunk position: the pack's outline sits where the built-in one
/// does, view bob and TAA jitter included.
#[test]
fn the_pack_matrices_put_the_outline_where_vs_line_does() {
let view = super::super::View::screen(37.0, -21.0, 70.0, 16.0 / 9.0, 256.0);
let pose =
Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.01, -0.03, 0.0));
let jitter = Mat4::from_translation(Vec3::new(0.0004, -0.0002, 0.0));
let layering = 1.0 - 1.0 / 4096.0;
let bytes = uniforms([0; 48], &view, pose, jitter, layering, [0.0; 4], [0.0; 6]);
let data: Vec<f32> = bytes
.as_chunks()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
let projection = Mat4::from_cols_slice(&data[64..80]);
let model_view = Mat4::from_cols_slice(&data[128..144]);
assert_eq!(&data[160..164], &[1.0; 4], "ColorModulator");
let shrink = 1.0 - 1.0 / 256.0;
for p in [Vec3::new(1.5, -0.2, 3.0), Vec3::new(-4.0, 2.0, -0.5)] {
let pack =
projection * Mat4::from_scale(Vec3::splat(shrink)) * model_view * p.extend(1.0);
let native = jitter * view.view_proj(pose) * (p * layering * shrink).extend(1.0);
let error: Vec4 = (pack - native).abs();
assert!(
error.max_element() < 1e-5 * native.w.abs(),
"{pack} {native}"
);
}
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// One edge across the middle of a 64 by 64 target, 4 pixels wide, blue (ARGB `ff0000ff`),
/// at `GameTime` 0.25, drawn with `program`: the RGBA rows read back.
fn draw_edge(device: &wgpu::Device, queue: &wgpu::Queue, program: &CoreProgram) -> Vec<u8> {
const SIZE: u32 = 64;
let lines = CoreLines::new(device, wgpu::TextureFormat::Rgba8Unorm, program)
.expect("the pack's pipelines");
let target = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let color = target(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::COPY_SRC,
);
let depth = target(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::empty(),
);
let view = super::super::View {
view: Mat4::IDENTITY,
projection: super::super::world_projection(70.0, 1.0, 64.0),
aspect: 1.0,
tan_half_fov: 35f32.to_radians().tan(),
};
let globals = crate::post_chain::Globals {
game_time: 0.25,
..Default::default()
};
let data = uniforms(
globals.bytes([SIZE, SIZE], 0),
&view,
Mat4::IDENTITY,
Mat4::IDENTITY,
1.0,
[0.0; 4],
[0.0; 6],
);
queue.write_buffer(lines.uniform(), 0, &data);
let mut edge = [-1.0, 0.0, -2.0, 1.0, 0.0, -2.0, 0.0, 4.0f32];
edge[6] = f32::from_bits(0xff00_00ff);
let edges = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&edge),
usage: wgpu::BufferUsages::VERTEX,
},
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_vertex_buffer(0, edges.slice(..));
lines.draw(&mut pass, false, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
read.slice(..).get_mapped_range().unwrap().to_vec()
}
/// The stroke's middle takes the pack's colour (`GameTime` red, its inverse green, the
/// vertex colour's blue); a pixel far from it stays clear.
fn assert_pack_coloured(pixels: &[u8]) {
let at = |x: usize, y: usize| &pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4];
for y in [31, 32] {
let pixel = at(32, y);
let want = [64, 191, 255, 255];
assert!(
pixel.iter().zip(want).all(|(&p, w)| p.abs_diff(w) <= 1),
"row {y}: {pixel:?}"
);
}
assert_eq!(at(32, 4), [0, 0, 0, 0]);
}
#[test]
fn a_pack_fragment_shader_colours_the_outline() {
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping");
return;
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
/// A pack's own include with a bigger block than vanilla's (an older `DynamicTransforms`)
/// is not used, and the outline draws natively, rather than failing at the draw.
#[test]
fn a_pack_block_bigger_than_vanillas_falls_back() {
let Some((device, _)) = device() else {
return;
};
let bigger = |id: &str| {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
/// Vanilla's own `rendertype_lines.vsh` (from the prepared resources) under a pack's
/// fragment shader.
#[test]
fn vanillas_lines_vertex_shader_takes_a_packs_fragment_shader() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
let Ok(vertex) = std::fs::read_to_string(
root.join("assets/minecraft/shaders/core/rendertype_lines.vsh"),
) else {
eprintln!(
"skipping resource-backed lines shader check: {}",
root.display()
);
return;
};
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
return;
};
assert_pack_coloured(&draw_edge(&device, &queue, &program));
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &vanilla).is_some());
}
}
+80 -20
View File
@@ -19,6 +19,10 @@
//! the results only sorts sections between the phases: a stale result draws
//! a hidden section in phase 1, or tests a section that is in sight.
//!
//! Far terrain tiles are tested the same way, with their meshes' own
//! bounds for a box (grown by a block too, which keeps their faces off the
//! box's), and resident slots of their own (`TILE`).
//!
//! Every solid draw is an entry of one of the two lists, so the CPU records
//! as many draws as without culling: drivers that unroll an indirect
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
@@ -39,6 +43,11 @@ use std::sync::{
};
const WORDS: u64 = 5 * 4;
/// A tested section's box, around its origin: its cell grown by a block.
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
/// count their slots apart from the sections' resident ones.
pub const TILE: u32 = 1 << 31;
/// Phase 1's sections are each tested every `RETEST` frames.
const RETEST: u32 = 8;
/// Culling runs while the last result read back hid at least this share of
@@ -63,6 +72,17 @@ const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// One test, as `cull.wgsl` reads it: visible index and phase 2 draws
/// (`place`), then its box's corners around the origin.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Test {
data: [u32; 4],
low: [f32; 4],
high: [f32; 4],
}
const TEST_BYTES: u64 = std::mem::size_of::<Test>() as u64;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
@@ -131,8 +151,10 @@ pub struct Cull {
phase2_capacity: u64,
tests: wgpu::Buffer,
tests_capacity: u64,
/// This frame's tests: visible index, first phase 2 draw, phase 2 draws.
test_data: Vec<[u32; 4]>,
/// This frame's tests: visible index, then its phase 2 draws in each
/// solid pass (`place`): the first of the first pass's, both counts (the
/// second's in the high half), the first of the second pass's; and its box.
test_data: Vec<Test>,
test_slots: Vec<u32>,
test_indices: Vec<u32>,
total: u64,
@@ -142,8 +164,10 @@ pub struct Cull {
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
/// Per resident slot: whether the last result read back saw it.
/// Per resident slot: whether the last result read back saw it; and per
/// far tile's slot (`TILE`).
seen: Vec<bool>,
seen_tiles: Vec<bool>,
stamp: u32,
/// Of the boxes the last test read back drew, how many it did not see
/// and how many it drew, and the share of solid indices hidden.
@@ -325,7 +349,7 @@ impl Cull {
),
phase2: buffer(device, "Section draws, phase 2", WORDS, PHASE2_USAGE),
phase2_capacity: 1,
tests: buffer(device, "Section tests", 16, TESTS_USAGE),
tests: buffer(device, "Section tests", TEST_BYTES, TESTS_USAGE),
tests_capacity: 1,
test_data: Vec::new(),
test_slots: Vec::new(),
@@ -347,6 +371,7 @@ impl Cull {
next: 0,
current: None,
seen: Vec::new(),
seen_tiles: Vec::new(),
stamp: 0,
hidden: ([0; 2], 1.0),
always: false,
@@ -378,11 +403,15 @@ impl Cull {
let share = indices as f32 / r.total.max(1) as f32;
self.hidden = ([hidden, results.len()], share);
for (&slot, &result) in r.slots.iter().zip(&results) {
let slot = slot as usize;
if slot >= self.seen.len() {
self.seen.resize(slot + 1, false);
let (seen, slot) = if slot & TILE != 0 {
(&mut self.seen_tiles, (slot & !TILE) as usize)
} else {
(&mut self.seen, slot as usize)
};
if slot >= seen.len() {
seen.resize(slot + 1, false);
}
self.seen[slot] = result == r.stamp;
seen[slot] = result == r.stamp;
}
}
r.buffer.unmap();
@@ -397,30 +426,61 @@ impl Cull {
!self.probe || self.stamp.is_multiple_of(PROBE)
}
/// Whether phase 1 draws the section in resident `slot`.
/// Whether phase 1 draws the section in resident `slot` (or the far tile
/// in its `TILE` slot).
pub fn first(&self, slot: u32) -> bool {
if slot & TILE != 0 {
return self
.seen_tiles
.get((slot & !TILE) as usize)
.copied()
.unwrap_or(false);
}
self.seen.get(slot as usize).copied().unwrap_or(false)
}
/// A section phase 1 draws, `index` in the visible list, with `indices`
/// solid indices: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32) {
/// solid indices and the box `bounds` around its origin: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32, bounds: [[f32; 3]; 2]) {
self.total += u64::from(indices);
if indices > 0 && (self.probe || slot.wrapping_add(self.stamp).is_multiple_of(RETEST)) {
self.push([index as u32, 0, 0, 0], slot, indices);
self.push(index, bounds, slot, indices);
}
}
/// A section left to phase 2: its `draws` from phase 2's `first`.
pub fn deferred(&mut self, index: usize, slot: u32, first: usize, draws: usize, indices: u32) {
/// A section left to phase 2, with `draws` draws there: its test, whose
/// draws `place` gives once phase 2's list is in its final order.
pub fn deferred(
&mut self,
index: usize,
slot: u32,
draws: usize,
indices: u32,
bounds: [[f32; 3]; 2],
) -> Option<u32> {
self.total += u64::from(indices);
if draws > 0 {
self.push([index as u32, first as u32, draws as u32, 0], slot, indices);
}
(draws > 0).then(|| {
self.push(index, bounds, slot, indices);
self.test_data.len() as u32 - 1
})
}
fn push(&mut self, test: [u32; 4], slot: u32, indices: u32) {
self.test_data.push(test);
/// `test`'s draws in solid pass `pass` (0 or 1): `draws` from phase 2's
/// `first`. Each pass draws its own part of the section's quads.
pub fn place(&mut self, test: u32, pass: u32, first: u32, draws: u32) {
debug_assert!(pass < 2 && draws < 1 << 16);
let t = &mut self.test_data[test as usize].data;
t[1 + 2 * pass as usize] = first;
t[2] |= draws << (16 * pass);
}
fn push(&mut self, index: usize, bounds: [[f32; 3]; 2], slot: u32, indices: u32) {
let [low, high] = bounds.map(|[x, y, z]| [x, y, z, 0.0]);
self.test_data.push(Test {
data: [index as u32, 0, 0, 0],
low,
high,
});
self.test_slots.push(slot);
self.test_indices.push(indices);
}
@@ -454,7 +514,7 @@ impl Cull {
self.tests = buffer(
device,
"Section tests",
self.tests_capacity * 16,
self.tests_capacity * TEST_BYTES,
TESTS_USAGE,
);
self.results = buffer(
+36 -20
View File
@@ -11,10 +11,19 @@ struct Globals {
view_proj: mat4x4<f32>,
}
// One test per section: its visible index (which names its origin), its
// first draw in `phase2` and how many draws it has there (0: phase 1 drew
// it, and the test only tells whether it is still in sight).
@group(0) @binding(0) var<storage, read> tests: array<vec4<u32>>;
// One test per section or far tile: its visible index (which names its
// origin), then its draws in `phase2` for each solid pass (gfx/cull.rs's
// `place`): the first pass's first draw, both passes' draw counts (the
// second's in the high 16 bits), the second pass's first draw. No draws:
// phase 1 drew it, and the test only tells whether it is still in sight.
// Then its box's lowest and highest corner around the origin (a section's:
// -1 and 17, `SECTION_BOX`).
struct Test {
data: vec4<u32>,
low: vec4<f32>,
high: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> tests: array<Test>;
// Per test: the stamp of the frame whose test saw the box.
@group(0) @binding(1) var<storage, read_write> results: array<u32>;
// Phase 2's draws, 5 words each, with instance counts of 0 until seen.
@@ -36,23 +45,27 @@ struct Box {
@location(0) @interpolate(flat) test: u32,
}
fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f32> {
return origin + vec3(
select(-1.0, 17.0, (c & 1u) != 0u),
select(-1.0, 17.0, (c & 2u) != 0u),
select(-1.0, 17.0, (c & 4u) != 0u),
select(low.x, high.x, (c & 1u) != 0u),
select(low.y, high.y, (c & 2u) != 0u),
select(low.z, high.z, (c & 4u) != 0u),
);
}
// Each tested section's box, grown by a block (block models reach one block
// out of their cell), drawn after phase 1 against its depth with back faces
// culled: a front face fragment that passes the depth test is the box's
// nearest point on that pixel in front of phase 1's depth, so the section
// may show there. A box failing everywhere would have every fragment of its
// section fail the depth test against the final depth, which is only nearer.
// Each tested section's or far tile's box, grown by a block (block models
// reach one block out of their cell), drawn after phase 1 against its depth
// with back faces culled: a front face fragment that passes the depth test
// is the box's nearest point on that pixel in front of phase 1's depth, so
// the section may show there. A box failing everywhere would have every
// fragment of its section fail the depth test against the final depth,
// which is only nearer.
@vertex
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
let origin = origins[tests[t].x].xyz;
let test = tests[t];
let origin = origins[test.data.x].xyz;
let low = test.low.xyz;
let high = test.high.xyz;
var out: Box;
out.test = t;
// The box's screen rectangle, from the corners in front of the camera.
@@ -60,7 +73,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
var hi = vec2(-1e30);
var behind = false;
for (var c = 0u; c < 8u; c++) {
let p = globals.view_proj * vec4(corner(origin, c), 1.0);
let p = globals.view_proj * vec4(corner(origin, low, high, c), 1.0);
behind = behind || p.w <= 0.0;
let q = p.xy / max(p.w, 1e-6);
lo = min(lo, q);
@@ -71,7 +84,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
// clips its front faces away, and a box over a large part of the screen
// is nearly always in sight and costly to test: both count as seen,
// through a small triangle in the top left corner at the nearest depth.
let near = all(origin < vec3(2.0)) && all(origin > vec3(-18.0));
let near = all(origin < vec3(1.0) - low) && all(origin > vec3(-1.0) - high);
if near || (!behind && size.x * size.y > params.big * 4.0) {
var p = vec2(-1.0, 1.0);
if v == 1u {
@@ -82,7 +95,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
return out;
}
out.position = globals.view_proj * vec4(corner(origin, CORNERS[v]), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
return out;
}
@@ -91,10 +104,13 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
fn see(t: u32) {
if results[t] != params.stamp {
results[t] = params.stamp;
let test = tests[t];
for (var k = 0u; k < test.z; k++) {
let test = tests[t].data;
for (var k = 0u; k < (test.z & 0xffffu); k++) {
phase2[(test.y + k) * 5u + 1u] = 1u;
}
for (var k = 0u; k < (test.z >> 16u); k++) {
phase2[(test.w + k) * 5u + 1u] = 1u;
}
}
}
@@ -0,0 +1,707 @@
//! `[displacement]` of Horizon's own shader packs (docs/api/shaders.md): near the camera the
//! solid faces whose textures have LabPBR heights are drawn again as a grid of cells, each
//! corner moved along the face's normal by the pack's `displacement`, while the flat quads
//! leave those pixels to them (`native_material.rs`'s `horizon_displaced_layer`).
//!
//! Each frame the solid quads of the sections around the camera are copied out of the arena
//! into one buffer, a compute pass picks the ones the grid draws (opaque, with relief, within
//! the distance, facing the eye and in view) into an indirect draw, and the material's
//! displaced pipeline draws one instance a quad.
use crate::mesher::TerrainVertex;
/// The solid quads the grid can draw a frame, and the sections they come from.
const MAX_QUADS: u64 = 1 << 16;
const MAX_SECTIONS: usize = 64;
/// Bytes of one quad's corners.
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
/// How far past the flat face the relief may reach, in blocks, for the view test.
const RELIEF_MARGIN: f32 = 0.5;
/// Which quads the grid takes over, for the flat terrain's pipelines (the material's and the
/// shadow's) and the grid's: `horizon_displace_distance` and the LabPBR normal maps
/// `horizon_pbr_normals` (group 3, binding 12), declared by the module.
pub const RELIEF: &str = "
// `[displacement] distance` (0 without): the flat quads leave the pixels within it on faces
// with relief to the grid.
override horizon_displace_distance: f32 = 0.0;
// The mean LabPBR height of a sprite: its normal map's last mip level (1 without one).
fn horizon_relief_mean(layer: u32) -> f32 {
if textureDimensions(horizon_pbr_normals).x <= 1u
|| layer >= textureNumLayers(horizon_pbr_normals) {
return 1.0;
}
let top = i32(textureNumLevels(horizon_pbr_normals)) - 1;
return textureLoad(horizon_pbr_normals, vec2(0i), i32(layer), top).a;
}
// The quads the grid redraws near the camera (`PICK`): opaque, on a texture whose height
// varies.
fn horizon_displaced_layer(layer: u32) -> bool {
return horizon_displace_distance > 0.0 && (layer & 0x8000u) != 0u
&& horizon_relief_mean(layer & 0x7fffu) < 0.998;
}
";
/// The grid's points (`horizon_displace_point`): one instance a quad the pick chose, its
/// corners read from the copy of the arena, each of its `horizon_displace_cells` squared
/// cells two triangles moved along the face's normal by the pack's `displacement`, measured
/// from the texture's mean height so that the face's edges, pinned there, meet their
/// neighbours whatever their relief. Needs `RELIEF`, the pack's `displacement` and
/// `horizon_material_displace` (its vertex stage).
pub const GRID: &str = "
struct HorizonDisplaceSection {
origin: vec4<f32>,
first: u32,
quads: u32,
pad0: u32,
pad1: u32,
}
@group(1) @binding(8) var<storage, read> horizon_displace_vertices: array<u32>;
@group(1) @binding(9) var<storage, read> horizon_displace_picked: array<vec2<u32>>;
@group(1) @binding(10) var<storage, read> horizon_displace_sections: array<HorizonDisplaceSection>;
// The grid's cells along a face's side.
override horizon_displace_cells: u32 = 16u;
// Each cell a flat step with walls to its neighbours (`grid_vertices`), not a smooth surface.
override horizon_displace_steps: bool = false;
struct HorizonDisplaceCorner {
position: vec3<f32>,
uv: vec2<f32>,
layer: u32,
light: vec2<f32>,
color: vec4<f32>,
}
// A corner of a quad as `TerrainVertex` holds it (24 bytes); light: sky, block.
fn horizon_displace_corner(quad: u32, i: u32, step: f32) -> HorizonDisplaceCorner {
let b = (quad * 4u + i) * 6u;
var c: HorizonDisplaceCorner;
c.position = vec3(bitcast<f32>(horizon_displace_vertices[b]),
bitcast<f32>(horizon_displace_vertices[b + 1u]),
bitcast<f32>(horizon_displace_vertices[b + 2u]));
let uv = horizon_displace_vertices[b + 3u];
c.uv = vec2<f32>(vec2(uv & 0xffffu, uv >> 16u)) * step;
let w = horizon_displace_vertices[b + 4u];
c.layer = w & 0xffffu;
c.light = vec2(f32((w >> 16u) & 0xffu), f32(w >> 24u));
c.color = unpack4x8unorm(horizon_displace_vertices[b + 5u]);
return c;
}
// The height map at `uv`, filtered between the texels of mip level `lod`.
fn horizon_displace_height(uv: vec2<f32>, layer: u32, lod: i32) -> f32 {
let size = vec2<i32>(textureDimensions(horizon_pbr_normals, lod));
let p = clamp(uv, vec2(0.0), vec2(1.0)) * vec2<f32>(size) - 0.5;
let i = vec2<i32>(floor(p));
let f = p - floor(p);
let m = size - 1;
let l = i32(layer);
let a = textureLoad(horizon_pbr_normals, clamp(i, vec2(0), m), l, lod).a;
let b = textureLoad(horizon_pbr_normals, clamp(i + vec2(1, 0), vec2(0), m), l, lod).a;
let c = textureLoad(horizon_pbr_normals, clamp(i + vec2(0, 1), vec2(0), m), l, lod).a;
let d = textureLoad(horizon_pbr_normals, clamp(i + vec2(1, 1), vec2(0), m), l, lod).a;
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// The height map's texel at `uv` of mip level `lod`.
fn horizon_displace_texel(uv: vec2<f32>, layer: u32, lod: i32) -> f32 {
let size = vec2<i32>(textureDimensions(horizon_pbr_normals, lod));
let i = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2(0), size - 1);
return textureLoad(horizon_pbr_normals, i, i32(layer), lod).a;
}
struct HorizonDisplacePoint {
// Where it is drawn, and where it lies on the flat face (both camera-relative).
position: vec3<f32>,
base: vec3<f32>,
uv: vec2<f32>,
layer: u32,
light: vec2<f32>,
color: vec4<f32>,
// The face's normal, and how much of the relief the point has by its distance.
normal: vec3<f32>,
near: f32,
}
// A quad's corners, for the steps.
struct HorizonDisplaceQuad {
c0: HorizonDisplaceCorner,
c1: HorizonDisplaceCorner,
c2: HorizonDisplaceCorner,
c3: HorizonDisplaceCorner,
origin: vec3<f32>,
normal: vec3<f32>,
}
fn horizon_displace_at(q: HorizonDisplaceQuad, s: f32, t: f32) -> vec3<f32> {
return mix(mix(q.c0.position, q.c1.position, s), mix(q.c3.position, q.c2.position, s), t)
+ q.origin;
}
fn horizon_displace_uv(q: HorizonDisplaceQuad, s: f32, t: f32) -> vec2<f32> {
return mix(mix(q.c0.uv, q.c1.uv, s), mix(q.c3.uv, q.c2.uv, s), t);
}
// How far cell (`i`, `j`) sinks: nothing outside the face, whose edges keep the mean height so
// that they meet their neighbours.
fn horizon_displace_cell_depth(q: HorizonDisplaceQuad, i: i32, j: i32) -> f32 {
let g = i32(horizon_displace_cells);
if i < 0 || j < 0 || i >= g || j >= g {
return 0.0;
}
let s = (f32(i) + 0.5) / f32(g);
let t = (f32(j) + 0.5) / f32(g);
let base = horizon_displace_at(q, s, t);
let uv = horizon_displace_uv(q, s, t);
let layer = q.c0.layer & 0x7fffu;
let lods = i32(textureNumLevels(horizon_pbr_normals)) - 1;
let size = f32(textureDimensions(horizon_pbr_normals).x);
let lod = clamp(i32(floor(log2(size / f32(g)))), 0, lods);
let material_id = horizon_sprite_material(layer);
let height = horizon_displace_texel(uv, layer, lod);
let mean = horizon_relief_mean(layer);
let depth = displacement(DisplacementInput(base, q.normal, uv, material_id, height))
- displacement(DisplacementInput(base, q.normal, uv, material_id, mean));
let r = horizon_displace_distance;
return depth * (1.0 - smoothstep(0.75 * r, r - 0.1, length(base)));
}
// Vertex `v` of a stepped grid: per cell, from -1 so that the first walls have a cell before
// them, its flat top and its walls to the next cell along each side. A wall faces the way
// out of the higher cell and takes that cell's texture.
fn horizon_displace_step(v: u32, q: HorizonDisplaceQuad) -> HorizonDisplacePoint {
let g = horizon_displace_cells;
let cell = v / 18u;
let part = (v / 6u) % 3u;
let i = i32(cell % (g + 1u)) - 1;
let j = i32(cell / (g + 1u)) - 1;
let cells = f32(g);
var k = v % 6u;
var s = 0.0;
var t = 0.0;
var uv_s = 0.0;
var uv_t = 0.0;
var depth = 0.0;
if part == 0u {
if i >= 0 && j >= 0 {
s = (f32(i) + f32((0x16u >> k) & 1u)) / cells;
t = (f32(j) + f32((0x34u >> k) & 1u)) / cells;
depth = horizon_displace_cell_depth(q, i, j);
}
uv_s = s;
uv_t = t;
} else if part == 1u {
if j >= 0 {
let here = horizon_displace_cell_depth(q, i, j);
let next = horizon_displace_cell_depth(q, i + 1, j);
if next < here {
k = (0x453120u >> (k * 4u)) & 15u;
}
s = f32(i + 1) / cells;
t = (f32(j) + f32((0x16u >> k) & 1u)) / cells;
depth = select(max(here, next), min(here, next), ((0x34u >> k) & 1u) == 1u);
uv_s = s + select(0.25, -0.25, here <= next) / cells;
uv_t = t;
}
} else {
if i >= 0 {
let here = horizon_displace_cell_depth(q, i, j);
let next = horizon_displace_cell_depth(q, i, j + 1);
if here < next {
k = (0x453120u >> (k * 4u)) & 15u;
}
s = (f32(i) + f32((0x16u >> k) & 1u)) / cells;
t = f32(j + 1) / cells;
depth = select(max(here, next), min(here, next), ((0x34u >> k) & 1u) == 1u);
uv_s = s;
uv_t = t + select(0.25, -0.25, here <= next) / cells;
}
}
var p: HorizonDisplacePoint;
let position = horizon_displace_at(q, s, t);
let c = clamp(vec2(uv_s, uv_t), vec2(0.0), vec2(1.0));
p.uv = horizon_displace_uv(q, c.x, c.y);
p.light = mix(mix(q.c0.light, q.c1.light, s), mix(q.c3.light, q.c2.light, s), t);
p.color = mix(mix(q.c0.color, q.c1.color, s), mix(q.c3.color, q.c2.color, s), t);
p.layer = q.c0.layer;
p.normal = q.normal;
p.base = horizon_material_displace(position, p.uv, q.c0.layer, p.color,
vec2<u32>(p.light + 0.5));
p.near = 1.0 - smoothstep(0.75 * horizon_displace_distance, horizon_displace_distance - 0.1,
length(p.base));
p.position = p.base - p.normal * depth;
return p;
}
// Vertex `v` of instance `instance`'s grid.
fn horizon_displace_point(v: u32, instance: u32) -> HorizonDisplacePoint {
let pick = horizon_displace_picked[instance];
let section = horizon_displace_sections[pick.y];
let c0 = horizon_displace_corner(pick.x, 0u, section.origin.w);
let c1 = horizon_displace_corner(pick.x, 1u, section.origin.w);
let c2 = horizon_displace_corner(pick.x, 2u, section.origin.w);
let c3 = horizon_displace_corner(pick.x, 3u, section.origin.w);
if horizon_displace_steps {
return horizon_displace_step(v, HorizonDisplaceQuad(c0, c1, c2, c3, section.origin.xyz,
normalize(cross(c1.position - c0.position, c2.position - c0.position))));
}
// The cell's two triangles wind as the quad's (0, 1, 2) and (0, 2, 3).
let g = horizon_displace_cells;
let cell = v / 6u;
let k = v % 6u;
let s = f32(cell % g + ((0x16u >> k) & 1u)) / f32(g);
let t = f32(cell / g + ((0x34u >> k) & 1u)) / f32(g);
var p: HorizonDisplacePoint;
let position = mix(mix(c0.position, c1.position, s), mix(c3.position, c2.position, s), t);
p.uv = mix(mix(c0.uv, c1.uv, s), mix(c3.uv, c2.uv, s), t);
p.light = mix(mix(c0.light, c1.light, s), mix(c3.light, c2.light, s), t);
p.color = mix(mix(c0.color, c1.color, s), mix(c3.color, c2.color, s), t);
p.layer = c0.layer;
p.normal = normalize(cross(c1.position - c0.position, c2.position - c0.position));
p.base = horizon_material_displace(position + section.origin.xyz, p.uv, c0.layer, p.color,
vec2<u32>(p.light + 0.5));
let layer = c0.layer & 0x7fffu;
// The heights at about a texel a cell.
let lods = i32(textureNumLevels(horizon_pbr_normals)) - 1;
let size = f32(textureDimensions(horizon_pbr_normals).x);
let lod = clamp(i32(floor(log2(size / f32(g)))), 0, lods);
let material_id = horizon_sprite_material(layer);
let height = horizon_displace_height(p.uv, layer, lod);
let mean = horizon_relief_mean(layer);
let depth = displacement(DisplacementInput(p.base, p.normal, p.uv, material_id, height))
- displacement(DisplacementInput(p.base, p.normal, p.uv, material_id, mean));
// Pinned at the face's edges, easing out toward the distance.
let edge = min(min(s, 1.0 - s), min(t, 1.0 - t)) * f32(g);
let r = horizon_displace_distance;
p.near = 1.0 - smoothstep(0.75 * r, r - 0.1, length(p.base));
p.position = p.base - p.normal * depth * clamp(edge, 0.0, 1.0) * p.near;
return p;
}
";
const PICK: &str = "
struct Params {
view_proj: mat4x4<f32>,
distance: f32,
sections: u32,
margin: f32,
capacity: u32,
// 1 for the shadow map's list: every side, wherever the eye looks.
shadow: u32,
pad0: u32,
pad1: u32,
pad2: u32,
}
struct Section {
origin: vec4<f32>,
first: u32,
quads: u32,
pad0: u32,
pad1: u32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> sections: array<Section>;
@group(0) @binding(2) var<storage, read> vertices: array<u32>;
@group(0) @binding(3) var<storage, read_write> picked: array<vec2<u32>>;
@group(0) @binding(4) var<storage, read_write> draw: array<atomic<u32>, 4>;
@group(0) @binding(5) var normals: texture_2d_array<f32>;
fn corner(quad: u32, i: u32, origin: vec3<f32>) -> vec3<f32> {
let b = (quad * 4u + i) * 6u;
return vec3(bitcast<f32>(vertices[b]), bitcast<f32>(vertices[b + 1u]),
bitcast<f32>(vertices[b + 2u])) + origin;
}
// Every corner of the box beyond one side of the view.
fn outside(lo: vec3<f32>, hi: vec3<f32>) -> bool {
var out = vec4(true);
var behind = true;
for (var i = 0u; i < 8u; i++) {
let p = select(lo, hi, vec3((i & 1u) != 0u, (i & 2u) != 0u, (i & 4u) != 0u));
let c = params.view_proj * vec4(p, 1.0);
out = out & vec4((c.x < -c.w), (c.x > c.w), (c.y < -c.w), (c.y > c.w));
behind = behind && c.w <= 0.0;
}
return any(out) || behind;
}
@compute @workgroup_size(64) fn pick(@builtin(global_invocation_id) id: vec3<u32>) {
if id.y >= params.sections {
return;
}
let section = sections[id.y];
if id.x >= section.quads {
return;
}
let quad = section.first + id.x;
// As `horizon_displaced_layer`: opaque, with a LabPBR height that varies.
let layer = vertices[quad * 24u + 4u] & 0xffffu;
let l = layer & 0x7fffu;
if (layer & 0x8000u) == 0u || textureDimensions(normals).x <= 1u
|| l >= textureNumLayers(normals) {
return;
}
let top = i32(textureNumLevels(normals)) - 1;
if textureLoad(normals, vec2(0i), i32(l), top).a >= 0.998 {
return;
}
let p0 = corner(quad, 0u, section.origin.xyz);
let p1 = corner(quad, 1u, section.origin.xyz);
let p2 = corner(quad, 2u, section.origin.xyz);
let p3 = corner(quad, 3u, section.origin.xyz);
let lo = min(min(p0, p1), min(p2, p3));
let hi = max(max(p0, p1), max(p2, p3));
if length(clamp(vec3(0.0), lo, hi)) >= params.distance {
return;
}
// Facing away from the eye (at the origin), where the flat quad is culled too, or out of
// its view; the shadow map takes them all, as the flat terrain draws both sides there.
if params.shadow == 0u && (dot(cross(p1 - p0, p2 - p0), p0) >= 0.0
|| outside(lo - params.margin, hi + params.margin)) {
return;
}
let i = atomicAdd(&draw[1], 1u);
if i >= params.capacity {
atomicSub(&draw[1], 1u);
return;
}
picked[i] = vec2(quad, id.y);
}
";
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
view_proj: [[f32; 4]; 4],
distance: f32,
sections: u32,
margin: f32,
capacity: u32,
shadow: u32,
pad: [u32; 3],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Section {
origin: [f32; 4],
first: u32,
quads: u32,
pad: [u32; 2],
}
/// A section the grid may draw from this frame: its arena range of solid quads and its
/// camera-relative origin.
pub struct Near<'a> {
pub buffer: &'a wgpu::Buffer,
pub start: u64,
pub quads: u32,
pub origin: [f32; 3],
}
/// One list of picked quads and its indirect draw.
pub struct Picks {
/// The grid's cells along a face's side.
pub cells: u32,
/// Each cell a flat step with walls.
pub steps: bool,
params: wgpu::Buffer,
/// The indirect draw: the grid's vertices, then the quads picked.
draw: wgpu::Buffer,
pick_group: wgpu::BindGroup,
/// Group 1 of the pipelines drawing it (bindings 8 to 10).
pub draw_group: wgpu::BindGroup,
}
pub struct Displacement {
/// `[displacement] distance`, in blocks.
pub distance: f32,
vertices: wgpu::Buffer,
sections: wgpu::Buffer,
pick: wgpu::ComputePipeline,
/// The quads the eye sees, and the ones the shadow map draws (when the pack has one).
pub view: Picks,
pub shadow: Option<Picks>,
/// Group 1's layout for the grid's pipelines.
pub draw_layout: wgpu::BindGroupLayout,
/// Sections the pick ran over this frame (0: nothing to draw).
pub drawn: usize,
}
fn storage(
binding: u32,
read_only: bool,
visibility: wgpu::ShaderStages,
) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
/// The shadow map's grid is coarser: its texels near the camera are a 32nd of a block.
pub const SHADOW_CELLS: u32 = 16;
/// The vertices of one quad's grid: two triangles a cell, or in `steps` also two walls a cell,
/// with a row and a column more for the walls at the face's first edges.
fn grid_vertices(cells: u32, steps: bool) -> u32 {
if steps {
(cells + 1) * (cells + 1) * 18
} else {
cells * cells * 6
}
}
impl Displacement {
/// The grid's buffers and pick pass for `[displacement]` with `distance` and `cells`, in
/// `steps` or not, reading the LabPBR normal maps `normals`; with a list for the shadow map
/// when `shadow`.
pub fn new(
device: &wgpu::Device,
distance: f32,
cells: u32,
steps: bool,
normals: &wgpu::TextureView,
shadow: bool,
) -> Self {
let buffer = |label, size, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage,
mapped_at_creation: false,
})
};
use wgpu::BufferUsages as U;
let vertices = buffer(
"Displacement quads",
MAX_QUADS * QUAD_BYTES,
U::STORAGE | U::COPY_DST,
);
let sections = buffer(
"Displacement sections",
(MAX_SECTIONS * std::mem::size_of::<Section>()) as u64,
U::STORAGE | U::COPY_DST,
);
let compute = wgpu::ShaderStages::COMPUTE;
let pick_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Displacement pick"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: compute,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
storage(1, true, compute),
storage(2, true, compute),
storage(3, false, compute),
storage(4, false, compute),
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: compute,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
],
});
let vertex = wgpu::ShaderStages::VERTEX;
let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Displacement draw"),
entries: &[
storage(8, true, vertex),
storage(9, true, vertex),
storage(10, true, vertex),
],
});
fn entry(binding: u32, resource: wgpu::BindingResource<'_>) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry { binding, resource }
}
let picks = |cells: u32| {
let params = buffer(
"Displacement parameters",
std::mem::size_of::<Params>() as u64,
U::UNIFORM | U::COPY_DST,
);
let picked = buffer("Displacement picks", MAX_QUADS * 8, U::STORAGE);
let draw = buffer(
"Displacement draw",
16,
U::STORAGE | U::INDIRECT | U::COPY_DST,
);
let pick_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Displacement pick"),
layout: &pick_layout,
entries: &[
entry(0, params.as_entire_binding()),
entry(1, sections.as_entire_binding()),
entry(2, vertices.as_entire_binding()),
entry(3, picked.as_entire_binding()),
entry(4, draw.as_entire_binding()),
entry(5, wgpu::BindingResource::TextureView(normals)),
],
});
let draw_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Displacement draw"),
layout: &draw_layout,
entries: &[
entry(8, vertices.as_entire_binding()),
entry(9, picked.as_entire_binding()),
entry(10, sections.as_entire_binding()),
],
});
Picks {
cells,
steps,
params,
draw,
pick_group,
draw_group,
}
};
let view = picks(cells);
// A step's flat top has to lie where its shadow was cast from, or it shadows itself in
// grain; a smooth surface hides the difference and can take fewer cells.
let shadow = shadow.then(|| {
picks(if steps {
cells
} else {
cells.min(SHADOW_CELLS)
})
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Displacement pick"),
source: wgpu::ShaderSource::Wgsl(PICK.into()),
});
let pick = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Displacement pick"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Displacement pick"),
bind_group_layouts: &[Some(&pick_layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some("pick"),
compilation_options: Default::default(),
cache: None,
});
Self {
distance,
vertices,
sections,
pick,
view,
shadow,
draw_layout,
drawn: 0,
}
}
/// Copies the solid quads of `near` (nearest first) and picks this frame's in `encoder`.
pub fn prepare(
&mut self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
near: &[Near<'_>],
view_proj: glam::Mat4,
) {
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
let mut first = 0u64;
let mut most = 0u32;
for n in near.iter().take(MAX_SECTIONS) {
let quads = u64::from(n.quads).min(MAX_QUADS - first);
if quads == 0 {
break;
}
let stride = std::mem::size_of::<TerrainVertex>() as u64;
encoder.copy_buffer_to_buffer(
n.buffer,
n.start * stride,
&self.vertices,
first * QUAD_BYTES,
quads * QUAD_BYTES,
);
table.push(Section {
origin: [
n.origin[0],
n.origin[1],
n.origin[2],
1.0 / crate::mesher::UV_STEPS,
],
first: first as u32,
quads: quads as u32,
pad: [0; 2],
});
first += quads;
most = most.max(quads as u32);
}
self.drawn = table.len();
if table.is_empty() {
return;
}
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Displacement pick"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pick);
for (picks, shadow) in lists {
let params = Params {
view_proj: view_proj.to_cols_array_2d(),
distance: self.distance,
sections: table.len() as u32,
margin: RELIEF_MARGIN,
capacity: MAX_QUADS as u32,
shadow,
pad: [0; 3],
};
queue.write_buffer(&picks.params, 0, bytemuck::bytes_of(&params));
let vertices = grid_vertices(picks.cells, picks.steps);
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
pass.set_bind_group(0, &picks.pick_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
}
}
/// Draws `picks`' grids with `pipeline`, its other groups bound.
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
picks: &Picks,
pipeline: &wgpu::RenderPipeline,
) {
if self.drawn == 0 {
return;
}
pass.set_pipeline(pipeline);
pass.set_bind_group(1, &picks.draw_group, &[]);
pass.draw_indirect(&picks.draw, 0);
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_stepped_grid_has_a_top_and_two_walls_a_cell_and_the_first_edges() {
assert_eq!(super::grid_vertices(16, false), 16 * 16 * 6);
assert_eq!(super::grid_vertices(16, true), 17 * 17 * 18);
}
#[test]
fn the_pick_validates() {
let module = naga::front::wgsl::parse_str(super::PICK)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::PICK)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::PICK)));
}
}
+158 -53
View File
@@ -42,8 +42,9 @@ struct Plan {
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
pass_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls,
/// and where its cutout pass begins in phase 2's.
pass_calls: [usize; 3],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -53,10 +54,15 @@ const GROUP_LIMIT: u32 = 65535;
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
/// terrain.wgsl's `arena_page`: the page the solid draws read their vertices from.
pub const ARENA_BINDING: u32 = 19;
struct PageTable {
/// Section id of every block of the page.
blocks: wgpu::Buffer,
group: Option<wgpu::BindGroup>,
/// The section arena's page `group` binds at `ARENA_BINDING` (`bind_pages`).
arena: Option<wgpu::Buffer>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
@@ -81,8 +87,9 @@ pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
@@ -128,8 +135,9 @@ pub struct Flat {
pub phase1_calls: usize,
pub solid_calls: usize,
/// Metal's two solid passes over phase 1: the opaque one draws
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
pub pass_calls: [usize; 2],
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`;
/// over phase 2, `calls[phase1_calls..pass_calls[2]]` and the rest.
pub pass_calls: [usize; 3],
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
@@ -137,6 +145,10 @@ pub struct Flat {
/// Frames with fewer sections in view keep the per-section draws.
pub min_sections: usize,
range_align: u64,
/// Phase 2's calls draw their page key as their first instance, as `draw_paged` does:
/// the visibility buffer's ID pass (`visbuffer`) reads it. The other flat pipelines
/// have no instance-rate inputs.
pub page_instances: bool,
}
/// Sections in view (and distant tiles) from which a frame is drawn flat.
@@ -247,7 +259,8 @@ fn buffer(
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too,
/// and phase 2's from `passes[2]` on (its cutout pass's).
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -255,7 +268,7 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
passes: [usize; 3],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
@@ -267,7 +280,7 @@ fn plan(
segments.clear();
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut pass_calls = [None; 3];
let mut total = 0u64;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
@@ -329,14 +342,22 @@ fn plan(
phase1_calls,
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
pass_calls: {
let [opaque, cutout, phase2] = pass_calls.map(|c| c.unwrap_or(calls.len()));
[
opaque.min(phase1_calls),
cutout.min(phase1_calls),
phase2.clamp(phase1_calls, solid_calls),
]
},
total,
}
}
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -349,13 +370,18 @@ impl Flat {
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Flat section draws"),
entries: &[storage(0, vertex, true), storage(1, vertex, true)],
entries: &[
storage(0, vertex, true),
storage(1, vertex, true),
storage(ARENA_BINDING, vertex, true),
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Flat terrain layout"),
@@ -404,38 +430,44 @@ impl Flat {
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"vs_section_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
true,
),
translucent: pipeline(
&layout,
@@ -444,6 +476,7 @@ impl Flat {
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
false,
),
translucent_mark: pipeline(
&layout,
@@ -452,6 +485,7 @@ impl Flat {
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
false,
),
flatten,
compute_layout,
@@ -490,11 +524,12 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
pass_calls: [0; 2],
pass_calls: [0; 3],
shadow_calls: [0; 2],
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
page_instances: false,
}
}
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
@@ -539,13 +574,15 @@ impl Flat {
self.pages.resize_with(page + 1, || None);
}
let table = self.pages[page].get_or_insert_with(|| PageTable {
// Copied whole into the visibility buffer's table of every page (`visbuffer`).
blocks: buffer(
device,
"Section ids by block",
blocks * 4,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
),
group: None,
arena: None,
});
let first = start / GRAIN;
let ids = vec![id; vertices.div_ceil(GRAIN) as usize];
@@ -606,7 +643,7 @@ impl Flat {
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
passes: [usize; 3],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
@@ -680,7 +717,7 @@ impl Flat {
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
self.pass_calls = [0; 2];
self.pass_calls = [0; 3];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -709,10 +746,11 @@ impl Flat {
);
// Phase 2's calls start empty, each at the start of its room.
self.phase2_call_data.clear();
let page_instances = self.page_instances;
self.phase2_call_data.extend(
self.calls[phase1_calls..solid_calls]
.iter()
.map(|c| [0, 1, c.first, 0, 0]),
.map(|c| [0, 1, c.first, 0, if page_instances { c.page } else { 0 }]),
);
let phase2_calls = self.phase2_call_data.len() as u64;
if phase2_calls > self.phase2_call_capacity {
@@ -807,29 +845,53 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
for page in self.calls.iter().map(|c| c.page) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
if table.group.is_none() {
table.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flat section draws"),
layout: &self.draw_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: table.blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.origins.as_entire_binding(),
},
],
}));
}
}
true
}
/// Gives every page table its bind group (`bind_page`): its blocks, the origins and, for
/// a page of the section arena, that page (`arena`, by page) for the solid draws to read.
/// Before each frame's draws, flat or not: the per-section solid draws bind the groups too
/// (`page_group`), and a page that grew is another buffer.
pub fn bind_pages<'a>(
&mut self,
device: &wgpu::Device,
arena: impl Fn(u32) -> Option<&'a wgpu::Buffer>,
) {
for (key, slot) in self.pages.iter_mut().enumerate() {
let Some(table) = slot else {
continue;
};
let page = (key < TRANSLUCENT_KEYS as usize)
.then(|| arena(key as u32))
.flatten();
if table.group.is_some() && table.arena.as_ref() == page {
continue;
}
table.arena = page.cloned();
table.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flat section draws"),
layout: &self.draw_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: table.blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.origins.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: ARENA_BINDING,
resource: page.unwrap_or(&self.empty).as_entire_binding(),
},
],
}));
}
}
/// The bind group of the section arena's page `page` (`bind_pages`), which the solid
/// draws read their vertices through.
pub fn page_group(&self, page: u32) -> Option<&wgpu::BindGroup> {
self.pages.get(page as usize)?.as_ref()?.group.as_ref()
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(
&self,
@@ -879,6 +941,46 @@ impl Flat {
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// `draw`, each call as the instance its page key names: the visibility buffer's ID
/// pass (`visbuffer`) tells the pages apart by it.
pub(super) fn draw_paged<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for call in calls {
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed(
call.first..call.first + call.count,
0,
call.page..call.page + 1,
);
}
}
/// Bytes of its buffers: the index list, ranges, origins and block tables.
pub fn bytes(&self) -> u64 {
let tables: u64 = self.pages.iter().flatten().map(|t| t.blocks.size()).sum();
self.indices.size()
+ self.ranges.size()
+ self.origins.size()
+ self.phase2_calls.size()
+ tables
}
/// The origins by section id, which the visibility buffer's resolve (`visbuffer`) reads
/// in its fragment shader.
pub(super) fn origins(&self) -> &wgpu::Buffer {
&self.origins
}
/// The block table of the section arena's page `page` (`blocks_per_page` entries), which
/// the visibility buffer copies into its table of every page.
pub(super) fn block_table(&self, page: u32) -> Option<&wgpu::Buffer> {
Some(&self.pages.get(page as usize)?.as_ref()?.blocks)
}
pub(super) fn blocks_per_page(&self) -> u64 {
self.blocks_per_page
}
/// Draws `calls` without their index buffer, `instances` times: a shader pack's geometry
/// stage reads the indices itself (`hz_indices`) and stands for one emitted triangle per
/// instance.
@@ -896,19 +998,22 @@ impl Flat {
pass.draw(call.first..call.first + call.count, 0..instances);
}
}
/// Draws phase 2's calls, as `flatten_phase2` left them.
/// Draws phase 2's `calls` (`calls[phase1_calls..solid_calls]` counted from 0), as
/// `flatten_phase2` left them.
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: std::ops::Range<usize>,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
let first = self.phase1_calls + calls.start;
for (k, call) in self.calls[first..self.phase1_calls + calls.end]
.iter()
.enumerate()
{
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
pass.draw_indexed_indirect(&self.phase2_calls, (calls.start + k) as u64 * 20);
}
}
fn bind_page<'a>(
@@ -968,7 +1073,7 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1017,14 +1122,14 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[3, 1],
[3, 1, solid],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1, 4], 4));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
calls,
@@ -1053,7 +1158,7 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
[camera, 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1082,7 +1187,7 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1095,7 +1200,7 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
pass_calls: [1, 0, 3],
total: 6 * 13
}
);
@@ -1200,7 +1305,7 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
align,
|page, _| page,
&mut calls,
File diff suppressed because it is too large. Load diff
@@ -1,30 +1,42 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections the CPU's walk
// chose, writes each one's origin and counts it in its page, per block of 64
// ids. `scan`: one workgroup per page turns the blocks' counts into where
// each block's draws start, and the page's count. `scatter`: each block
// writes its sections' draws there, in id order, so a page's draws come in
// the same order every frame (atomics alone would order them as the GPU
// schedules, and where faces of two sections meet at the same depth the later
// draw keeps the pixel). Nothing here computes with floats: the origins are
// the CPU's bits, copied.
// `cull`: one invocation per section id keeps the sections whose column is
// within the render distance, that cave culling's graph reaches (occlusion.rs)
// and whose box is in the frustum, writes each one's origin and counts it in
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
// blocks' counts into where each block's draws start, and the page's count.
// `scatter`: each block writes its sections' draws there, in id order, so a
// page's draws come in the same order every frame (atomics alone would order
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
// regions cover, and where the regions and the chosen ids' bits start in
// `tables`.
// regions cover, and where the regions start in `tables`.
ids: u32,
updates: u32,
pages: u32,
regions: u32,
mask: u32,
pad0: u32,
pad1: u32,
pad2: u32,
// Bits 3j, 3j + 1 and 3j + 2: frustum plane j's x, y and z are at least 0.
signs: u32,
// The render distance in columns, and the camera's column.
near: i32,
cam_x: i32,
cam_z: i32,
// The tables' first column: x, 0, z, 0.
first: vec4<i32>,
// Their entries along x, y and z, and 0.
cells: vec4<u32>,
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH and SKY; 0.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
pad: u32,
}
// gpu_culling.rs `Record`.
@@ -32,8 +44,8 @@ struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, then two words
// unused here.
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then a word unused here.
a: vec4<u32>,
}
@@ -45,9 +57,10 @@ struct Update {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `GpuCulling::prepare`: the origins' f32 bits along x, y and
// z, each page's region of `draws` (its first draw and its size), then a bit
// per id the CPU's walk chose.
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
// per frustum plane its terms for those origins, n each; per plane and axis its
// terms for the 32 box codes; then each page's region of `draws` (its first
// draw and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
@@ -59,6 +72,9 @@ struct Update {
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@group(0) @binding(8) var<storage, read> reached_cells: array<u32>;
// gfx.rs `section_instance`'s w for a whole section: 1 / mesher::UV_STEPS.
const STEP_BITS: u32 = 0x38000000u;
@@ -68,6 +84,13 @@ const ROW: u32 = 65535u;
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// gpu_culling.rs `rounded_box` for a box past what its codes hold: no plane
// rejects it.
const UNBOUNDED: u32 = 0x80000000u;
// In `params.flags`: cave culling's graph is in `params.reach`, and its search
// reached the box's top layer (the sky band).
const REACH: u32 = 1u;
const SKY: u32 = 2u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -80,9 +103,69 @@ fn region(page: u32) -> vec2<u32> {
return vec2<u32>(tables[at], tables[at + 1u]);
}
// The CPU's walk chose it (gpu_culling.rs `GpuCulling::choose`).
fn chosen(id: u32) -> bool {
return ((tables[params.mask + id / 32u] >> (id % 32u)) & 1u) != 0u;
// streaming.rs `in_view_distance` around the camera's column. A gap at or past
// the distance cannot pass, so testing it first keeps the squares in range.
fn near(x: i32, z: i32) -> bool {
let r = params.near;
let gx = max(abs(x - params.cam_x) - 1, 0);
let gz = max(abs(z - params.cam_z) - 1, 0);
if gx >= r || gz >= r {
return false;
}
return gx * gx + gz * gz < r * r;
}
// occlusion.rs `Occlusion::contains` (`Graph::contains`): with no graph, or with
// cave culling off, every section; in the searched box, whether the search
// reached it; above a clamped box, over its columns and within the level, the
// sky band (all of it or none); else none. `Reach::contains` on the CPU.
fn reached(key: vec3<i32>) -> bool {
if (params.flags & REACH) == 0u {
return true;
}
let r = params.reach.z;
let dx = key.x - params.reach.x;
let dz = key.z - params.reach.y;
let dy = key.y - params.reach.w;
if dx < -r || dx > r || dz < -r || dz > r || dy < 0 {
return false;
}
if dy >= params.reach_height {
return (params.flags & SKY) != 0u && key.y >= 0 && key.y <= params.reach_sections;
}
let i = u32(((dz + r) * (2 * r + 1) + dx + r) * params.reach_height + dy);
return ((reached_cells[i >> 5u] >> (i & 31u)) & 1u) != 0u;
}
fn term(at: u32) -> i32 {
return bitcast<i32>(tables[at]);
}
// gfx.rs `Frustum::intersects` for the box `b` of the section in cell `c` of the
// tables, in integers (gpu_culling.rs `tables`): it keeps every box the CPU keeps.
fn in_frustum(c: vec3<u32>, b: u32) -> bool {
if (b & UNBOUNDED) != 0u {
return true;
}
let nx = params.cells.x;
let ny = params.cells.y;
let n = nx + ny + params.cells.z;
for (var j = 0u; j < 6u; j++) {
// The box's codes at the corner the plane looks at.
let s = params.signs >> (3u * j);
let kx = (b >> select(0u, 15u, (s & 1u) != 0u)) & 31u;
let ky = (b >> select(5u, 20u, (s & 2u) != 0u)) & 31u;
let kz = (b >> select(10u, 25u, (s & 4u) != 0u)) & 31u;
let origin_terms = n + j * n;
let code_terms = 7u * n + 96u * j;
let sum = params.w[j / 4u][j % 4u] + term(origin_terms + c.x)
+ term(origin_terms + nx + c.y) + term(origin_terms + nx + ny + c.z)
+ term(code_terms + kx) + term(code_terms + 32u + ky) + term(code_terms + 64u + kz);
if sum < 0 {
return false;
}
}
return true;
}
// One invocation per changed record; the CPU lists each id once.
@@ -97,19 +180,20 @@ fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
// Past the table's end the records are stale: the CPU never writes them free.
if id >= params.ids {
return;
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !chosen(id) {
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
return;
}
let page = u32(s.key.w);
// The tables hold every column within the distance; a key before their
// first wraps past their end.
let c = vec3<u32>(s.key.xyz - params.first.xyz);
if page >= params.pages || any(c >= params.cells.xyz) {
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
return;
}
let nx = params.cells.x;
+7 -5
View File
@@ -1,8 +1,10 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
//! white, expanding the world's highlights towards their peak brightness. The
//! world is copied aside before the hand and HUD so only it is expanded.
//! without it, in vanilla's gamma space, into a 16-bit target when HDR output
//! was on when the renderer was made, else an 8-bit one
//! (`Renderer::frame_format`), and one pass (`hdr.wgsl`) writes it to an HDR10
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -301,7 +303,7 @@ impl Hdr {
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
@@ -0,0 +1,223 @@
//! Every render pipeline's stage interface against the weakest GPUs' limit (#8195).
//!
//! OpenGL 3.3 drivers (a GeForce 9100M G) allow `max_inter_stage_shader_variables` = 15, and wgpu
//! counts the fragment stage's built-in inputs (`front_facing`, `sample_index`, ...) against it.
//! A pipeline over that limit panics at start-up on such a GPU, with no way to run the game, so
//! this checks the shaders with naga, without a GPU.
use std::path::{Path, PathBuf};
/// `Limits::downlevel_defaults().max_inter_stage_shader_variables`, the least a GPU offers.
const LIMIT: u32 = 15;
/// One entry point's variables: the user-defined `@location`s and the built-ins wgpu counts.
#[derive(Debug, PartialEq, Eq)]
struct Interface {
entry: String,
/// Vertex outputs or fragment inputs, user-defined.
user: u32,
/// The fragment stage's `front_facing`, `sample_index`, `sample_mask` and `primitive_index`
/// inputs, which wgpu counts as one variable each; 0 for a vertex stage.
built_ins: u32,
}
impl Interface {
fn total(&self) -> u32 {
self.user + self.built_ins
}
}
/// The bindings an argument or a result carries, a struct's members flattened.
fn bindings(
module: &naga::Module,
binding: &Option<naga::Binding>,
ty: naga::Handle<naga::Type>,
) -> Vec<naga::Binding> {
if let Some(binding) = binding {
return vec![binding.clone()];
}
match &module.types[ty].inner {
naga::TypeInner::Struct { members, .. } => {
members.iter().filter_map(|m| m.binding.clone()).collect()
}
_ => Vec::new(),
}
}
fn is_location(binding: &naga::Binding) -> bool {
matches!(binding, naga::Binding::Location { .. })
}
/// The stage interface of every vertex and fragment entry point of `source`.
fn interfaces(source: &str) -> Vec<Interface> {
let module = naga::front::wgsl::parse_str(source).unwrap();
let mut out = Vec::new();
for entry in &module.entry_points {
let function = &entry.function;
let (user, built_ins) = match entry.stage {
naga::ShaderStage::Vertex => {
let outputs: Vec<_> = function
.result
.iter()
.flat_map(|r| bindings(&module, &r.binding, r.ty))
.collect();
(outputs.iter().filter(|b| is_location(b)).count(), 0)
}
naga::ShaderStage::Fragment => {
let inputs: Vec<_> = function
.arguments
.iter()
.flat_map(|a| bindings(&module, &a.binding, a.ty))
.collect();
let built_ins = inputs
.iter()
.filter(|b| {
matches!(
b,
naga::Binding::BuiltIn(
naga::BuiltIn::FrontFacing
| naga::BuiltIn::SampleIndex
| naga::BuiltIn::SampleMask
| naga::BuiltIn::PrimitiveIndex
)
)
})
.count();
(inputs.iter().filter(|b| is_location(b)).count(), built_ins)
}
_ => continue,
};
out.push(Interface {
entry: entry.name.clone(),
user: user as u32,
built_ins: built_ins as u32,
});
}
out
}
/// The shaders as the renderer assembles them, by the `.wgsl` files each is made of.
fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
vec![
("entity", vec!["entity.wgsl"], entity.clone()),
(
"static entity",
vec!["entity.wgsl", "static_entity.wgsl"],
entity.clone() + include_str!("../static_entity.wgsl"),
),
(
"improved transparency clouds",
vec!["entity.wgsl", "oit.wgsl", "oit_clouds.wgsl"],
entity.clone() + include_str!("../oit.wgsl") + include_str!("../oit_clouds.wgsl"),
),
(
"improved transparency features",
vec!["entity.wgsl", "oit.wgsl", "oit_features.wgsl"],
entity + include_str!("../oit.wgsl") + include_str!("../oit_features.wgsl"),
),
(
"improved transparency world",
vec!["terrain.wgsl", "oit.wgsl", "oit_world.wgsl"],
include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"),
),
(
"visibility buffer",
vec!["terrain.wgsl", "visbuffer.wgsl"],
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"),
),
]
}
fn wgsl_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
wgsl_files(&path, out);
} else if path.extension().is_some_and(|e| e == "wgsl") {
out.push(path);
}
}
}
/// Every shader: the assembled ones, and each `.wgsl` file that stands alone. A file that does
/// not (it needs another's declarations) must be part of an assembled one, so that a new shader
/// cannot slip past the audit.
fn audited() -> Vec<(String, Vec<Interface>)> {
let composed = composed();
let mut files = Vec::new();
wgsl_files(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
&mut files,
);
files.sort();
let mut out: Vec<(String, Vec<Interface>)> = composed
.iter()
.map(|(name, _, source)| ((*name).to_owned(), interfaces(source)))
.collect();
for file in files {
let name = file.file_name().unwrap().to_string_lossy().into_owned();
let source = std::fs::read_to_string(&file).unwrap();
let alone = naga::front::wgsl::parse_str(&source).is_ok();
let part = composed.iter().any(|(_, parts, _)| parts.contains(&&*name));
assert!(
alone || part,
"{name} needs other files' declarations: add its assembly to `composed`"
);
if alone {
out.push((name, interfaces(&source)));
}
}
out
}
#[test]
fn no_pipeline_exceeds_the_weakest_gpus_inter_stage_limit() {
let mut over = Vec::new();
for (shader, entries) in audited() {
for e in entries {
if e.total() > LIMIT {
over.push(format!(
"{shader}::{}: {} user-defined + {} built-in = {} > {LIMIT}",
e.entry,
e.user,
e.built_ins,
e.total()
));
}
}
}
assert!(over.is_empty(), "over the limit:\n{}", over.join("\n"));
}
/// The audit counts what wgpu counts (the failure of #8195: 15 user-defined + `FrontFacing`).
#[test]
fn a_front_facing_input_counts_against_the_limit() {
let source = "struct Io { @builtin(position) p: vec4<f32>, @location(0) a: vec4<f32>, @location(1) b: f32 }
@vertex fn vs() -> Io { return Io(vec4<f32>(0.0), vec4<f32>(0.0), 0.0); }
@fragment fn fs(in: Io, @builtin(front_facing) f: bool) -> @location(0) vec4<f32> { return in.a; }";
assert_eq!(
interfaces(source),
vec![
Interface {
entry: "vs".into(),
user: 2,
built_ins: 0
},
Interface {
entry: "fs".into(),
user: 2,
built_ins: 1
},
]
);
}
@@ -63,6 +63,9 @@ pub(super) struct Tile {
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
}
/// How long a tile takes to fade in over the ones it replaces.
@@ -160,6 +163,18 @@ impl Tile {
None => &mut self.mesh,
}
}
/// The box occlusion culling tests (`cull::Cull`), around its origin: its
/// mesh's bounds grown by a block, while that mesh is the one drawn
/// whole (no stand-in, no fade). A fading tile's dither moves its depth
/// toward the camera (terrain.wgsl's `fade_depth`), out of the box.
pub fn cull_bounds(&self, fade: u32) -> Option<[[f32; 3]; 2]> {
(fade == 0 && self.previous.is_none()).then(|| {
[
(self.bounds.min - 1.0).to_array(),
(self.bounds.max + 1.0).to_array(),
]
})
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
@@ -192,6 +207,10 @@ impl Renderer {
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
/// (`upload_lods`), then fades in.
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
if let Some(copies) = &mut self.capture_copies {
copies.tile(key, mesh);
}
let old_slot = self.lods.get(&key).map(|tile| tile.cull_slot);
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
@@ -212,6 +231,12 @@ impl Renderer {
};
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
Some((bounds, mesh)) => {
let cull_slot = old_slot.unwrap_or_else(|| {
self.lod_cull_free.pop().unwrap_or_else(|| {
self.lod_cull_next += 1;
self.lod_cull_next - 1
})
});
self.lods.insert(
key,
Tile {
@@ -221,6 +246,7 @@ impl Renderer {
dying: None,
alone: false,
previous,
cull_slot,
},
);
}
@@ -228,6 +254,7 @@ impl Renderer {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
}
}
}
@@ -240,12 +267,19 @@ impl Renderer {
}
pub fn remove_lod(&mut self, key: TileKey) {
if let Some(copies) = &mut self.capture_copies {
copies.forget_tile(key);
}
if let Some(tile) = self.lods.remove(&key) {
self.lod_cull_free.push(tile.cull_slot);
self.release_tile(tile);
}
}
pub fn clear_lods(&mut self) {
if let Some(copies) = &mut self.capture_copies {
copies.clear_tiles();
}
self.lod_pending = None;
// No far terrain left to fade out over.
for (key, since) in std::mem::take(&mut self.lod_leaving) {
@@ -260,6 +294,8 @@ impl Renderer {
for (_, tile) in std::mem::take(&mut self.lods) {
self.release_tile(tile);
}
self.lod_cull_free.clear();
self.lod_cull_next = 0;
self.set_lod_cutout(Default::default());
}
@@ -437,6 +473,15 @@ impl Renderer {
}
}
/// The tiles drawn for good, not fading out (`render-capture`).
pub(super) fn standing_lods(&self) -> FxHashSet<TileKey> {
self.lods
.iter()
.filter(|(_, tile)| tile.dying.is_none())
.map(|(&key, _)| key)
.collect()
}
/// The chunk columns the resident tiles now leave out (`Lod::take_cutout`).
/// Removed sections they no longer leave out are released.
pub fn set_lod_cutout(&mut self, cutout: crate::fast_hash::FxHashSet<(i32, i32)>) {
@@ -41,17 +41,28 @@ struct HorizonMaterialOut {
@location(10) @interpolate(flat) emission: f32,
// terrain.wgsl's `emission`, for HDR output's mark.
@location(11) @interpolate(flat) hdr_code: u32,
// The point on the flat face (`position` without `[displacement]`'s relief), how much of
// that relief it has (`MaterialInput.displaced`) and the face's normal (0 when the
// fragment finds it).
@location(12) base: vec3<f32>,
@location(13) displaced: f32,
@location(14) @interpolate(flat) normal: vec3<f32>,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<f32>) -> HorizonMaterialOut {
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.layer = layer;
out.tint = vec4(color.rgb, 1.0);
let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos));
let sky = f32(light.x);
let block = max(light.y, horizon_terrain_dynamic_block_light(pos));
let sky = light.x;
// 16 per level, 15 levels.
out.light = min(vec2(block, sky) / 240.0, vec2(1.0));
out.lightmap = horizon_terrain_sample_lightmap(sky / 16.0, block / 16.0).rgb;
@@ -73,6 +84,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
out.base = pos;
out.displaced = 0.0;
out.normal = vec3(0.0);
return out;
}
@vertex fn horizon_material_vs(
@@ -142,15 +156,17 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// 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);
// `[displacement]`'s grid hands down its face's normal: its relief is the pack's to shade.
let normal = select(horizon_material_normal(in.position), in.normal,
dot(in.normal, in.normal) > 0.5);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -176,6 +192,13 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
return 1.0;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
// `[displacement]`'s grid draws these pixels.
if length(in.base) < horizon_displace_distance - 0.05 && horizon_displaced_layer(in.layer) {
discard;
}
return horizon_material_solid(in);
}
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
@@ -196,6 +219,37 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
}
";
/// `[displacement]`'s grid (gfx/displace.rs): one instance a quad the pick chose, its corners
/// read from the copy of the arena, each of its cells two triangles moved along the face's
/// normal by the pack's `displacement`, measured from the texture's mean height so that the
/// face's edges, pinned there, meet their neighbours whatever their relief.
const DISPLACED_ENTRIES: &str = "
@vertex fn horizon_material_vs_displaced(@builtin(vertex_index) v: u32,
@builtin(instance_index) instance: u32) -> HorizonMaterialOut {
let p = horizon_displace_point(v, instance);
var out = horizon_material_out(p.position, p.uv, p.layer, p.color, p.light);
out.base = p.base;
out.displaced = p.near;
out.normal = p.normal;
return out;
}
@fragment fn horizon_material_fs_displaced(in: HorizonMaterialOut) -> HorizonMaterialSolid {
if length(in.base) >= horizon_displace_distance + 0.05 {
discard;
}
return horizon_material_solid(in);
}
";
/// What the material's displaced pipeline is built against: `[displacement]`'s values and
/// its group 1.
pub struct DisplacedShape<'a> {
pub distance: f32,
pub cells: u32,
pub steps: bool,
pub layout: &'a wgpu::BindGroupLayout,
}
/// What terrain pipelines are built against: the renderer's layouts and constants.
pub struct TerrainShape<'a> {
/// The terrain's group 0.
@@ -227,6 +281,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// `[displacement]`'s grid of the solid terrain near the camera (gfx/displace.rs).
pub displaced: Option<wgpu::RenderPipeline>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
@@ -290,9 +346,10 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
if vertex {
DISPLACE_PACK
@@ -300,7 +357,13 @@ fn shader_source(
DISPLACE_VANILLA
},
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
layer_table("horizon_sprite_material", materials),
super::displace::RELIEF,
if displaced {
super::displace::GRID.to_owned() + DISPLACED_ENTRIES
} else {
String::new()
}
)
}
@@ -310,8 +373,9 @@ fn module(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex);
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -326,9 +390,16 @@ impl NativeMaterial {
constants: &[(String, f64)],
fog: bool,
vertex: bool,
displaced: Option<&DisplacedShape<'_>>,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
let module = module(
stage,
shape.sprite_tags,
shape.sprite_materials,
vertex,
displaced.is_some(),
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -336,6 +407,14 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
));
if let Some(d) = displaced {
constants.push(("horizon_displace_cells", f64::from(d.cells)));
constants.push(("horizon_displace_steps", f64::from(u8::from(d.steps))));
}
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -411,7 +490,7 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
targets: if fragment != "horizon_material_fs_translucent" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
@@ -453,6 +532,16 @@ impl NativeMaterial {
),
)
});
// The grid's corners come from its group 1, not from vertex buffers.
let displaced = displaced.map(|d| {
pipeline(
&layout(Some(d.layout)),
"horizon_material_vs_displaced",
&[],
"horizon_material_fs_displaced",
None,
)
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
@@ -460,6 +549,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
displaced,
writes_surface,
})
}
@@ -515,7 +605,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false).unwrap();
let module = module(&stage, &tags, &materials, false, false).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -524,7 +614,13 @@ mod tests {
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
e.emit_to_string(&shader_source(
&stage.source,
&tags,
&materials,
false,
false
))
)
});
let entries: Vec<&str> = module
@@ -546,4 +642,39 @@ mod tests {
[("gain", 1.5)]
);
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"material.wgsl\"\n[displacement]\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * (1.0 - 0.5 * input.displaced));\n}\n\
fn displacement(input: DisplacementInput) -> f32 {\n \
return (1.0 - input.height) * 0.125 * f32(input.material + 1u);\n}\n";
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
"horizon_material_vs_displaced",
"horizon_material_fs_displaced",
] {
assert!(
module.entry_points.iter().any(|e| e.name == entry),
"{entry}"
);
}
}
}
@@ -3,9 +3,10 @@
use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::displace::Displacement;
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_shadow::{NativeShadow, ShadowGrid};
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -21,6 +22,8 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[displacement]`'s grid, while it is on and the resource packs have height maps.
pub displacement: Option<Displacement>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
@@ -29,6 +32,33 @@ pub struct NativePack {
pub timing: Option<PassTiming>,
}
/// Whether the option `id` (a bool, or a number above 0) is on, or there is none to ask.
fn option_on(constants: &[(String, f64)], id: Option<&String>) -> bool {
id.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
})
}
/// `[displacement]`'s cells: the value of its `cells_option`, if any.
fn chosen_cells(
d: &horizon_shader_api::manifest::Displacement,
options: &[horizon_shader_api::manifest::PackOption],
constants: &[(String, f64)],
) -> u32 {
let chosen = d.cells_option.as_ref().and_then(|id| {
let option = options.iter().find(|o| o.id == *id)?;
let horizon_shader_api::manifest::OptionKind::Choice { values, .. } = &option.kind else {
return None;
};
let (_, index) = constants.iter().find(|(k, _)| k == id)?;
values.get(*index as usize)?.parse().ok()
});
chosen.unwrap_or(d.cells)
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
@@ -49,11 +79,48 @@ impl NativePack {
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
.displacement
.as_ref()
.filter(|d| option_on(constants, d.option.as_ref()))
.filter(|_| terrain.pbr.is_some())
.map(|d| {
// No more cells than the height maps have texels.
let texels = terrain.pbr.map_or(16, |p| p[0].width());
let cells = chosen_cells(d, &report.manifest.options, constants)
.min(texels)
.max(1);
let steps =
d.steps_option.is_some() && option_on(constants, d.steps_option.as_ref());
Displacement::new(
device,
d.distance,
cells,
steps,
&frame.pbr[0],
report.shadow.is_some(),
)
});
let shadow = report
.shadow
.as_ref()
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
// `[displacement]`'s grid casts too, from the material's module.
let material = report.stages.iter().find(|s| s.kind == StageKind::Material);
let grid = displacement.as_ref().and_then(|d| {
let picks = d.shadow.as_ref()?;
Some(ShadowGrid {
distance: d.distance,
cells: picks.cells,
steps: picks.steps,
material: material?,
layout: &d.draw_layout,
normals: &frame.pbr[0],
})
});
NativeShadow::new(
device,
terrain,
@@ -61,6 +128,7 @@ impl NativePack {
&s.solid_materials,
vertex,
constants,
grid,
)
})
.transpose()?;
@@ -71,12 +139,7 @@ impl NativePack {
.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)
});
&& option_on(constants, option);
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
@@ -119,7 +182,22 @@ impl NativePack {
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| {
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
let displaced = displacement.as_ref().map(|d| DisplacedShape {
distance: d.distance,
cells: d.view.cells,
steps: d.view.steps,
layout: &d.draw_layout,
});
NativeMaterial::new(
device,
terrain,
&frame,
stage,
constants,
fog,
has_vertex,
displaced.as_ref(),
)
})
.transpose()?;
let post = (!post.is_empty())
@@ -148,6 +226,7 @@ impl NativePack {
vanilla_stars,
weather,
shadow,
displacement,
timing: None,
};
Ok((pack, labels))
@@ -122,7 +122,7 @@ pub struct NativeFrame {
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
pub(super) pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
@@ -623,7 +623,8 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
// `[displacement]`'s grid moves its corners by the heights (gfx/displace.rs).
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
@@ -23,6 +23,9 @@ struct HorizonShadowOut {
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
// The point on the flat face, camera-relative (`[displacement]`'s grid takes it over near
// the camera).
@location(4) base: vec3<f32>,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
@@ -37,6 +40,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
out.base = pos;
return out;
}
@vertex fn horizon_shadow_vs(
@@ -58,6 +62,10 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
if horizon_shadow_taken(in.base, in.layer) { discard; }
horizon_shadow_cutout(in);
}
fn horizon_shadow_cutout(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
@@ -105,6 +113,45 @@ struct HorizonEntityShadowOut {
}
";
/// No `[displacement]` grid: the flat terrain casts everywhere.
const UNTAKEN: &str = "
fn horizon_shadow_taken(base: vec3<f32>, layer: u32) -> bool {
return false;
}
";
/// `[displacement]`'s grid in the shadow map (gfx/displace.rs), cast in the flat quads' place.
/// The height maps join the pass's frame group.
const GRID_ENTRIES: &str = "
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
fn horizon_shadow_taken(base: vec3<f32>, layer: u32) -> bool {
return length(base) < horizon_displace_distance - 0.05 && horizon_displaced_layer(layer);
}
@vertex fn horizon_shadow_vs_displaced(@builtin(vertex_index) v: u32,
@builtin(instance_index) instance: u32) -> HorizonShadowOut {
let p = horizon_displace_point(v, instance);
var out = horizon_shadow_vertex(p.position, p.uv, p.layer, p.color);
out.base = p.base;
return out;
}
@fragment fn horizon_shadow_fs_displaced(in: HorizonShadowOut) {
if length(in.base) >= horizon_displace_distance + 0.05 { discard; }
horizon_shadow_cutout(in);
}
";
/// What the shadow pass's grid is built from: `[displacement]`'s values, the material stage
/// (whose module holds `displacement` and the vertex stage), group 1's layout and the height
/// maps.
pub struct ShadowGrid<'a> {
pub distance: f32,
pub cells: u32,
pub steps: bool,
pub material: &'a horizon_shader_api::check::CheckedStage,
pub layout: &'a wgpu::BindGroupLayout,
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -326,6 +373,8 @@ pub struct NativeShadow {
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
/// `[displacement]`'s grid (gfx/displace.rs's shadow list).
pub displaced: Option<wgpu::RenderPipeline>,
}
/// The entity draw's shader.
@@ -418,17 +467,36 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
/// `solid` of `shadow_solid`. Under `[displacement]` (`grid`: the material stage's source, which
/// holds the vertex stage and `displacement`), its grid too.
fn shader_source(
solid: &str,
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
(Some(material), _) => (
format!(
"{material}{}{table}{}{}{GRID_ENTRIES}",
horizon_shader_api::stages::PBR_STUB,
super::displace::RELIEF,
super::displace::GRID,
),
if vertex.is_some() {
super::native_material::DISPLACE_PACK
} else {
super::native_material::DISPLACE_VANILLA
},
),
(None, Some(stage)) => (
format!("{stage}{table}{UNTAKEN}"),
super::native_material::DISPLACE_PACK,
),
None => (
(None, None) => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
@@ -452,11 +520,13 @@ impl NativeShadow {
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
grid: Option<ShadowGrid<'_>>,
) -> Result<Self, String> {
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -465,31 +535,59 @@ impl NativeShadow {
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
]);
if let Some(g) = &grid {
constants.extend([
("horizon_displace_distance", f64::from(g.distance)),
("horizon_displace_cells", f64::from(g.cells)),
("horizon_displace_steps", f64::from(u8::from(g.steps))),
]);
}
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
// The frame alone, and under `[displacement]` the height maps its grid moves by.
let frame_entry = wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
};
let normals_entry = wgpu::BindGroupLayoutEntry {
binding: 12,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
};
let layout_entries: Vec<_> = std::iter::once(frame_entry)
.chain(grid.is_some().then_some(normals_entry))
.collect();
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
entries: &layout_entries,
});
let group_entries: Vec<_> = std::iter::once(wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
})
.chain(grid.as_ref().map(|g| wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(g.normals),
}))
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
entries: &group_entries,
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
@@ -519,10 +617,11 @@ impl NativeShadow {
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let pipeline_with = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment: &str| {
let translucent = fragment == "horizon_shadow_fs_translucent";
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -556,11 +655,7 @@ impl NativeShadow {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
entry_point: Some(fragment),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
@@ -568,6 +663,17 @@ impl NativeShadow {
cache: None,
})
};
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let fragment = if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
};
pipeline_with(layout, vertex, buffers, fragment)
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
@@ -595,6 +701,15 @@ impl NativeShadow {
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
// The grid's corners come from its group 1, not from vertex buffers.
let displaced = grid.as_ref().map(|g| {
pipeline_with(
&layout(Some(g.layout)),
"horizon_shadow_vs_displaced",
&[],
"horizon_shadow_fs_displaced",
)
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
@@ -605,6 +720,7 @@ impl NativeShadow {
flat_translucent,
entity,
group,
displaced,
})
}
}
@@ -616,7 +732,7 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -627,6 +743,47 @@ mod tests {
}
}
#[test]
fn the_shadow_shader_with_the_displaced_grid_validates() {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"material.wgsl\"\n[shadow]\n[displacement]\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo);\n}\n\
fn displacement(input: DisplacementInput) -> f32 {\n \
return (1.0 - input.height) * 0.125;\n}\n";
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
let materials = [(3, 7)];
let text = shader_source(
&shadow_solid(&materials, &[7]),
None,
&materials,
Some(&stage.source),
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
for entry in ["horizon_shadow_vs_displaced", "horizon_shadow_fs_displaced"] {
assert!(
module.entry_points.iter().any(|e| e.name == entry),
"{entry}"
);
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
@@ -12,6 +12,10 @@
//! for the next one. A section meshed in between propagates into the graph the
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
//!
//! GPU culling (gfx/gpu_culling.rs) answers as `contains` does from a copy of
//! the graph (`Reach`): the cells it reached, kept as bits too, sent again
//! whenever the generation moves, which it does whenever an answer may change.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
@@ -129,6 +133,10 @@ struct Graph {
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
/// the camera's.
entered: Vec<u8>,
/// `REACHED` of each cell again, a bit each at `index` (32 a word, lowest
/// bit first): the GPU's copy (`Occlusion::reach`), written with it
/// (`set_reached`).
bits: Vec<u32>,
/// Whether any section in the searched top layer was reached. Sections
/// above a clamped box use this conservative sky-band answer.
sky_reached: bool,
@@ -177,6 +185,13 @@ impl Graph {
false
}
}
/// Cell `i` is reached, having travelled `directions` (`REACHED` among
/// them): the one writer of `REACHED`, so `bits` say what `directions` do.
fn set_reached(&mut self, i: usize, directions: u8) {
debug_assert!(directions & REACHED != 0);
self.directions[i] = directions;
self.bits[i / 32] |= 1 << (i % 32);
}
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
fn search(
&mut self,
@@ -220,6 +235,8 @@ impl Graph {
let size = side * side * self.height as usize;
self.directions.clear();
self.directions.resize(size, 0);
self.bits.clear();
self.bits.resize(size.div_ceil(32), 0);
self.entered.clear();
self.entered.resize(size, 0);
self.sky_reached = false;
@@ -243,7 +260,7 @@ impl Graph {
queue.clear();
if (0..columns.sections).contains(&from.1) {
let start = self.index(from).expect("camera inside the searched box");
self.directions[start] = REACHED;
self.set_reached(start, REACHED);
queue.push_back(start as u32);
} else {
self.seed_outside(camera, queue);
@@ -294,7 +311,7 @@ impl Graph {
let i = self
.index((self.from.0 + dx, y, self.from.2 + dz))
.expect("seed inside the searched box");
self.directions[i] = directions;
self.set_reached(i, directions);
// Entered through the face opposite the travel (`sourceDirection`).
self.entered[i] = 1 << (travel ^ 1);
queue.push_back(i as u32);
@@ -304,14 +321,15 @@ impl Graph {
/// every face their entry faces connect to, away from the camera. A step
/// into a section already reached adds the face it enters through.
/// `full`: the search from `camera` (`rays` holds its lines), not a
/// partial update.
/// partial update. Whether it reached a cell it had not.
fn propagate(
&mut self,
queue: &mut VecDeque<u32>,
camera: DVec3,
full: bool,
vis: impl Fn(usize, SectionKey) -> u64,
) {
) -> bool {
let mut grew = false;
let eye = (
(camera.x.floor() as i32) >> 4,
(camera.y.floor() as i32 - self.min_y) >> 4,
@@ -365,14 +383,16 @@ impl Graph {
}
}
if self.directions[n] & REACHED == 0 {
self.directions[n] = directions | 1 << d;
self.set_reached(n, directions | 1 << d);
self.entered[n] = 1 << (d ^ 1);
queue.push_back(n as u32);
grew = true;
} else {
self.entered[n] |= 1 << (d ^ 1);
}
}
}
grew
}
/// Vanilla's distant step test: from the corner of `key` that the step `d`
/// picks, walk towards the camera in section diagonals while farther than
@@ -472,6 +492,42 @@ impl Graph {
}
}
/// The graph `Occlusion::contains` reads, for GPU culling's copy
/// (gfx/gpu_culling.rs): its searched box (`Graph::index`), the level's height
/// in sections, whether the search reached the box's top layer (the sky band
/// above a clamped box), and its cells' `REACHED` as bits.
#[derive(Clone, Copy)]
pub struct Reach<'a> {
/// `Occlusion::generation` when it was taken.
pub generation: u64,
pub from_x: i32,
pub from_z: i32,
pub radius: i32,
pub y0: i32,
pub height: i32,
pub sections: i32,
pub sky_reached: bool,
pub bits: &'a [u32],
}
#[cfg(test)]
impl Reach<'_> {
/// gpu_culling.wgsl's `reached`, statement for statement: what
/// `Graph::contains` answers.
pub fn contains(&self, key: SectionKey) -> bool {
let r = self.radius;
let (dx, dz, dy) = (key.0 - self.from_x, key.2 - self.from_z, key.1 - self.y0);
if dx < -r || dx > r || dz < -r || dz > r || dy < 0 {
return false;
}
if dy >= self.height {
return self.sky_reached && (0..=self.sections).contains(&key.1);
}
let i = (((dz + r) * (2 * r + 1) + dx + r) * self.height + dy) as usize;
(self.bits[i / 32] >> (i % 32)) & 1 != 0
}
}
/// `Mth.floor` for coordinates inside the world, without a libm call (the
/// x86-64 baseline has no rounding instruction).
fn floor(x: f64) -> i32 {
@@ -518,6 +574,14 @@ impl Worker {
}
}
/// A new value for `Occlusion::generation`, unique in the process: a copy made
/// from an older `Occlusion` (another level's) never matches. As gfx.rs
/// `next_block_entity_revision`.
fn next_generation() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
pub struct Occlusion {
/// The render thread's copy, for partial updates (and searches when the
/// worker is gone).
@@ -545,6 +609,8 @@ pub struct Occlusion {
changed_late: Vec<SectionKey>,
vis: Vec<u64>,
queue: VecDeque<u32>,
/// Changes whenever `contains` may answer differently (`generation`).
generation: u64,
}
impl Occlusion {
@@ -564,6 +630,7 @@ impl Occlusion {
changed_late: Vec::new(),
vis: Vec::new(),
queue: VecDeque::new(),
generation: next_generation(),
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
@@ -608,9 +675,11 @@ impl Occlusion {
self.edits.clear();
self.edits.push(Edit::Clear);
self.dirty = true;
// Another world: the old graph says nothing about it.
// Another world: the old graph says nothing about it. Without one, every
// section was visible already, and stays so.
if let Some(graph) = self.graph.take() {
self.spare = graph;
self.generation = next_generation();
}
self.searched = None;
self.discard = self.running.is_some();
@@ -623,6 +692,38 @@ impl Occlusion {
pub fn contains(&self, key: SectionKey) -> bool {
self.graph.as_ref().is_none_or(|g| g.contains(key))
}
/// Changes whenever `contains` may answer differently: a search landed, a
/// partial update reached more sections, or the graph went.
#[cfg(any(test, debug_assertions))]
pub fn generation(&self) -> u64 {
self.generation
}
/// The graph `contains` reads; `None` while every section counts as
/// visible.
pub fn reach(&self) -> Option<Reach<'_>> {
self.graph.as_ref().map(|g| Reach {
generation: self.generation,
from_x: g.from.0,
from_z: g.from.2,
radius: g.radius,
y0: g.y0,
height: g.height,
sections: g.sections,
sky_reached: g.sky_reached,
bits: &g.bits,
})
}
/// The bits `reach` exports are the cells' `REACHED`, cell for cell.
#[cfg(any(test, debug_assertions))]
pub fn reach_is_exact(&self) -> bool {
self.graph.as_ref().is_none_or(|g| {
g.bits.len() == g.directions.len().div_ceil(32)
&& g.directions
.iter()
.enumerate()
.all(|(i, &d)| (d & REACHED != 0) == ((g.bits[i / 32] >> (i % 32)) & 1 != 0))
})
}
/// Take a finished search, propagate changed sections into the graph,
/// and start a search when sections changed or the camera moved to
/// another section. Never waits for the worker.
@@ -642,6 +743,7 @@ impl Occlusion {
&& let Some(graph) = self.graph.take()
{
self.spare = graph;
self.generation = next_generation();
}
}
fn start(&mut self, camera: DVec3, radius: i32) {
@@ -713,6 +815,7 @@ impl Occlusion {
if let Some(old) = self.graph.replace(graph) {
self.spare = old;
}
self.generation = next_generation();
}
/// Vanilla's `runPartialUpdate`: what changed sections now connect to.
fn propagate_changes(&mut self, camera: DVec3) {
@@ -729,7 +832,9 @@ impl Occlusion {
}
}
let columns = &self.columns;
graph.propagate(&mut self.queue, camera, false, |_, key| columns.get(key));
if graph.propagate(&mut self.queue, camera, false, |_, key| columns.get(key)) {
self.generation = next_generation();
}
}
/// Wait for the running search: tests, and A/B timing of the old
/// blocking search.
@@ -1405,4 +1510,197 @@ mod tests {
"enclosed cave does not see the sky"
);
}
/// `contains` over the columns `radius` + 3 around `from`, from two
/// sections below the level to two above it, checked against the graph
/// `reach` exports: `None` exactly without a graph, the same answer for
/// every key, nothing past its box, its bits its cells' `REACHED`, and its
/// sky band reached exactly when a bit of its top layer is.
fn answers(o: &Occlusion, from: SectionKey, radius: i32) -> Vec<bool> {
let reach = o.reach();
assert_eq!(reach.is_some(), o.graph.is_some(), "a graph exported");
assert!(o.reach_is_exact(), "the bits are the cells'");
if let Some(g) = &o.graph {
let height = g.height as usize;
let mut top = (height - 1..g.directions.len()).step_by(height);
let sky = top.any(|i| (g.bits[i / 32] >> (i % 32)) & 1 != 0);
assert_eq!(g.sky_reached, sky, "the sky band");
}
let past = |key: SectionKey| {
o.graph.as_ref().is_some_and(|g| {
(key.0 - g.from.0).abs() > g.radius
|| (key.2 - g.from.2).abs() > g.radius
|| !(g.y0..=g.sections).contains(&key.1)
})
};
let mut answers = Vec::new();
for x in from.0 - radius - 3..=from.0 + radius + 3 {
for z in from.2 - radius - 3..=from.2 + radius + 3 {
for y in -2..=o.columns.sections + 1 {
let key = (x, y, z);
let contains = o.contains(key);
assert_eq!(reach.is_none_or(|r| r.contains(key)), contains, "{key:?}");
assert!(!(contains && past(key)), "{key:?} past the box");
answers.push(contains);
}
}
}
answers
}
/// The graph `reach` exports answers as `contains` (`answers`): walled in,
/// opened by a partial update, cleared; over clamped boxes, the sky band
/// above them reached or not (a closed cave); from above the world. The
/// search, the partial update and `clear` each change the generation, and
/// another level's graph never repeats one.
#[test]
fn the_exported_graph_answers_as_contains() {
let mut o = Occlusion::new(0, 4);
let first = o.generation();
answers(&o, (0, 1, 0), 3);
for (dx, dy, dz) in STEPS {
o.set((dx, 1 + dy, dz), 0);
}
o.settle(at((0, 1, 0)), 3);
assert_ne!(o.generation(), first, "a search landed");
let walled = answers(&o, (0, 1, 0), 3);
let searched = o.generation();
o.set((0, 1, 1), ALL_VISIBLE);
o.update(at((0, 1, 0)), 3);
assert_ne!(o.generation(), searched, "a partial update reached more");
assert_ne!(answers(&o, (0, 1, 0), 3), walled);
let opened = o.generation();
o.clear();
assert_ne!(o.generation(), opened, "the graph went");
answers(&o, (0, 1, 0), 3);
// Another level's graph never takes one of this one's generations, or
// the pass would keep its copy of this one.
let mut other = Occlusion::new(0, 4);
other.settle(at((0, 1, 0)), 3);
assert_ne!(other.generation(), searched, "another level's graph");
let (mut clamped, mut band) = (0, [0; 2]);
let mut check = |mut o: Occlusion, camera: DVec3, radius: i32| {
o.settle(camera, radius);
answers(&o, o.columns.section_of(camera), radius);
let g = o.graph.as_ref().expect("searched");
if g.height < o.columns.sections + 2 {
clamped += 1;
// The first section above the box, over the camera's column.
band[usize::from(o.contains((g.from.0, g.y0 + g.height, g.from.2)))] += 1;
}
};
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);
check(o, at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9), radius);
}
}
let mut cave = Occlusion::new(0, 24);
for (dx, dy, dz) in STEPS {
cave.set((dx, 1 + dy, dz), 0);
}
check(cave, at((0, 1, 0)), 4);
let mut above = Occlusion::new(0, 24);
random_world(&mut above, 7, 900, 6);
check(above, DVec3::new(3.3, 400.0, -5.6), 6);
assert!(
clamped > 0 && band[0] > 0 && band[1] > 0,
"{clamped} clamped, the band above hidden and seen {band:?}"
);
}
/// Whenever an answer of `contains` changes, so does the generation: over
/// 300 random steps, each a section set (open, closed or anything between),
/// a column removed, the camera moved to another section (now and then
/// above or below the world), or now and then another world, after a
/// `clear` or a teleport that drops every column (`remove_columns`), each
/// followed by a frame's `update` or a settled search; then an update from
/// a section the graph never reached. The exported graph answers as
/// `contains` after every step.
#[test]
fn every_change_of_the_answers_changes_the_generation() {
let mut seed = 0x0bad_5eed_u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let (spread, radius, around) = (5, 3, (0, 0, 0));
let offset = DVec3::new(3.3, -5.7, 1.9);
let mut o = Occlusion::new(0, 24);
random_world(&mut o, 0x2545_f491, 400, spread);
let mut camera = at((0, 2, 0)) + offset;
o.settle(camera, radius);
let mut last = (o.generation(), answers(&o, around, spread + radius));
let mut changed = 0;
for step in 0..300 {
let r = next();
let key = (
(r % 11) as i32 - spread,
((r >> 8) % 7) as i32,
((r >> 16) % 11) as i32 - spread,
);
match r >> 24 & 15 {
0..=7 => {
let vis = match r >> 28 & 3 {
0 => ALL_VISIBLE,
1 => 0,
_ => next() & ALL_VISIBLE,
};
o.set(key, vis);
}
8 | 9 => o.remove_column(key.0, key.2),
10..=13 => {
let y = match r >> 32 & 7 {
0 => 30,
1 => -3,
_ => key.1,
};
camera = at((key.0, y, key.2)) + offset;
}
_ if r >> 36 & 7 == 0 => {
o.clear();
random_world(&mut o, next(), 400, spread);
}
_ if r >> 36 & 7 == 1 => {
// A teleport: every column goes at once.
let all: FxHashSet<_> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
random_world(&mut o, next(), 400, spread);
}
_ => {}
}
if r >> 40 & 1 == 0 {
o.update(camera, radius);
} else {
o.settle(camera, radius);
}
let now = (o.generation(), answers(&o, around, spread + radius));
if now.1 != last.1 {
assert_ne!(
now.0, last.0,
"step {step}: answers changed, the generation not"
);
changed += 1;
}
last = now;
}
assert!(changed >= 50, "{changed} steps changed answers");
// Walled in, then past the wall: the graph never reached the camera's
// section, and goes until the next search lands. (The first settle may
// land a search that started before the wall.)
for (dx, dy, dz) in STEPS {
o.set((dx, 1 + dy, dz), 0);
}
o.settle(at((0, 1, 0)), radius);
o.settle(at((0, 1, 0)), radius);
let walled = (o.generation(), answers(&o, around, spread + radius));
assert!(!o.contains((3, 1, 0)), "past the wall");
o.update(at((3, 1, 0)), radius);
assert_ne!(answers(&o, around, spread + radius), walled.1);
assert_ne!(o.generation(), walled.0, "the graph went");
}
}
@@ -194,6 +194,8 @@ pub(super) struct Draw {
/// The pack's shadow program draws it into the shadow map too.
pub shadow: bool,
pub slot: u32,
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
pub portal: bool,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
@@ -375,38 +377,87 @@ impl PackEntities {
pack,
casts,
overlay,
false,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
rest.extend_from_slice(run);
}
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
continue;
let whole = &entities[range];
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
// is theirs), for gbuffers_beaconbeam.
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
let parts: [(&[Vertex], Part); 3] = if split {
other.clear();
portal_faces.clear();
beam_faces.clear();
for quad in whole.chunks(4) {
match quad[0].layer & 0xffff {
9 | 10 => portal_faces.extend_from_slice(quad),
6 => beam_faces.extend_from_slice(quad),
_ => other.extend_from_slice(quad),
}
}
[
(&other, Part::Plain),
(&portal_faces, Part::Portal),
(&beam_faces, Part::Beam),
]
} else {
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
for (run, part) in parts {
if run.is_empty() {
continue;
}
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
Part::Plain => program_of(&span).filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
}),
};
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
let Some(program) = program.filter(|_| pack || casts) else {
continue;
};
let start = self.scratch.len() as u32;
match part {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
if part == Part::Portal {
as_portal_texture(&mut self.scratch[start as usize..]);
}
let vertices = self.scratch.len() as u32 - start;
let overlay = self.scratch[start as usize].layer & 0xffff;
let portal = part == Part::Portal;
self.push_draw(
span,
posed + start,
vertices,
program,
pack,
casts,
overlay,
portal,
);
}
self.push_draw(
span,
posed + start,
run.len() as u32,
program,
pack,
casts,
run[0].layer & 0xffff,
);
}
if self.draws.is_empty() && posed == 0 {
return;
@@ -453,6 +504,7 @@ impl PackEntities {
pack: bool,
casts: bool,
overlay: u32,
portal: bool,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
@@ -476,6 +528,7 @@ impl PackEntities {
pack,
shadow: casts,
slot: self.draws.len() as u32,
portal,
});
}
}
@@ -504,13 +557,104 @@ fn plain(run: &[Vertex]) -> bool {
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// What part of a block entity's run goes to which program (`prepare`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Part {
Plain,
Portal,
Beam,
}
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
/// beam, the sprite's v range in the light bytes) for a pack: each face cut where its texture
/// repeats, so every piece holds one copy of the sprite in plain atlas coordinates (at most 64
/// pieces a face; a taller beam stretches its copies), white light and its colour kept.
fn beam_segments(run: &[Vertex], out: &mut Vec<Vertex>) {
for quad in run.as_chunks::<4>().0 {
let wrap = quad[0].light;
let (v0, size) = (
(wrap & 65535) as f32 / 65535.0,
(wrap >> 16) as f32 / 65535.0,
);
// Corners 0 and 3 at the beam's end (v1), 1 and 2 at its start (v2); 0 and 1 on one
// side, 2 and 3 on the other.
let (v1, v2) = (quad[0].uv[1], quad[1].uv[1]);
if (v1 - v2).abs() < 1e-6 {
continue;
}
let (lo, hi) = (v1.min(v2), v1.max(v2));
let first = lo.floor();
let repeats = (hi.ceil() - first).max(1.0);
let step = (repeats / 64.0).ceil().max(1.0);
let pieces = (repeats / step).ceil() as u32;
let at = |side: usize, v: f32| {
let (a, b) = if side == 0 {
(quad[1], quad[0])
} else {
(quad[2], quad[3])
};
let t = (v - v2) / (v1 - v2);
glam::Vec3::from(a.pos)
.lerp(glam::Vec3::from(b.pos), t)
.to_array()
};
for k in 0..pieces {
let base = first + k as f32 * step;
let (a, b) = (base.max(lo), (base + step).min(hi));
if b <= a {
continue;
}
// The end-side corners take the piece's bound nearer v1.
let (end_v, start_v) = if v1 > v2 { (b, a) } else { (a, b) };
for (i, corner) in quad.iter().enumerate() {
let side = usize::from(i >= 2);
let v = if i == 0 || i == 3 { end_v } else { start_v };
let mut out_v = *corner;
out_v.pos = at(side, v);
out_v.uv = [corner.uv[0], v0 + (v - base) / step * size];
out_v.layer = 0;
out_v.light = 0x0000_f0f0;
out.push(out_v);
}
}
}
}
/// `run` added to Horizon's own draw without End portal faces: under a shader pack Horizon's
/// entity shader would put its screen-projected starfield into the pack's buffers as if they
/// were its own frame (misplaced where the pack renders at another size), so a portal the pack
/// does not draw (its programs not there, or not yet) is better left out.
fn extend_without_portals(rest: &mut Vec<Vertex>, run: &[Vertex]) {
for quad in run.chunks(4) {
if !quad.iter().all(|v| matches!(v.layer & 0xffff, 6 | 9 | 10)) {
rest.extend_from_slice(quad);
}
}
}
/// End portal faces for a pack's `gbuffers_block` (block entities): each face textured with the
/// whole of `end_portal.png`, which the draw binds as the atlas (a texture of its own, so a
/// pack's own portal effect can wrap it), white, at full light; the pack tells the portal by its
/// `blockEntityId`.
fn as_portal_texture(run: &mut [Vertex]) {
const CORNERS: [[f32; 2]; 4] = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
for (i, v) in run.iter_mut().enumerate() {
v.uv = CORNERS[i % 4];
v.layer = 0;
v.color = 0xFFFF_FFFF;
v.light = 0x0000_f0f0;
}
}
/// The programs of entity runs, in the order they draw.
pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
pub const BEAM: usize = 5;
pub const PROGRAMS: usize = 6;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -587,11 +731,11 @@ mod tests {
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
@@ -654,6 +798,103 @@ fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
}
}
#[cfg(test)]
mod portal_beam_tests {
use super::*;
fn quad(layer: u32, light: u32, corners: [([f32; 3], [f32; 2]); 4]) -> [Vertex; 4] {
corners.map(|(pos, uv)| Vertex {
pos,
uv,
layer,
color: 0xFF20_40FF,
light,
..bytemuck::Zeroable::zeroed()
})
}
#[test]
fn a_beam_face_becomes_one_sprite_copy_per_repeat() {
// `block_entity_render::beam`'s corner order: 0 and 3 at the end (v1), 1 and 2 at the
// start (v2); the sprite's v range (0.25, height 0.5) in the light bytes.
let wrap = (0.25f32 * 65535.0).round() as u32 | ((0.5f32 * 65535.0).round() as u32) << 16;
let face = quad(
6,
wrap,
[
([0.0, 3.0, 0.0], [0.9, 3.0]),
([0.0, 0.0, 0.0], [0.9, 0.0]),
([1.0, 0.0, 0.0], [0.1, 0.0]),
([1.0, 3.0, 0.0], [0.1, 3.0]),
],
);
let mut out = Vec::new();
beam_segments(&face, &mut out);
assert_eq!(out.len(), 12, "three repeats, three pieces");
for (k, piece) in out.chunks(4).enumerate() {
let (bottom, top) = (k as f32, k as f32 + 1.0);
assert_eq!([piece[0].pos[1], piece[1].pos[1]], [top, bottom]);
assert_eq!([piece[2].pos[1], piece[3].pos[1]], [bottom, top]);
for v in piece {
assert!((0.25 - 1e-4..=0.75 + 1e-4).contains(&v.uv[1]), "{:?}", v.uv);
assert_eq!(v.layer, 0);
assert_eq!(v.light, 0x0000_f0f0);
assert_eq!(v.color, 0xFF20_40FF);
}
assert!((piece[0].uv[1] - 0.75).abs() < 1e-4 && (piece[1].uv[1] - 0.25).abs() < 1e-4);
assert_eq!([piece[0].uv[0], piece[2].uv[0]], [0.9, 0.1]);
}
}
#[test]
fn a_very_tall_beam_is_cut_into_at_most_64_pieces() {
let face = quad(
6,
0xFFFF_0000,
[
([0.0, 2000.0, 0.0], [1.0, 2000.0]),
([0.0, 0.0, 0.0], [1.0, 0.0]),
([1.0, 0.0, 0.0], [0.0, 0.0]),
([1.0, 2000.0, 0.0], [0.0, 2000.0]),
],
);
let mut out = Vec::new();
beam_segments(&face, &mut out);
assert!(out.len() / 4 <= 64, "{} pieces", out.len() / 4);
let top = out.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
let bottom = out.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
assert_eq!(
(bottom, top),
(0.0, 2000.0),
"the pieces still span the whole beam"
);
}
#[test]
fn portal_faces_take_the_whole_portal_texture_at_full_light() {
let mut face = quad(9, 0, [([0.0; 3], [0.0; 2]); 4]).to_vec();
as_portal_texture(&mut face);
let uvs: Vec<[f32; 2]> = face.iter().map(|v| v.uv).collect();
assert_eq!(uvs, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
assert!(
face.iter()
.all(|v| v.layer == 0 && v.color == 0xFFFF_FFFF && v.light == 0x0000_f0f0)
);
}
#[test]
fn horizon_keeps_no_portal_or_beam_face_under_a_pack() {
let mut run = Vec::new();
for layer in [0, 6, 9, 10, 2] {
run.extend(quad(layer, 0, [([0.0; 3], [0.0; 2]); 4]));
}
let mut rest = Vec::new();
extend_without_portals(&mut rest, &run);
let kept: Vec<u32> = rest.chunks(4).map(|q| q[0].layer).collect();
assert_eq!(kept, [0, 2]);
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
@@ -35,7 +35,9 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
dimension: if def.is_1d() {
wgpu::TextureViewDimension::D1
} else if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
@@ -51,6 +53,15 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
.collect()
}
/// The texture dimension a view of `view` needs.
fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension {
match view {
wgpu::TextureViewDimension::D1 => wgpu::TextureDimension::D1,
wgpu::TextureViewDimension::D3 => wgpu::TextureDimension::D3,
_ => wgpu::TextureDimension::D2,
}
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
@@ -85,7 +96,9 @@ pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), S
));
}
let limits = device.limits();
let largest = if def.is_3d() {
let largest = if def.is_1d() {
limits.max_texture_dimension_1d
} else if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
@@ -128,11 +141,7 @@ pub(super) fn resize_images(
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
dimension: texture_dimension(image.dimension),
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
view_formats: &[],
@@ -321,11 +330,7 @@ pub(super) fn stand_ins(
},
mip_level_count: 1,
sample_count: 1,
dimension: if dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
dimension: texture_dimension(dimension),
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
+377 -33
View File
@@ -7,14 +7,17 @@
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only.
//! written last (OQ-8). A cleared buffer has both textures cleared at the start of the frame:
//! with an odd number of flips a frame, the texture a program writes was "main" the frame before
//! and may still hold what was stored into it after that frame's clear (Iris 1.11.7 blends onto
//! the clear value there, probed with a pack of our own).
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, Val};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -28,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
#[cfg(test)]
mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -365,6 +371,8 @@ struct Pass {
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
/// 16–31) and the shadowcolor sides read (bits 32–33).
groups: HashMap<u64, wgpu::BindGroup>,
/// The same for End portal draws, with `end_portal.png` bound as the atlas.
portal_groups: HashMap<u64, wgpu::BindGroup>,
/// (binding, target) of each image: for a buffer, the side its sampler reads.
images: Vec<(u32, Image)>,
/// Binds the pack's storage buffers in set 4.
@@ -539,6 +547,10 @@ pub struct PackPost {
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
shadow_terrain: Option<Pass>,
/// `end_portal.png`'s atlas rect this frame, and a texture of its own copied from it that
/// End portal draws bind as their atlas.
portal_rect: [f32; 4],
portal_texture: Option<([f32; 4], wgpu::TextureView)>,
shadow_water: Option<Pass>,
shadow_comp: Vec<Pass>,
/// Buffers with mipmaps (filterable ones a program asks them for).
@@ -566,6 +578,10 @@ pub struct PackPost {
composite_pre: u16,
formats: [wgpu::TextureFormat; BUFFERS],
clears: [Option<[f32; 4]>; BUFFERS],
/// Buffers of a three-channel format, and whether their textures are new: then they get
/// alpha 1 before the next frame.
rgb: u16,
rgb_unset: bool,
used: u16,
/// `size.buffer.*`.
sizes: [Option<horizon_shaders::BufferSize>; BUFFERS],
@@ -650,6 +666,33 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
}
}
/// The SNORM wgpu format of SNORM pack format `f`, where this adapter can render to it. Iris
/// stores such a buffer clamped to [-1, 1] and quantised to its bits; elsewhere the half floats of
/// [`texture_format`] stand in.
fn snorm_format(
f: Format,
features: wgpu::Features,
adapter: &wgpu::Adapter,
) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
let format = match (f.kind, f.bits, f.channels) {
(Kind::Snorm, 8, 1) => T::R8Snorm,
(Kind::Snorm, 8, 2) => T::Rg8Snorm,
(Kind::Snorm, 8, _) => T::Rgba8Snorm,
(Kind::Snorm, 16, 1) => T::R16Snorm,
(Kind::Snorm, 16, 2) => T::Rg16Snorm,
(Kind::Snorm, 16, _) => T::Rgba16Snorm,
_ => return None,
};
let mut needs = wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
if f.bits == 16 {
needs |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
}
let renders = (adapter.get_texture_format_features(format).allowed_usages)
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT);
(features.contains(needs) && renders).then_some(format)
}
/// The texture behind sampler `name` in a program of the shadow buffers (`shadow_space`: buffer
/// n is shadowcolor n) or of the others, when the shadow pass runs (`shadow_pass`). `shadow` is
/// `shadowtex1` when the program also declares `watershadow` (6.3).
@@ -666,11 +709,7 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = match name {
"shadowcolor" | "shadowcolor0" => Some(0),
"shadowcolor1" => Some(1),
_ => None,
};
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -1039,6 +1078,8 @@ struct Resources<'a> {
gpu: Gpu,
/// The buffers whose format can be bound as an image.
storable: u16,
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
rgb: u16,
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
/// later programs, which read what was written, as in Iris. `final` continues
@@ -1061,7 +1102,7 @@ fn bindings(
buffers: &[u32],
geometry: Option<&Geometry<'_>>,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; 2]>,
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
pack: &Resources<'_>,
) -> Option<(
@@ -1253,7 +1294,9 @@ fn bindings(
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
return None;
};
let dimension = if i.ty.ends_with("3D") {
let dimension = if i.ty.ends_with("1D") {
wgpu::TextureViewDimension::D1
} else if i.ty.ends_with("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
@@ -1381,7 +1424,7 @@ fn build_pass(
device: &wgpu::Device,
p: horizon_shaders::PostProgram,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; 2]>,
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
screen: wgpu::TextureFormat,
notes: &mut Vec<String>,
geometry: Option<&Geometry<'_>>,
@@ -1392,7 +1435,7 @@ fn build_pass(
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
let index = |name: &str| {
pack_uniforms::index(name).or_else(|| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
@@ -1441,6 +1484,12 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if !p.group.shadow()
&& pack.rgb & 1 << buffer != 0
&& let Some(t) = &mut state
{
t.write_mask = wgpu::ColorWrites::COLOR;
}
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
@@ -1605,6 +1654,7 @@ fn build_pass(
atlas: geometry.and_then(|g| g.atlas.clone()),
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
copies: image_buffers(&images) & reads,
reads: reads | image_buffers(&images),
buffers: !interface.buffers.is_empty(),
@@ -1638,7 +1688,7 @@ fn build_compute(
group: Group,
c: horizon_shaders::ComputeProgram,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; 2]>,
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
custom: &[String],
pack: &Resources<'_>,
@@ -1739,6 +1789,7 @@ fn build_compute(
atlas: None,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
buffers: !interface.buffers.is_empty(),
images,
compute: Vec::new(),
@@ -1763,16 +1814,26 @@ impl PackPost {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
let formats: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
let f = post.config.buffers[i].format;
snorm_format(f, features, adapter).unwrap_or_else(|| texture_format(f, features))
});
let sky_clear = Some(&post.config.buffers[0])
.filter(|b| b.clear)
.and_then(|b| b.clear_color);
// A three-channel buffer reads alpha 1 whatever is written or cleared, as in Iris: its
// passes leave alpha alone, and its clears and first contents set it to 1.
let rgb = (0..BUFFERS)
.filter(|&i| post.config.buffers[i].format.channels == 3)
.fold(0u16, |m, i| m | 1 << i);
let clears = std::array::from_fn(|i| {
let b = &post.config.buffers[i];
b.clear.then(|| {
b.clear_color
.unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] })
let mut c = (b.clear_color).unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] });
if rgb & 1 << i != 0 {
c[3] = 1.0;
}
c
})
});
let mut notes = post.notes;
@@ -1784,6 +1845,8 @@ impl PackPost {
}
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
// A first run finds the names neither the frame nor the pack defines.
@@ -1799,13 +1862,14 @@ impl PackPost {
let mut used = 1u16;
let draws = DrawUniforms::new(device);
// The shadow buffers' formats when the shadow pass runs; their programs see them as
// buffers 0 and 1.
let shadow_formats: Option<[wgpu::TextureFormat; 2]> = post.shadow_pass.then(|| {
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
});
// buffers 0 to 7.
let shadow_formats: Option<[wgpu::TextureFormat; SHADOW_COLORS]> =
post.shadow_pass.then(|| {
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
});
let shadow_space: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
shadow_formats
.filter(|_| i < 2)
.filter(|_| i < SHADOW_COLORS)
.map_or(formats[i], |f| f[i])
});
let (mut storage, mut copied, mut image_copied, mut snapshotted) = (0u16, 0u16, 0u64, 0u64);
@@ -1873,6 +1937,7 @@ impl PackPost {
indices: indices.as_ref(),
gpu,
storable,
rgb,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
};
@@ -2020,6 +2085,7 @@ impl PackPost {
post.entities_translucent,
post.particles,
post.damaged_block,
post.beacon_beam,
];
let late = [
false,
@@ -2027,6 +2093,7 @@ impl PackPost {
post.translucent_late,
post.particles_late,
false,
true,
];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
@@ -2242,7 +2309,7 @@ impl PackPost {
0
};
m | own | p.shadow_reads
});
}) & shadow_colors;
let depth1 = every_pass()
.flat_map(|p| &p.samplers)
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
@@ -2318,7 +2385,13 @@ impl PackPost {
}
});
let float = wgpu::TextureSampleType::Float { filterable: true };
let blit_targets = std::iter::once(target(formats[0]));
let mut world_target = target(formats[0]);
if rgb & 1 != 0
&& let Some(t) = &mut world_target
{
t.write_mask = wgpu::ColorWrites::COLOR;
}
let blit_targets = std::iter::once(world_target);
// Cleared by the pass's load operation; the shader writes nothing there.
let blit_targets: Vec<_> = blit_targets
.chain(cleared.iter().map(|&b| {
@@ -2425,6 +2498,8 @@ impl PackPost {
depth2,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
portal_texture: None,
shadow_water,
shadow_comp,
mipped,
@@ -2443,6 +2518,8 @@ impl PackPost {
composite_pre: mask(&post.composite_pre),
formats,
clears,
rgb,
rgb_unset: true,
used,
storage,
copied,
@@ -2810,7 +2887,10 @@ impl PackPost {
let group = self.bind(device, queue, encoder, pass, size);
let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&pass.name),
timestamp_writes: None,
timestamp_writes: self
.timing
.as_mut()
.and_then(|t| t.compute_writes(&pass.name)),
});
cp.set_pipeline(&pipeline);
cp.set_bind_group(0, &group, &[]);
@@ -2839,7 +2919,7 @@ impl PackPost {
// one clearing them.
let (depth_ops, shadow_ops) = match &mut self.shadow_targets {
Some(s) if pass.group.shadow() => s.clear_ops(pass.writes.iter().map(|w| w.1)),
_ => (super::pack_shadow::load(), [load; 2]),
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
};
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
@@ -3024,6 +3104,42 @@ impl PackPost {
continue;
}
let group = self.bind(device, queue, encoder, pass, size);
// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs
// that wrap it with their own coordinates sample the portal sprite only.
let portal_group = if geometry
.draws
.iter()
.any(|d| d.pack && d.program == program && d.portal)
&& let Some(atlas) = pass.atlas.clone().or_else(|| self.atlas.clone())
&& self.portal_rect[2] > 0.0
{
if self
.portal_texture
.as_ref()
.is_none_or(|(rect, _)| *rect != self.portal_rect)
{
pass.portal_groups.clear();
let view = super::pack_weather::copy_sprite(
device,
encoder,
atlas.texture(),
self.portal_rect,
);
self.portal_texture = Some((self.portal_rect, view));
}
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
// `bind` caches groups by a key without the atlas: the portal groups are kept
// in a cache of their own, swapped in with the texture.
let saved = std::mem::replace(&mut pass.atlas, view);
let groups =
std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
let g = self.bind(device, queue, encoder, pass, size);
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
pass.atlas = saved;
Some(g)
} else {
None
};
{
let mut rp = self.begin(encoder, pass);
rp.set_pipeline(pass.render());
@@ -3036,7 +3152,14 @@ impl PackPost {
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
let mut on_portal = false;
for d in draws {
if let Some(portal) = &portal_group
&& d.portal != on_portal
{
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
on_portal = d.portal;
}
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
@@ -3110,9 +3233,11 @@ impl PackPost {
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
portal: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
self.portal_rect = portal;
let shadow = self.shadow_casters();
let Some(e) = &mut self.entities else {
rest.clear();
@@ -3436,16 +3561,25 @@ impl PackPost {
}
/// Regenerates the mip chain of buffer `b`'s side that programs read.
fn mipmap(&self, encoder: &mut wgpu::CommandEncoder, b: usize) {
fn mipmap(&mut self, encoder: &mut wgpu::CommandEncoder, b: usize) {
let (Some(targets), Some((pipeline, _))) = (&self.targets, self.down.get(&self.formats[b]))
else {
return;
};
let Some(mips) = &targets.mips[b] else { return };
let side = &mips[usize::from(self.main & 1 << b != 0)];
// Timed as one pass per buffer (its first level), so the pass timer shows the chains.
let label = format!("mipmaps colortex{b}");
let mut first = true;
for (attachment, group) in &side.levels {
let timestamp_writes = if std::mem::take(&mut first) {
self.timing.as_mut().and_then(|t| t.writes(&label))
} else {
None
};
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack mipmap"),
timestamp_writes,
color_attachments: &[attach(
attachment,
wgpu::Operations {
@@ -3559,6 +3693,7 @@ impl PackPost {
return;
}
self.setup_pending = true;
self.rgb_unset = true;
let sizes: [[u32; 2]; BUFFERS] =
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
@@ -4015,6 +4150,26 @@ impl PackPost {
..Default::default()
});
}
// Three-channel buffers start out with alpha 1 on both sides: a pass writes the side the
// frame's clear didn't touch.
if self.rgb_unset {
self.rgb_unset = false;
let start = wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
};
for b in (0..BUFFERS).filter(|&b| self.rgb & 1 << b != 0) {
for alt in [false, true] {
if let Some(v) = view(self.main, b, alt) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack three-channel start"),
color_attachments: &[attach(v, start)],
..Default::default()
});
}
}
}
}
let clear_op = |b: usize| {
let c = self.clears[b].unwrap_or_default();
wgpu::Operations {
@@ -4035,26 +4190,35 @@ impl PackPost {
// Attachments share a size and stay within the device's count and byte limits.
let limits = device.limits();
let max_attachments = (limits.max_color_attachments as usize).min(8);
let mut by_size: Vec<([u32; 2], Vec<usize>)> = Vec::new();
for &b in &self.other_clears {
// The buffers the blit doesn't clear, and the other texture of every cleared buffer.
let alt_clears = (0..BUFFERS)
.filter(|&b| self.clears[b].is_some() && view(self.main, b, true).is_some())
.map(|b| (b, true));
let mut by_size: Vec<([u32; 2], Vec<(usize, bool)>)> = Vec::new();
for (b, alt) in self
.other_clears
.iter()
.map(|&b| (b, false))
.chain(alt_clears)
{
let at = targets.sizes[b];
match by_size.iter_mut().find(|(s, buffers)| {
let bytes = buffers
.iter()
.fold(0, |bytes, &b| attachment_bytes(bytes, self.formats[b]));
.fold(0, |bytes, &(b, _)| attachment_bytes(bytes, self.formats[b]));
*s == at
&& buffers.len() < max_attachments
&& attachment_bytes(bytes, self.formats[b])
<= limits.max_color_attachment_bytes_per_sample
}) {
Some((_, l)) => l.push(b),
None => by_size.push((at, vec![b])),
Some((_, l)) => l.push((b, alt)),
None => by_size.push((at, vec![(b, alt)])),
}
}
for (_, chunk) in &by_size {
let attachments: Vec<_> = chunk
.iter()
.map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b)))
.map(|&(b, alt)| attach(view(self.main, b, alt).expect("used buffer"), clear_op(b)))
.collect();
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack clear"),
@@ -4087,9 +4251,18 @@ impl PackPost {
self.blit_group = Some(([world.clone(), world_depth.clone()], group));
}
{
// The blit leaves a three-channel colortex0's alpha at the 1 it starts with.
let world_ops = if self.rgb & 1 != 0 {
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
}
} else {
overwrite
};
let mut attachments = vec![attach(
view(self.main, 0, false).expect("colortex0"),
overwrite,
world_ops,
)];
for &b in &self.blit_clears {
attachments.push(attach(
@@ -4291,6 +4464,9 @@ impl PackPost {
}
}
#[cfg(test)]
mod snorm_tests;
#[cfg(test)]
#[path = "pack_post/depth_test.rs"]
mod depth_test;
@@ -4437,6 +4613,7 @@ mod tests {
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
};
@@ -4514,6 +4691,7 @@ mod tests {
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
};
@@ -4594,6 +4772,169 @@ mod tests {
);
}
/// `blend.<program>` of a composite program blends its output onto what the buffer's side
/// holds (Iris 1.11.7, probed: 0.25 over 0.75 with SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO and
/// a source alpha of 0.25 reads 0.625).
#[test]
fn a_composite_program_blends_onto_its_buffer() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
// A pass's pipeline layout has six bind group slots.
let desc = wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&desc)) else {
return;
};
let dir = std::env::temp_dir().join(format!("hz-composite-blend-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let file = |name: &str, text: &str| std::fs::write(shaders.join(name), text).unwrap();
file(
"composite.vsh",
"#version 120\nvoid main() { gl_Position = ftransform(); }\n",
);
file(
"composite.fsh",
"#version 120\n/* DRAWBUFFERS:3 */\n\
void main() { gl_FragData[0] = vec4(0.25, 0.25, 1.0, 0.25); }\n",
);
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let properties = horizon_shaders::Properties::parse(
"blend.composite = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
&horizon_shaders::Macros::default(),
);
let mut post = horizon_shaders::load_post(
&pack,
"",
&properties,
&horizon_shaders::Macros::standard(),
&Default::default(),
);
std::fs::remove_dir_all(&dir).unwrap();
let program = post.programs.remove(0);
let buffers = pack_images::Buffers::new(
&device,
Default::default(),
Default::default(),
wgpu::ShaderStages::FRAGMENT,
);
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &[],
images: &[],
left_out: &[],
buffers: &buffers,
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
};
let draws = DrawUniforms::new(&device);
let mut notes = Vec::new();
let pass = build_pass(
&device,
program,
&[T::Rgba8Unorm; BUFFERS],
None,
T::Rgba8Unorm,
&mut notes,
None,
&[],
&draws.layout,
&pack,
)
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 64,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: T::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let head = [64.0f32, 1.0, 1.0 / 64.0, 1.0];
queue.write_buffer(&pass.ubo, 0, bytemuck::cast_slice(&head));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pass.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: pass.ubo.as_entire_binding(),
}],
});
let mut encoder = device.create_command_encoder(&Default::default());
let before = wgpu::Color {
r: 0.75,
g: 0.5,
b: 0.25,
a: 0.5,
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(
&view,
wgpu::Operations {
load: wgpu::LoadOp::Clear(before),
store: wgpu::StoreOp::Store,
},
)],
..Default::default()
});
{
// Loaded, as `PackPost::render` attaches a buffer a composite pass writes.
let load = wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
};
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(&view, load)],
..Default::default()
});
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
rp.draw(0..6, 0..1);
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texel: Vec<u8> = read.slice(..).get_mapped_range().unwrap()[..4].to_vec();
// rgb = 0.25 × src + 0.75 × dst, alpha = the source's.
let expected = [0.625, 0.4375, 0.4375, 0.25].map(|v: f32| v * 255.0);
for (got, want) in texel.iter().zip(expected) {
assert!(
(f32::from(*got) - want).abs() <= 1.0,
"{texel:?} vs {expected:?}"
);
}
}
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
@@ -4654,3 +4995,6 @@ mod tests {
#[cfg(test)]
mod destination_tests;
#[cfg(test)]
mod rgb_alpha_tests;
@@ -151,14 +151,14 @@ fn pack(
post
}
struct Fixture {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
pub(super) struct Fixture {
pub(super) adapter: wgpu::Adapter,
pub(super) device: wgpu::Device,
pub(super) queue: wgpu::Queue,
}
impl Fixture {
fn new() -> Option<Self> {
pub(super) fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("Post destination test skipped: no GPU adapter");
@@ -198,7 +198,7 @@ impl Fixture {
.create_view(&Default::default())
}
fn build(&self, post: Post) -> PackPost {
pub(super) fn build(&self, post: Post) -> PackPost {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
&self.device,
@@ -226,7 +226,7 @@ impl Fixture {
post
}
fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
@@ -454,3 +454,70 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
#[path = "clear_budget_tests.rs"]
mod clear_budget_tests;
/// A cleared buffer written three times a frame flips an odd number of times, so its first write
/// goes to the texture that was "main" the frame before, which a later program wrote after that
/// frame's clear. Where that first write discards, Iris 1.11.7 reads the clear value (a probe
/// pack of our own with these three programs reads 0 there, not the previous frame's 0.25).
#[test]
fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for (program, fragment) in [
(
"composite",
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:4 */\n\
void main() { if(uv.x>0.5) discard; gl_FragData[0]=vec4(0.5,0.5,0.5,1); }\n",
),
(
"composite1",
"#version 120\nuniform sampler2D colortex4;\nvarying vec2 uv;\n/* RENDERTARGETS:4,5 */\n\
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=texture2D(colortex4,uv); }\n",
),
(
"composite2",
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,1); }\n",
),
(
"final",
"#version 120\nuniform sampler2D colortex5;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex5,uv).rgb,1); }\n",
),
] {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"both-textures-cleared",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(n, p)| {
let expected = if n % 16 < 8 {
[128, 128, 128, 255]
} else {
[0, 0, 0, 255]
};
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,82 @@
//! `HIGHER_SHADOWCOLOR`: shadowcolor2 to 7 are written by shadow programs' directives, read by
//! them and by the other programs, and cleared to their own colours, as in Iris 1.11.7 (probe
//! packs p7951a to c; the PR has the readings). The programs here are original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("shadowcomp.vsh", VERTEX);
add(
"shadowcomp.fsh",
"#version 120\n/* RENDERTARGETS: 2,7 */\n\
void main() { gl_FragData[0]=vec4(0.25,0,0,1); gl_FragData[1]=vec4(0,0.5,0,1); }\n",
);
// A shadow program reads a higher buffer it didn't write.
add("shadowcomp1.vsh", VERTEX);
add(
"shadowcomp1.fsh",
"#version 120\n/* RENDERTARGETS: 3 */\nuniform sampler2D shadowcolor7;\nvarying vec2 uv;\n\
void main() { gl_FragData[0]=texture2D(shadowcolor7,uv); }\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows shadowcolor 2, 3, 5 and 6 in turn.
add(
"final.fsh",
"#version 330 compatibility\nconst int shadowMapResolution=16;\n\
const vec4 shadowcolor5ClearColor=vec4(0,0,0.75,1);\n\
uniform sampler2D shadowcolor2;\nuniform sampler2D shadowcolor3;\n\
uniform sampler2D shadowcolor5;\nuniform sampler2D shadowcolor6;\nin vec2 uv;\n\
void main() {\n vec4 c[4] = vec4[4](texture(shadowcolor2,uv), texture(shadowcolor3,uv),\n\
texture(shadowcolor5,uv), texture(shadowcolor6,uv));\n\
gl_FragColor = c[int(gl_FragCoord.x) % 4];\n}\n",
);
add(
"shaders.properties",
"shadow.enabled=true\niris.features.required = HIGHER_SHADOWCOLOR\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-higher-shadowcolor",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn higher_shadowcolor_buffers_are_written_read_and_cleared() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Two frames: the second starts from the first one's buffers.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
// shadowcolor2 written red, 3 a copy of 7 (green), 5 its clear colour, 6 white.
let expected = [
[64, 0, 0, 255],
[0, 128, 0, 255],
[0, 0, 191, 255],
[255; 4],
];
for (x, want) in expected.iter().enumerate() {
let got = pixels[x];
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1),
"frame {frame}, column {x}: {got:?}, expected {want:?}; row {:?}",
&pixels[..4]
);
}
}
}
@@ -0,0 +1,100 @@
//! Three-channel buffers read alpha 1, as in Iris 1.11.7: written with alpha 0, cleared with
//! alpha 0, or never written. The values come from probe pack p8106 run in Iris (the PR has
//! them); the programs here are the probe's, cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1,5,7 */\n\
/*\nconst int colortex0Format = RGB16F;\nconst int colortex1Format = RGB16F;\n\
const int colortex2Format = RGB8;\nconst int colortex3Format = RGB32F;\n\
const int colortex4Format = RGB32UI;\nconst int colortex5Format = RGBA16F;\n\
const int colortex7Format = RGB16;\n*/\n\
const vec4 colortex2ClearColor = vec4(0.25, 0.5, 0.75, 0.0);\n\
const bool colortex3Clear = false;\n\
void main() {\n gl_FragData[0] = vec4(0.25, 0.5, 0.75, 0.0);\n\
gl_FragData[1] = vec4(0.25, 0.5, 0.75, 0.0);\n gl_FragData[2] = vec4(0.25, 0.5, 0.75, 0.0);\n}\n",
);
add("composite1.vsh", VERTEX);
add(
"composite1.fsh",
"#version 330 compatibility\n/* RENDERTARGETS: 4 */\nlayout(location = 0) out uvec4 o;\n\
void main() { o = uvec4(5u, 6u, 7u, 0u); }\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows buffer x % 8: its alpha in red, its green in green.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nuniform sampler2D colortex1;\n\
uniform sampler2D colortex2;\nuniform sampler2D colortex3;\nuniform usampler2D colortex4;\n\
uniform sampler2D colortex5;\nuniform sampler2D colortex7;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
switch (p.x % 8) {\n\
case 0: c = texelFetch(colortex0, p, 0); break;\n\
case 1: c = texelFetch(colortex1, p, 0); break;\n\
case 2: c = texelFetch(colortex2, p, 0); break;\n\
case 3: c = texelFetch(colortex3, p, 0); break;\n\
case 4: c = vec4(texelFetch(colortex4, p, 0)) / 12.0; c.a *= 12.0; break;\n\
case 5: c = texelFetch(colortex5, p, 0); break;\n\
case 7: c = texelFetch(colortex7, p, 0); break;\n\
}\n gl_FragColor = vec4(c.a, c.g, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-rgb-alpha",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 3, "{:?}", post.notes);
post
}
#[test]
fn three_channel_buffers_read_alpha_1() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Twice: the second frame reads buffers that a first frame already used.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
// (alpha, green) per buffer: 0 is the world (black) through the blit; 1, 5 and 7 are
// written with alpha 0, 2 is cleared with alpha 0, 3 is never cleared nor written, 4 is
// RGB32UI written (5, 6, 7, 0). Only 5 (RGBA16F) keeps the alpha 0 it was given.
let expected = [
[255, 0],
[255, 128],
[255, 128],
[255, 0],
[255, 128],
[0, 128],
[0, 0],
[255, 128],
];
let row: Vec<[u8; 2]> = pixels[..8].iter().map(|p| [p[0], p[1]]).collect();
for (b, (got, want)) in row.iter().zip(expected).enumerate() {
if b == 6 {
continue;
}
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"frame {frame}, colortex{b}: (alpha, green) {got:?}, expected {want:?}; row {row:?}"
);
}
}
}
@@ -0,0 +1,121 @@
//! SNORM colortex buffers clamp to [-1, 1] and quantise, as in Iris 1.11.7, where the adapter
//! renders to SNORM formats. Expected values are those probe pack p8106 read back in Iris (the
//! PR has them); the programs here are the probe's, cut down. Values that land halfway between
//! two steps (-0.5 × 127) are left out: GPUs may round them either way.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
/*\nconst int colortex1Format = RGBA8_SNORM;\nconst int colortex2Format = RGBA16_SNORM;\n\
const int colortex3Format = R16_SNORM;\nconst int colortex4Format = RGBA8_SNORM;\n*/\n\
const vec4 colortex4ClearColor = vec4(-1.5, 0.1, 0.3337, 0.0);\n\
void main() {\n gl_FragData[0] = vec4(-1.5, 0.1, 0.3337, 1000.0);\n\
gl_FragData[1] = vec4(-1.5, 0.1, 0.3337, 1000.0);\n gl_FragData[2] = vec4(0.1);\n}\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
switch (p.x / 4) {\n\
case 0: c = texelFetch(colortex1, p, 0); break;\n\
case 1: c = texelFetch(colortex2, p, 0); break;\n\
case 2: c = texelFetch(colortex3, p, 0); break;\n\
case 3: c = texelFetch(colortex4, p, 0); break;\n\
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-snorm", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn snorm_buffers_clamp_and_quantise() {
let Some(gpu) = Fixture::new() else { return };
// The fixture's device asks for no features; SNORM targets need these.
let wanted = wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
let renders = [
wgpu::TextureFormat::Rgba8Snorm,
wgpu::TextureFormat::Rgba16Snorm,
]
.iter()
.all(|&f| {
(gpu.adapter.get_texture_format_features(f).allowed_usages)
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
});
if !gpu.adapter.features().contains(wanted) || !renders {
eprintln!("SNORM test skipped: this adapter doesn't render to SNORM formats");
return;
}
let (device, queue) = pollster::block_on(gpu.adapter.request_device(&wgpu::DeviceDescriptor {
required_features: wanted,
required_limits: gpu.adapter.limits(),
..Default::default()
}))
.expect("GPU device with SNORM formats");
let gpu = Fixture {
adapter: gpu.adapter,
device,
queue,
};
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [16, 1]);
let read: Vec<f32> = pixels
.iter()
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
.collect();
// Iris 1.11.7: RGBA8_SNORM written (-1.5, 0.1, 0.3337, 1000); RGBA16_SNORM the same;
// R16_SNORM written 0.1 (G and B read 0, alpha 1); RGBA8_SNORM cleared to
// (-1.5, 0.1, 0.3337, 0).
let expected = [
-1.0,
0.102_362_2,
0.330_708_7,
1.0, //
-1.0,
0.100_009_2,
0.333_689_4,
1.0, //
0.100_009_2,
0.0,
0.0,
1.0, //
-1.0,
0.102_362_2,
0.330_708_7,
0.0,
];
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
assert!(
(got - want).abs() <= 1e-6,
"buffer {} component {}: {got}, expected {want}; all {read:?}",
1 + n / 4,
n % 4
);
}
}
@@ -1,8 +1,10 @@
//! A shader pack's shadow buffers (spec 6.3): the shadow map's depth (`shadowtex0`, and
//! `shadowtex1` without translucent geometry), and the colour buffers `shadowcolor0/1`, each
//! `shadowtex1` without translucent geometry), and the colour buffers `shadowcolor0` to `7`, each
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -34,9 +36,9 @@ pub(super) struct ShadowTargets {
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides of each shadowcolor buffer used.
pub color: [Option<[wgpu::TextureView; 2]>; 2],
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
pub clears: [Option<[f32; 4]>; 2],
pub clears: [Option<[f32; 4]>; SHADOW_COLORS],
/// Bit n set: B is shadowcolor n's "main".
pub main: u16,
/// Nothing cleared yet this frame: the first pass into the shadow buffers clears them as it
@@ -79,7 +81,7 @@ impl ShadowTargets {
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; 2],
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
depth1: bool,
) -> Self {
@@ -143,12 +145,12 @@ impl ShadowTargets {
pub fn clear_ops(
&mut self,
writes: impl Iterator<Item = usize>,
) -> (Ops<f32>, [Ops<Color>; 2]) {
) -> (Ops<f32>, [Ops<Color>; SHADOW_COLORS]) {
if !std::mem::take(&mut self.fresh) {
return (load(), [load(); 2]);
return (load(), [load(); SHADOW_COLORS]);
}
let mut colors = [load(); 2];
for n in writes.filter(|&n| n < 2) {
let mut colors = [load(); SHADOW_COLORS];
for n in writes.filter(|&n| n < SHADOW_COLORS) {
if let Some(c) = self.clears[n] {
colors[n] = clear(c);
self.cleared |= 1 << n;
@@ -162,11 +164,12 @@ impl ShadowTargets {
}
/// Clears what no pass has cleared this frame: all of it when no shadow program drew, the
/// cleared shadowcolor buffers the first one did not write.
/// cleared shadowcolor buffers the first one did not write. One buffer a pass: eight wide
/// formats together would pass the device's bytes-per-sample limit.
pub fn clear_rest(&mut self, encoder: &mut wgpu::CommandEncoder) {
let depth = std::mem::take(&mut self.fresh);
let cleared = std::mem::replace(&mut self.cleared, 0b11);
let attachments: Vec<_> = (0..2)
let mut depth = std::mem::take(&mut self.fresh);
let cleared = std::mem::replace(&mut self.cleared, u8::MAX);
let attachments = (0..SHADOW_COLORS)
.filter(|n| cleared & 1 << n == 0)
.filter_map(|n| {
Some(Some(wgpu::RenderPassColorAttachment {
@@ -175,24 +178,30 @@ impl ShadowTargets {
resolve_target: None,
ops: clear(self.clears[n]?),
}))
})
.collect();
if !depth && attachments.is_empty() {
return;
});
let passes: Vec<Vec<_>> = attachments.map(|a| vec![a]).collect();
let passes = if passes.is_empty() && depth {
vec![Vec::new()]
} else {
passes
};
for attachments in &passes {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack shadow clear"),
color_attachments: attachments,
depth_stencil_attachment: std::mem::take(&mut depth).then_some(
wgpu::RenderPassDepthStencilAttachment {
view: &self.depth.1,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
},
),
..Default::default()
});
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack shadow clear"),
color_attachments: &attachments,
depth_stencil_attachment: depth.then_some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth.1,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
/// `shadowtex1`: the shadow map as it is now (before translucent geometry).
@@ -265,6 +265,13 @@ pub const NAMES: &[&str] = &[
"playerMood",
"constantMood",
"centerDepthSmooth",
"alphaTestRef",
"entityId",
"blockEntityId",
"entityColor",
GBUFFERS_FOG_MODE,
GBUFFERS_FOG_SHAPE,
GBUFFERS_CENTER_DEPTH,
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -277,6 +284,13 @@ pub const NAMES: &[&str] = &[
"cloudHeight",
];
/// What programs that draw the game's geometry (gbuffers, shadow) read as `fogMode`,
/// `fogShape` and `centerDepthSmooth`: no GLSL name can hold a dot, so no pack reads these
/// directly.
const GBUFFERS_FOG_MODE: &str = "gbuffers.fogMode";
const GBUFFERS_FOG_SHAPE: &str = "gbuffers.fogShape";
const GBUFFERS_CENTER_DEPTH: &str = "gbuffers.centerDepthSmooth";
/// Values carried from frame to frame.
pub struct FrameUniforms {
pub values: Vec<Value>,
@@ -472,18 +486,16 @@ impl FrameUniforms {
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
// Vanilla fades by the stronger of two linear fogs; a pack gets the environmental one
// (`FogData::environmental_start`/`environmental_end`), as Iris 1.11.7 gives it in every
// program kind: 0 to 1024 on a clear day, -160 to 768 in rain, -8 to 96 under water, 10
// to 96 in the Nether (measured with a probe pack).
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => {
let (start, end) = if f.environmental_end < f.render_distance_end {
(f.environmental_start, f.environmental_end)
} else {
(f.render_distance_start, f.render_distance_end)
};
([f.color[0], f.color[1], f.color[2]], start, end)
}
Some(f) => (
[f.color[0], f.color[1], f.color[2]],
f.environmental_start,
f.environmental_end,
),
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let light = shadow_light(frame, &self.shadow);
@@ -545,12 +557,22 @@ impl FrameUniforms {
"thunderStrength" => Value::f(&[inputs.thunder]),
"fogColor" => Value::f(&fog_color),
"skyColor" => Value::f(&frame.sky.sky_color),
// GL_LINEAR: vanilla's fog is linear.
"fogMode" => Value::i(&[9729]),
// Iris 1.11.7, measured: programs that draw no geometry read no fog mode (0) and
// shape (-1); those that do read GL_LINEAR and a cylinder (1). The density is 0.
"fogMode" => Value::i(&[0]),
GBUFFERS_FOG_MODE => Value::i(&[9729]),
"fogStart" => Value::f(&[fog_start]),
"fogEnd" => Value::f(&[fog_end]),
"fogDensity" => Value::f(&[1.0]),
"fogShape" => Value::i(&[0]),
"fogDensity" => Value::f(&[0.0]),
"fogShape" => Value::i(&[-1]),
GBUFFERS_FOG_SHAPE => Value::i(&[1]),
// Iris 1.11.7 gives gbuffers programs 0 (measured in 16 scenes).
GBUFFERS_CENTER_DEPTH => Value::f(&[0.0]),
// Outside a draw (gbuffers programs get these per draw), as Iris 1.11.7 gives
// them to composite and final programs (measured).
"alphaTestRef" => Value::f(&[0.1]),
"blockEntityId" => Value::i(&[-1]),
"entityColor" => Value::f(&[0.0; 4]),
"isEyeInWater" => Value::i(&[inputs.eye_in]),
"blindness" => Value::f(&[inputs.blindness]),
"nightVision" => Value::f(&[frame.night_vision]),
@@ -690,6 +712,7 @@ fn components(name: &str) -> usize {
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -700,6 +723,18 @@ pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
}
/// The position in [`NAMES`] of the value a program reads as `name`; `geometry`: the program
/// draws the game's geometry (gbuffers, shadow), which reads its own fog mode and shape and no
/// centre depth.
pub fn index_in(name: &str, geometry: bool) -> Option<usize> {
match name {
"fogMode" if geometry => index(GBUFFERS_FOG_MODE),
"fogShape" if geometry => index(GBUFFERS_FOG_SHAPE),
"centerDepthSmooth" if geometry => index(GBUFFERS_CENTER_DEPTH),
_ => index(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1178,6 +1213,64 @@ mod initial_history_tests {
}
}
fn int(u: &FrameUniforms, at: usize) -> i32 {
match u.values[at] {
Value::I(v) => v[0],
_ => panic!("integer uniform expected"),
}
}
/// Iris 1.11.7, read back exactly with a probe pack on a clear day (render distance 12,
/// cloud range 64): fogStart 0 and fogEnd 1024 (the environmental fog, not the render
/// distance's 172.8 to 192), fogDensity 0; composite and final programs read alphaTestRef
/// 0.1, blockEntityId -1, fogMode 0 and fogShape -1.
#[test]
fn fog_and_draw_uniforms_outside_a_draw_follow_iris() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
f.fog = Some(crate::fog::FogData {
color: [0.7, 0.8, 1.0, 1.0],
environmental_start: 0.0,
environmental_end: 1024.0,
render_distance_start: 172.8,
render_distance_end: 192.0,
sky_end: 192.0,
cloud_end: 1024.0,
});
let mut u = FrameUniforms::default();
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(float(&u, "fogStart")[0], 0.0);
assert_eq!(float(&u, "fogEnd")[0], 1024.0);
assert_eq!(float(&u, "fogDensity")[0], 0.0);
assert_eq!(float(&u, "alphaTestRef")[0], 0.1);
assert_eq!(int(&u, index("blockEntityId").unwrap()), -1);
assert_eq!(int(&u, index("entityId").unwrap()), 0);
assert_eq!(int(&u, index("fogMode").unwrap()), 0);
assert_eq!(int(&u, index("fogShape").unwrap()), -1);
}
/// Iris 1.11.7 (probe pack): gbuffers programs read fogMode GL_LINEAR (9729) and fogShape 1
/// (cylinder) where composite and final programs read 0 and -1.
#[test]
fn programs_that_draw_geometry_read_their_own_fog_and_centre_depth() {
let hud = HudBatch::new([800, 450]);
let mut u = FrameUniforms::default();
u.update(&frame(&hud), &PackInputs::default(), [800, 450]);
let at = |name, geometry| index_in(name, geometry).unwrap();
assert_eq!(int(&u, at("fogMode", true)), 9729);
assert_eq!(int(&u, at("fogShape", true)), 1);
assert_eq!(int(&u, at("fogMode", false)), 0);
assert_eq!(int(&u, at("fogShape", false)), -1);
assert_eq!(at("fogStart", true), at("fogStart", false));
// Iris 1.11.7 (probe pack): `centerDepthSmooth` reads 0 in gbuffers programs.
let depth = |geometry| match u.values[at("centerDepthSmooth", geometry)] {
Value::F(v) => v[0],
_ => panic!("float uniform expected"),
};
assert_eq!(depth(true), 0.0);
assert_eq!(depth(false), 1.0);
}
/// Iris 1.11.7, read frame by frame with a probe pack: eyeBrightnessSmooth falls by half
/// in 1 s and is rounded down; wetness takes 60 s to rise halfway and 20 s to fall; the
/// centre depth 0.1 s.
@@ -162,7 +162,7 @@ impl PackWeather {
/// A texture holding the atlas's texels under `rect` (normalised x, y, width, height), as
/// Horizon's weather shader reads them (`weather_texel`).
fn copy_sprite(
pub(super) fn copy_sprite(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
atlas: &wgpu::Texture,
+118 -16
View File
@@ -1,7 +1,8 @@
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
//! then drawn onto the swapchain image as a fullscreen triangle.
//! then drawn onto the swapchain image as a fullscreen triangle. A 16-bit frame
//! (`Renderer::frame_format`) with HDR output off goes the same way every frame.
/// The texture a frame to read back is drawn into, and the pass that puts it
/// on the swapchain afterwards.
@@ -15,18 +16,21 @@ pub struct Readback {
impl Readback {
/// The texture this frame draws into instead of the swapchain image
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
/// image cannot be copied from.
/// `surface`, in the frame's `format`: `None` unless that differs from the
/// swapchain's, or the frame is read back (`screenshot`) and the image
/// cannot be copied from.
pub fn target(
&mut self,
device: &wgpu::Device,
surface: &wgpu::Texture,
format: wgpu::TextureFormat,
screenshot: bool,
) -> Option<wgpu::Texture> {
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
if format == surface.format().remove_srgb_suffix()
&& (!screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC))
{
return None;
}
let format = surface.format().remove_srgb_suffix();
if self
.texture
.as_ref()
@@ -60,22 +64,57 @@ impl Readback {
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = &self.texture else {
let Some(texture) = self.texture.clone() else {
return;
};
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let target = surface.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
let source = texture.create_view(&Default::default());
self.blit(device, encoder, &texture, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
/// as an 8-bit frame holds it, for a screenshot to read back.
pub fn unorm_copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
format: wgpu::TextureFormat,
) -> wgpu::Texture {
let copy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot copy"),
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let target = copy.create_view(&Default::default());
self.blit(device, encoder, frame, &target, format);
copy
}
fn blit(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::Texture,
target: &wgpu::TextureView,
format: wgpu::TextureFormat,
) {
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let source = source.create_view(&Default::default());
if let Some((_, blitter)) = &self.blitter {
blitter.copy(device, encoder, &source, &target);
blitter.copy(device, encoder, &source, target);
}
}
}
@@ -84,6 +123,8 @@ impl Readback {
mod tests {
use super::*;
const F: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -101,7 +142,7 @@ mod tests {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
format: F,
usage,
view_formats: &[],
})
@@ -156,7 +197,7 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
let blitter = wgpu::util::TextureBlitter::new(&device, F);
let mut readback = Readback::default();
let colors = [
(1.0, [0, 0, 255, 255]),
@@ -173,7 +214,7 @@ mod tests {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, true)
.target(&device, &surface, F, true)
.unwrap_or_else(|| surface.clone());
clear(&mut encoder, &frame, color);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
@@ -205,9 +246,70 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
assert!(readback.target(&device, &readable, true).is_none());
assert!(readback.target(&device, &readable, F, true).is_none());
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
assert!(readback.target(&device, &surface, false).is_none());
assert!(readback.target(&device, &surface, F, false).is_none());
assert!(readback.texture.is_none() && !readback.drawn);
}
/// A 16-bit frame with HDR output off draws into a 16-bit texture of its own, and both
/// the swapchain and a screenshot's 8-bit copy get it clamped as an 8-bit frame holds it.
fn reaches_an_8_bit_swapchain(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
color: wgpu::Color,
expected: [u8; 4],
) {
let surface = texture(
device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut readback = Readback::default();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback.target(device, &surface, format, false).unwrap();
assert_eq!(frame.format(), format);
clear(&mut encoder, &frame, color);
let copy = readback.unorm_copy(device, &mut encoder, &frame, F);
let screenshot = crate::gfx::copy_frame(device, &mut encoder, &copy, [70, 3]);
readback.present(device, &mut encoder, &surface);
let presented = crate::gfx::copy_frame(device, &mut encoder, &surface, [70, 3]);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{error}");
}
assert_eq!(last_pixel(device, &screenshot), expected);
assert_eq!(last_pixel(device, &presented), expected);
}
/// `Rgba16Unorm`, the frame HDR output draws into where the device can
/// (`Renderer::frame_format`).
#[test]
fn a_16_bit_unorm_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let wide = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
if !adapter.features().contains(wide) {
return;
}
let descriptor = wgpu::DeviceDescriptor {
required_features: wide,
..Default::default()
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&descriptor)) else {
return;
};
let color = wgpu::Color {
r: 1.0,
g: 0.2,
b: 0.0,
a: 1.0,
};
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
}
+226 -32
View File
@@ -17,6 +17,9 @@ struct Column {
z: i32,
/// Section y (from the world's bottom) and slot, in no particular order.
sections: Vec<(i32, u32)>,
/// Those of `sections` that are marked (`Resident::insert_marked`), in no
/// particular order.
marked: Vec<(i32, u32)>,
/// The lowest and highest section y of `sections`.
low: i32,
high: i32,
@@ -36,6 +39,9 @@ pub struct Resident<T> {
min_y: i32,
items: Vec<(SectionKey, T)>,
bounds: Vec<Option<[Vec3; 2]>>,
/// Per slot, whether the section is marked: within the render distance,
/// `walk` lists only those when `marked_only_near`.
marked: Vec<bool>,
slots: FxHashMap<SectionKey, u32>,
columns: Vec<Column>,
column_index: FxHashMap<(i32, i32), u32>,
@@ -47,6 +53,7 @@ impl<T> Default for Resident<T> {
min_y: crate::chunk::MIN_Y,
items: Vec::new(),
bounds: Vec::new(),
marked: Vec::new(),
slots: FxHashMap::default(),
columns: Vec::new(),
column_index: FxHashMap::default(),
@@ -65,12 +72,13 @@ impl<T> Resident<T> {
use std::mem::size_of;
self.items.capacity() * size_of::<(SectionKey, T)>()
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
+ self.marked.capacity() * size_of::<bool>()
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
+ self.columns.capacity() * size_of::<Column>()
+ self
.columns
.iter()
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
.map(|c| (c.sections.capacity() + c.marked.capacity()) * size_of::<(i32, u32)>())
.sum::<usize>()
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
}
@@ -83,14 +91,23 @@ impl<T> Resident<T> {
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key and value.
/// Every section's key, value and bounds.
#[cfg(debug_assertions)]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T)> {
self.items.iter().map(|(key, value)| (*key, value))
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
}
/// Every section's key and value.
pub fn iter_mut(&mut self) -> impl Iterator<Item = (SectionKey, &mut T)> {
self.items.iter_mut().map(|(key, value)| (*key, value))
/// Every section's key, value and bounds.
pub fn iter_mut(&mut self) -> impl Iterator<Item = (SectionKey, &mut T, Option<[Vec3; 2]>)> {
let items = self.items.iter_mut().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
}
/// Whether the section at `key` is marked (`insert_marked`).
#[cfg(any(test, debug_assertions))]
pub fn is_marked(&self, key: SectionKey) -> bool {
self.slots
.get(&key)
.is_some_and(|&slot| self.marked[slot as usize])
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
@@ -118,6 +135,7 @@ impl<T> Resident<T> {
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
self.marked.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
@@ -127,16 +145,31 @@ impl<T> Resident<T> {
pub fn insert(&mut self, key: SectionKey, value: T) {
self.insert_with_bounds(key, value, None);
}
/// Adds or replaces `key`'s value with optional tight geometry bounds.
/// Adds or replaces `key`'s value with optional tight geometry bounds, unmarked.
#[cfg(test)]
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
self.insert_marked(key, value, bounds, false);
}
/// Adds or replaces `key`'s value with optional tight geometry bounds, and
/// whether it is marked: `walk` with `marked_only_near` lists the sections
/// within the render distance that are.
pub fn insert_marked(
&mut self,
key: SectionKey,
value: T,
bounds: Option<[Vec3; 2]>,
marked: bool,
) {
if let Some(&slot) = self.slots.get(&key) {
self.items[slot as usize].1 = value;
self.bounds[slot as usize] = bounds;
self.mark_slot(slot, marked);
return;
}
let slot = self.items.len() as u32;
self.items.push((key, value));
self.bounds.push(bounds);
self.marked.push(marked);
self.slots.insert(key, slot);
let column = match self.column_index.get(&(key.0, key.2)) {
Some(&c) => &mut self.columns[c as usize],
@@ -147,6 +180,7 @@ impl<T> Resident<T> {
x: key.0,
z: key.2,
sections: Vec::new(),
marked: Vec::new(),
low: key.1,
high: key.1,
});
@@ -154,15 +188,39 @@ impl<T> Resident<T> {
}
};
column.sections.push((key.1, slot));
if marked {
column.marked.push((key.1, slot));
}
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
/// Marks or unmarks the section in `slot`, in its column's list too.
fn mark_slot(&mut self, slot: u32, marked: bool) {
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
if was == marked {
return;
}
let (x, y, z) = self.items[slot as usize].0;
let column = &mut self.columns[self.column_index[&(x, z)] as usize];
if marked {
column.marked.push((y, slot));
} else {
let at = column
.marked
.iter()
.position(|s| s.1 == slot)
.expect("marked section in its column's list");
column.marked.swap_remove(at);
}
}
/// Removes every section of the `gone` columns, the others keeping their order and
/// marks: one pass over the store, where `remove` moves a slot and looks its column
/// up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
// Every column (a far teleport): emptied, without a lookup per section.
if self.columns.iter().all(|c| gone.contains(&(c.x, c.z))) {
self.bounds.clear();
self.marked.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
@@ -173,21 +231,23 @@ impl<T> Resident<T> {
}
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
let marked = std::mem::take(&mut self.marked);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
for (((key, value), bounds), marked) in items.into_iter().zip(bounds).zip(marked) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
self.insert_marked(key, value, bounds, marked);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
self.mark_slot(slot, false);
let c = self.column_index[&(key.0, key.2)] as usize;
let column = &mut self.columns[c];
let at = column
@@ -206,8 +266,10 @@ impl<T> Resident<T> {
column.bounds();
}
let _ = self.bounds.swap_remove(slot as usize);
let _ = self.marked.swap_remove(slot as usize);
let (_, value) = self.items.swap_remove(slot as usize);
// The last item now sits in `slot`: point its key and column at it.
// The last item now sits in `slot`: point its key and column, and its
// column's marked list if it is marked, at it.
if let Some(&(moved, _)) = self.items.get(slot as usize) {
let last = self.items.len() as u32;
self.slots.insert(moved, slot);
@@ -218,6 +280,14 @@ impl<T> Resident<T> {
.find(|s| s.1 == last)
.expect("moved section in its column");
entry.1 = slot;
if self.marked[slot as usize] {
let entry = column
.marked
.iter_mut()
.find(|s| s.1 == last)
.expect("moved marked section in its column's list");
entry.1 = slot;
}
}
Some(value)
}
@@ -328,6 +398,22 @@ impl<T> Resident<T> {
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec<Visible>,
) {
self.walk(camera, radius, frustum, reachable, beyond, false, out);
}
/// `visible`, less the sections within the render distance that are not
/// marked (`insert_marked`) when `marked_only_near`, in the same order: GPU
/// culling's frames, where the GPU draws those. A column's box spans all of
/// its sections either way, so both decide alike on those they share.
pub fn walk(
&self,
camera: DVec3,
radius: i32,
frustum: &impl Cull,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
marked_only_near: bool,
out: &mut Vec<Visible>,
) {
let from = camera_column(camera);
for column in &self.columns {
@@ -335,6 +421,14 @@ impl<T> Resident<T> {
if !near && !beyond(column.x, column.z) {
continue;
}
let list = if marked_only_near && near {
&column.marked
} else {
&column.sections
};
if list.is_empty() {
continue;
}
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
@@ -351,7 +445,7 @@ impl<T> Resident<T> {
let xz_sq = dx8 * dx8 + dz8 * dz8;
if inside {
for &(y, slot) in &column.sections {
for &(y, slot) in list {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
@@ -368,7 +462,7 @@ impl<T> Resident<T> {
});
}
} else {
for &(y, slot) in &column.sections {
for &(y, slot) in list {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
@@ -519,9 +613,10 @@ mod tests {
assert_eq!(fast, slow);
}
/// Every key reaches its value through its slot and column, and every
/// column's bounds and sections match the keys.
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, u32>) {
/// Every key reaches its value and mark through its slot and column, every
/// column's bounds and sections match the keys, and its marked list holds
/// its marked sections.
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, (u32, bool)>) {
assert_eq!(r.len(), model.len());
let mut seen = 0;
for column in &r.columns {
@@ -532,13 +627,24 @@ mod tests {
for &(y, slot) in &column.sections {
let (key, value) = &r.items[slot as usize];
assert_eq!(*key, (column.x, y, column.z));
assert_eq!(model[key], *value);
assert_eq!(model[key], (*value, r.marked[slot as usize]));
seen += 1;
}
let mut marked: Vec<(i32, u32)> = column
.sections
.iter()
.copied()
.filter(|&(_, slot)| r.marked[slot as usize])
.collect();
let mut listed = column.marked.clone();
marked.sort_unstable();
listed.sort_unstable();
assert_eq!(listed, marked, "column ({}, {})", column.x, column.z);
}
assert_eq!(seen, model.len());
for key in model.keys() {
for (key, &(_, marked)) in model {
assert!(r.contains(*key));
assert_eq!(r.is_marked(*key), marked, "{key:?}");
}
}
@@ -546,7 +652,12 @@ mod tests {
fn slots_and_columns_follow_inserts_and_removals() {
let mut r = Resident::default();
let mut model = HashMap::new();
// A deterministic mix of inserts, replacements and removals.
// Bounds that tell values apart.
let bounds = |value: u32| {
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
};
// A deterministic mix of inserts, replacements and removals, marked at
// random: a replacement can mark or unmark its section.
let mut seed = 0x2545_f491_u32;
for step in 0..4000u32 {
seed ^= seed << 13;
@@ -563,47 +674,130 @@ mod tests {
.flat_map(|x| (-2..=2).map(move |z| (x, z)))
.collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model.drain().map(|(_, v)| v).collect();
let mut expected: Vec<u32> = model.drain().map(|(_, (value, _))| value).collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
assert!(r.columns.is_empty() && r.column_index.is_empty());
} else if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
// The others keep their values, bounds and marks (`check`).
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.map(|(_, (value, _))| value)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
} else {
r.insert(key, step);
model.insert(key, step);
let marked = (seed >> 20).is_multiple_of(2);
r.insert_marked(key, step, bounds(step), marked);
model.insert(key, (step, marked));
}
check(&r, &model);
// `iter_mut` yields every key once, with its value.
// `iter_mut` yields every key once, with its value and bounds.
let mut seen = HashSet::new();
for (key, value) in r.iter_mut() {
assert_eq!(*value, model[&key], "{key:?}");
for (key, value, b) in r.iter_mut() {
assert_eq!(*value, model[&key].0, "{key:?}");
assert_eq!(b, bounds(*value), "{key:?}");
assert!(seen.insert(key), "{key:?} twice");
}
assert_eq!(seen.len(), model.len());
}
for (_, value) in r.iter_mut() {
for (_, value, _) in r.iter_mut() {
*value += 1;
}
for (key, value) in &model {
for (key, (value, _)) in &model {
assert_eq!(r.find(*key), Some(&(value + 1)));
}
r.clear();
assert_eq!(r.len(), 0);
assert!(r.columns.is_empty());
assert!(r.columns.is_empty() && r.marked.is_empty());
}
/// GPU frames' walk (`walk` with `marked_only_near`) is `visible` less the
/// sections within the render distance that are not marked, in the same
/// order: with marks at random, bounds reaching out of their cells, short
/// columns entirely inside the frustum, cave reach, and columns past the
/// distance kept for far terrain (GPU frames have no water mirror,
/// `gpu_frame`).
#[test]
fn the_marked_walk_is_the_walk_less_unmarked_sections_within_the_distance() {
let mut r = Resident::default();
let mut seed = 0x9e37_79b9_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for x in -14i32..=14 {
for z in -14i32..=14 {
// Short columns ahead of the first camera: most lie entirely inside.
let rows = if z > 0 && x.abs() <= 12 { 3..6 } else { 0..24 };
for y in rows {
let bits = next();
if bits.is_multiple_of(5) {
continue;
}
let bounds = match (bits >> 4) % 4 {
0 => None,
1 => Some([Vec3::splat(-1.5), Vec3::splat(17.5)]),
2 => Some([Vec3::new(0.0, 18.0, 0.0), Vec3::new(16.0, 20.0, 16.0)]),
_ => Some([Vec3::new(2.0, 0.0, 3.0), Vec3::new(14.0, 5.5, 16.0)]),
};
r.insert_marked((x, y, z), (), bounds, (bits >> 8).is_multiple_of(3));
}
}
}
let reachable = |k: SectionKey| (3 * k.0 + k.1 + 5 * k.2).rem_euclid(7) != 0;
let beyond = |x: i32, z: i32| (x - z).rem_euclid(3) == 0;
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let (mut kept, mut dropped, mut inside) = (0, 0, 0);
for (camera, yaw, pitch) in [
(DVec3::new(0.5, -8.0, 0.5), 0.0, 5.0),
(DVec3::new(-37.25, 130.5, 21.75), 135.0, 35.0),
(DVec3::new(100.0, -60.0, -3.0), 270.0, -80.0),
(DVec3::new(8.0, 64.0, 8.0), 45.0, 89.0),
] {
let view = Frustum {
planes: super::super::frustum(projection * crate::camera::view_matrix(yaw, pitch)),
};
inside += r
.columns
.iter()
.filter(|c| {
let min = origin((c.x, c.low, c.z), r.min_y, camera).as_vec3();
let top = origin((c.x, c.high, c.z), r.min_y, camera).as_vec3();
view.contains(min, top + Vec3::splat(16.0))
})
.count();
for radius in [3, 7, 12] {
let (mut all, mut marked) = (Vec::new(), Vec::new());
r.visible(camera, radius, &view, reachable, beyond, &mut all);
r.walk(camera, radius, &view, reachable, beyond, true, &mut marked);
let from = camera_column(camera);
let near = |v: &Visible| in_view_distance(from, radius, v.key.0, v.key.2);
let expected: Vec<Visible> = all
.iter()
.copied()
.filter(|v| r.is_marked(v.key) || !near(v))
.collect();
assert_eq!(marked, expected, "{camera} {yaw} {pitch} {radius}");
kept += marked.len();
dropped += all.len() - marked.len();
}
}
assert!(
kept > 100 && dropped > 100,
"{kept} kept, {dropped} dropped"
);
assert!(inside > 50, "{inside} columns entirely inside");
}
/// The old walk (every key of a hash map, sorted by distance, then two
@@ -0,0 +1,417 @@
//! A visibility buffer for solid terrain, opt-in with `HORIZON_VISBUFFER=1` (read once, at
//! startup): pass 1 rasterises the solid flat draws (near sections and far tiles, phase 1 and,
//! with occlusion culling, phase 2) writing only depth and the ID of the quad each pixel shows
//! (`visbuffer.wgsl`'s `vis_id`: arena page and quad); cutout and fading
//! quads keep their alpha test and dither there, the rest never discard. Pass 2 is one
//! full-screen draw with up to `PAGE_SLOTS` arena pages bound at once (more pages, more draws)
//! that reads the quad back from its page and its section's origin (through a copy of every
//! drawn page's block table in one buffer), and runs terrain.wgsl's
//! vertex work and solid shading once per pixel with analytic screen gradients. Every solid
//! pixel is shaded once, however many quads were drawn over it and however small they are.
//! A draw per page instead made each GPU thread group run the shading once for every page
//! among its pixels, with most lanes idle: the pages' sections lie all over the screen.
//!
//! It runs only in frames drawn flat (`flat`: Metal past `flat::MIN_SECTIONS`, or
//! `HORIZON_FLAT_DRAWS=1`) and falls back to the normal solid passes in frames with a shader
//! pack drawing terrain, one of Horizon's own packs with a material stage or a shadow pass, a
//! pack's surface target, and on the title screen. The water mirror's own pass stays as it is.
use super::arena::Arena;
use super::flat::Flat;
use super::pack_timing::PassTiming;
/// Arena pages one resolve draw reads (`visbuffer.wgsl`'s `vis_page_0`...).
pub const PAGE_SLOTS: u32 = 16;
/// Arena pages an ID names (`visbuffer.wgsl`'s `vis_id`): frames with more draw without it.
pub const MAX_PAGES: u32 = 64;
/// The resolve binds that many pages, the block tables and the origins by section id.
const STORAGE_BUFFERS: u32 = PAGE_SLOTS + 2;
const IDS: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
/// Whether the visibility buffer is asked for, and the device binds storage buffers enough.
pub fn wanted(device: &wgpu::Device) -> bool {
std::env::var("HORIZON_VISBUFFER").as_deref() == Ok("1")
&& device.limits().max_storage_buffers_per_shader_stage >= STORAGE_BUFFERS
}
pub struct Visbuffer {
/// Pass 1 over the quads `fs_opaque` draws (no discard) and the rest.
opaque: wgpu::RenderPipeline,
cutout: wgpu::RenderPipeline,
resolve: wgpu::RenderPipeline,
/// Pass 2's group 1: the block tables (binding 0), the origins by section id (1),
/// `PAGE_SLOTS` pages' vertices (from 2), the IDs (after them).
page_layout: wgpu::BindGroupLayout,
/// Every page's block table, page after page (`Flat::blocks_per_page` entries each),
/// copied each frame from the drawn pages' own, and its size in pages.
blocks: Option<(wgpu::Buffer, u32)>,
blocks_per_page: u64,
/// The IDs, the size of the world's depth.
ids: Option<(wgpu::TextureView, [u32; 2])>,
}
impl Visbuffer {
/// `terrain_layout`: terrain.wgsl's group 0; `flat_layout`: the flat draws' group 1;
/// `color`: the world pass's colour format; `constants`: the sprite arrays' pipeline
/// constants.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
flat: &Flat,
color: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> Self {
let flat_layout = &flat.draw_layout;
let blocks_per_page = flat.blocks_per_page();
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"))
.into(),
),
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries: Vec<_> = (0..2 + PAGE_SLOTS)
.map(storage)
.chain([wgpu::BindGroupLayoutEntry {
binding: 2 + PAGE_SLOTS,
visibility: fragment,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Uint,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}])
.collect();
let page_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Visibility buffer pages"),
entries: &entries,
});
let id_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Visibility buffer IDs"),
bind_group_layouts: &[Some(terrain_layout), Some(flat_layout)],
..Default::default()
});
let resolve_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Visibility buffer resolve"),
bind_group_layouts: &[Some(terrain_layout), Some(&page_layout)],
..Default::default()
});
let ids = |label: &'static str, vertex: &'static str, fragment: &'static str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&id_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: Default::default(),
// `section_corner` reads the arena page (`flat::ARENA_BINDING`).
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: IDS,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let opaque = ids("Visibility buffer opaque", "vs_vis_opaque", "fs_vis_id");
let cutout = ids("Visibility buffer cutout", "vs_vis_cutout", "fs_vis_cutout");
let resolve = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Visibility buffer resolve"),
layout: Some(&resolve_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_vis_resolve"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_vis_resolve"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &resolve_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Self {
opaque,
cutout,
resolve,
page_layout,
blocks: None,
blocks_per_page,
ids: None,
}
}
/// Makes the IDs the world depth's `size`, and the block tables hold the pages `flat`
/// draws this frame.
pub fn fit(&mut self, device: &wgpu::Device, size: [u32; 2], flat: &Flat) {
let pages = flat.calls[..flat.solid_calls]
.iter()
.map(|c| c.page + 1)
.max()
.unwrap_or(1);
if self.blocks.as_ref().is_none_or(|(_, n)| *n < pages) {
let pages = pages.next_power_of_two();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Visibility buffer block tables"),
size: u64::from(pages) * self.blocks_per_page * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.blocks = Some((buffer, pages));
}
if self.ids.as_ref().is_some_and(|(_, s)| *s == size) {
return;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Visibility buffer IDs"),
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: IDS,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.ids = Some((texture.create_view(&Default::default()), size));
}
/// Pass 1's render pass over the world's `depth`: the IDs cleared (`clear`, phase 1) or
/// kept (phase 2 of occlusion culling).
pub fn id_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
clear: bool,
timing: Option<&mut PassTiming>,
) -> wgpu::RenderPass<'e> {
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
let name = if clear {
"Visibility IDs"
} else {
"Visibility IDs after culling"
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ids,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: if clear {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
wgpu::LoadOp::Load
},
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
/// pipeline over `calls[..pass_calls[0]]`, the cutout one over
/// `calls[pass_calls[1]..phase1_calls]` (each leaves out the other's quads).
pub fn draw_ids<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: &Flat,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy,
) {
let [opaque, cutout, _] = flat.pass_calls;
pass.set_pipeline(&self.opaque);
flat.draw_paged(pass, &flat.calls[..opaque], vertices);
pass.set_pipeline(&self.cutout);
flat.draw_paged(pass, &flat.calls[cutout..flat.phase1_calls], vertices);
}
/// Phase 2's calls as `Flat::flatten_phase2` left them (their first instance is their
/// page: `Flat::page_instances`), through both pipelines.
pub fn draw_ids_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: &Flat,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy,
) {
// Phase 2 lists each section's opaque runs first, its cutout runs after.
let split = flat.pass_calls[2] - flat.phase1_calls;
let total = flat.solid_calls - flat.phase1_calls;
pass.set_pipeline(&self.opaque);
flat.draw_phase2(pass, 0..split, vertices);
pass.set_pipeline(&self.cutout);
flat.draw_phase2(pass, split..total, vertices);
}
/// Bytes of its block tables and IDs.
pub fn bytes(&self) -> u64 {
let blocks = self.blocks.as_ref().map_or(0, |(b, _)| b.size());
let ids = self
.ids
.as_ref()
.map_or(0, |(_, [w, h])| u64::from(*w) * u64::from(*h) * 4);
blocks + ids
}
/// Pass 2: shades the solid terrain the IDs show into `target`, the world's colour,
/// over the sky drawn before. `terrain`: terrain.wgsl's group 0.
#[allow(clippy::too_many_arguments)]
pub fn resolve(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
terrain: &wgpu::BindGroup,
flat: &Flat,
arena: &Arena,
timing: Option<&mut PassTiming>,
) {
let (Some((ids, _)), Some((blocks, _))) = (&self.ids, &self.blocks) else {
return;
};
let mut pages: Vec<u32> = flat.calls[..flat.solid_calls]
.iter()
.map(|c| c.page)
.collect();
pages.sort_unstable();
pages.dedup();
let Some(&any) = pages.first() else {
return;
};
let table = self.blocks_per_page * 4;
for &page in &pages {
if let Some(from) = flat.block_table(page) {
encoder.copy_buffer_to_buffer(from, 0, blocks, u64::from(page) * table, table);
}
}
// Pages in groups of `PAGE_SLOTS` by number: a page's slot is its number's remainder.
// Slots no drawn page fills take one that is, which no pixel's ID names.
let mut chunks: Vec<u32> = pages.iter().map(|p| p / PAGE_SLOTS).collect();
chunks.dedup();
let groups: Vec<(u32, wgpu::BindGroup)> = chunks
.into_iter()
.map(|chunk| {
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: flat.origins().as_entire_binding(),
},
];
entries.extend((0..PAGE_SLOTS).map(|slot| {
let page = chunk * PAGE_SLOTS + slot;
let page = if pages.binary_search(&page).is_ok() {
page
} else {
any
};
wgpu::BindGroupEntry {
binding: 2 + slot,
resource: arena.buffer(page).as_entire_binding(),
}
}));
entries.push(wgpu::BindGroupEntry {
binding: 2 + PAGE_SLOTS,
resource: wgpu::BindingResource::TextureView(ids),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Visibility buffer pages"),
layout: &self.page_layout,
entries: &entries,
});
(chunk, group)
})
.collect();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer resolve"),
timestamp_writes: timing.and_then(|t| t.writes("Visibility resolve")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(&self.resolve);
pass.set_bind_group(0, terrain, &[]);
// The group of pages goes as the instance: each draw shades the pixels whose quad is
// in one of its pages (one draw, up to `PAGE_SLOTS` pages).
for (chunk, group) in &groups {
pass.set_bind_group(1, group, &[]);
pass.draw(0..3, *chunk..*chunk + 1);
}
}
}
+95 -2
View File
@@ -128,6 +128,44 @@ impl EntityCullingMode {
}
}
/// Particle visibility strategy, independent of Entity Culling.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ParticleCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl ParticleCullingMode {
pub const ALL: [ParticleCullingMode; 3] = [
ParticleCullingMode::Off,
ParticleCullingMode::Synchronous,
ParticleCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
"synchronous" | "sync" => Self::Synchronous,
"asynchronous" | "async" => Self::Asynchronous,
"off" => Self::Off,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -333,6 +371,13 @@ pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Drops a face between two leaves when the leaf it faces has a leaf or
/// a full opaque cube behind it too: that face only shows through two
/// cutout layers. The faces behind the outer layer stay, so a crown
/// looks the same from outside. The faced leaf must be in the section
/// (as `Hollow`), so the block behind it is at most a border cell of the
/// next section, whose edits remesh this one.
Hidden,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
@@ -344,8 +389,9 @@ pub enum LeafCullingMode {
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
pub const ALL: [LeafCullingMode; 5] = [
LeafCullingMode::Off,
LeafCullingMode::Hidden,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
@@ -353,6 +399,7 @@ impl LeafCullingMode {
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Hidden => "hidden",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
@@ -361,6 +408,7 @@ impl LeafCullingMode {
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Hidden => "Hidden Faces",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
@@ -473,6 +521,17 @@ pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static SMOOTH_GRADIENTS_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can draw, blend and filter the 16-bit frame (`Graphics::hdr_smooth_gradients`):
/// set when the renderer starts. Where it can't, the settings grey its switch out.
pub fn offer_smooth_gradients(on: bool) {
SMOOTH_GRADIENTS_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -551,6 +610,8 @@ pub struct Graphics {
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Skip particles outside the view or hidden behind opaque terrain.
pub particle_culling: ParticleCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
@@ -634,6 +695,11 @@ pub struct Graphics {
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// Smooth Gradients (HDR page, on by default): with HDR output on, the frame is drawn in
/// 16 bits, so dark gradients don't band (`Renderer::frame_format`). Read when a renderer
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -648,6 +714,7 @@ impl Default for Graphics {
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
entity_culling: EntityCullingMode::Off,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
@@ -685,6 +752,7 @@ impl Default for Graphics {
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: true,
hdr_smooth_gradients: true,
shader_pack: String::new(),
}
}
@@ -741,6 +809,7 @@ impl Graphics {
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
@@ -815,6 +884,7 @@ impl Graphics {
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"hdrSmoothGradients" => g.hdr_smooth_gradients = flag(g.hdr_smooth_gradients),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -831,12 +901,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.particle_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
@@ -881,6 +952,7 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
);
text += &self.blue_map.serialize();
text
@@ -896,6 +968,11 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
/// HDR on, with Smooth Gradients.
pub fn wide_frame(&self) -> bool {
self.hdr && self.hdr_smooth_gradients
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
@@ -1015,6 +1092,7 @@ mod tests {
},
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
cache_signs: true,
@@ -1051,6 +1129,7 @@ mod tests {
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: false,
hdr_smooth_gradients: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -1061,6 +1140,16 @@ mod tests {
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
for mode in ParticleCullingMode::ALL {
let graphics = Graphics {
particle_culling: mode,
..Graphics::default()
};
assert_eq!(
Graphics::parse(&graphics.serialize()).particle_culling,
mode
);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
@@ -1082,6 +1171,10 @@ mod tests {
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
// Smooth Gradients is on, and draws a 16-bit frame only with HDR on.
assert!(Graphics::default().hdr_smooth_gradients && !Graphics::default().wide_frame());
assert!(Graphics::parse("hdr:true\n").wide_frame());
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
@@ -209,7 +209,29 @@ pub fn button(
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(batch, atlas, label, x + 2.0, ty, w - 4.0, color);
scrolling_label(batch, atlas, label, [x + 2.0, ty, w - 4.0], (y, h), color);
}
/// A widget label of `[left, y, width]` inside the widget's `(top, height)`: centred when it fits,
/// else slid (vanilla `AbstractWidget.extractScrollingStringOverContents`) and clipped to the box.
fn scrolling_label(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
[left, y, width]: [f32; 3],
(top, height): (f32, f32),
color: u32,
) {
ui::scrolling_text(
batch,
atlas,
text,
left,
left + width,
top,
top + height,
y,
color,
);
}
/// A slider: its value over a thin track filled in the accent up to the handle at `position`.
pub fn slider(
@@ -222,7 +244,14 @@ pub fn slider(
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
ui::centered_text(batch, atlas, value, x + 2.0, y + 1.0, w - 4.0, color);
scrolling_label(
batch,
atlas,
value,
[x + 2.0, y + 1.0, w - 4.0],
(y, h),
color,
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
@@ -260,7 +289,7 @@ pub fn cycler(
(true, true) => WHITE,
(true, false) => SOFT,
};
batch.text_styled(atlas, value, x + 9.0, ty, color, true);
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
/// A screen's title, centred at the top (split-screen profiles).
pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
@@ -271,3 +300,76 @@ pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
pub fn cost_color(cost: Cost) -> u32 {
hex(cost.rgb(), 255)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn x_span(batch: &HudBatch, plain: &HudBatch) -> (f32, f32) {
let label = |v: &&crate::hud::HudVertex| !plain.vertices.iter().any(|p| p.pos == v.pos);
batch
.vertices
.iter()
.filter(label)
.map(|v| v.pos[0])
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), x| {
(lo.min(x), hi.max(x))
})
}
/// A label wider than its widget slides inside the widget's box instead of being cut by
/// characters; a short one stays centred.
#[test]
fn long_labels_stay_in_the_widget_and_short_ones_centred() {
let atlas = atlas();
let rect = [10.0, 20.0, 100.0, 20.0];
let long = Text::literal("Graphics API: Prefer Vulkan (Experimental)");
assert!(atlas.font.width_text(&long.resolved()) > rect[2]);
let short = Text::literal("Done");
let short_w = atlas.font.width_text(&short.resolved());
let draws: [(&str, fn(&mut HudBatch, &HudAtlas, [f32; 4], &Text)); 3] = [
("button", |b, a, r, t| {
button(b, a, r, t, (false, true, false))
}),
("slider", |b, a, r, t| {
slider(b, a, r, t, 0.5, (false, true))
}),
("cycler", |b, a, r, t| cycler(b, a, r, t, (false, true))),
];
for (name, draw) in draws {
let mut plain = HudBatch::new([640, 480]);
draw(&mut plain, &atlas, rect, &Text::empty());
let base = plain.vertices.len();
let mut batch = HudBatch::new([640, 480]);
draw(&mut batch, &atlas, rect, &long);
assert!(batch.vertices.len() > base, "{name}: long label drawn");
// The widget's own fill, track and handle also draw: only the label's vertices count.
let label = batch
.vertices
.iter()
.filter(|v| !plain.vertices.iter().any(|p| p.pos == v.pos));
for v in label {
assert!(
(rect[0]..=rect[0] + rect[2]).contains(&v.pos[0])
&& (rect[1]..=rect[1] + rect[3]).contains(&v.pos[1]),
"{name}: {:?} outside the widget",
v.pos
);
}
// The label's own glyphs (past the widget's fill) start centred.
let mut batch = HudBatch::new([640, 480]);
draw(&mut batch, &atlas, rect, &short);
let (lo, hi) = x_span(&batch, &plain);
let middle = (lo + hi) / 2.0;
let want = rect[0] + rect[2] / 2.0;
assert!(
(middle - want).abs() <= short_w / 2.0 + 2.0 && (middle - want).abs() < 3.0,
"{name}: centre {middle}, want {want}"
);
}
}
}
+215 -18
View File
@@ -7,7 +7,7 @@
use crate::{
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::{self as style, Cost},
hud::{HudAtlas, HudBatch},
@@ -66,6 +66,7 @@ pub enum H {
BlueMapRadius,
CaveCulling,
EntityCulling,
ParticleCulling,
LeafCulling,
CacheShulkerBoxes,
CacheSigns,
@@ -103,6 +104,7 @@ pub enum H {
ShadowCache,
GraphicsApi,
TranslucentBatching,
HdrSmoothGradients,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -379,6 +381,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Hides entities behind opaque blocks.",
),
h(
H::ParticleCulling,
None,
"Skips offscreen, subpixel and terrain-hidden particles.",
),
h(
H::LeafCulling,
Saves,
@@ -490,6 +497,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrSmoothGradients,
Low,
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
),
h(
H::HdrCalibrate,
None,
@@ -620,6 +632,9 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
},
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Hidden => {
"Drops leaf faces behind two leaf layers: faster, same crowns."
}
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
@@ -628,6 +643,15 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
(Item::Horizon(H::ParticleCulling), _) => match s.horizon.particle_culling {
ParticleCullingMode::Off => "Draws every particle.",
ParticleCullingMode::Synchronous => {
"Skips offscreen, subpixel and terrain-hidden particles."
}
ParticleCullingMode::Asynchronous => {
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work."
}
},
_ => text,
}
}
@@ -719,6 +743,7 @@ impl H {
),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
@@ -805,12 +830,16 @@ impl H {
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
// These two are greyed out where the device can't (`Item::blocked`): Off, as
// they are there, while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::HdrSmoothGradients => labelled(
"Smooth Gradients",
on_off(g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -871,6 +900,9 @@ impl H {
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::ParticleCulling => {
g.particle_culling = step(&ParticleCullingMode::ALL, g.particle_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
@@ -920,6 +952,7 @@ impl H {
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::HdrSmoothGradients => g.hdr_smooth_gradients = !g.hdr_smooth_gradients,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -951,7 +984,8 @@ impl Item {
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -963,16 +997,23 @@ impl Item {
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(H::HdrSmoothGradients)
if !crate::graphics::smooth_gradients_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
Item::Horizon(
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
@@ -1010,6 +1051,9 @@ impl Item {
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -1076,6 +1120,7 @@ impl Item {
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
| H::HdrSmoothGradients
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1902,6 +1947,7 @@ impl GraphicsUi {
framebuffer: [i32; 2],
drag: bool,
right: bool,
shift: bool,
) -> Vec<Action> {
self.keep(s);
let (cols, lines, content) = self.layout(s, w, h);
@@ -1979,7 +2025,7 @@ impl GraphicsUi {
return Vec::new();
}
self.focus = Some(Target::Item(item));
let actions = self.activate(s, item, false, framebuffer, Some((x, &cols)));
let actions = self.activate(s, item, shift, framebuffer, Some((x, &cols)));
self.mark(&actions);
actions
}
@@ -2358,7 +2404,15 @@ mod tests {
[cols.list_x, ui.row_y(&cols, &line), cols.list_w, style::ROW]
}
fn press(ui: &mut GraphicsUi, s: &mut Settings, (x, y): (f32, f32)) -> Vec<Action> {
ui.click(s, (x, y), SIZE, FB, false, false)
ui.click(s, (x, y), SIZE, FB, false, false, false)
}
fn press_shift(
ui: &mut GraphicsUi,
s: &mut Settings,
(x, y): (f32, f32),
shift: bool,
) -> Vec<Action> {
ui.click(s, (x, y), SIZE, FB, false, false, shift)
}
#[test]
@@ -2449,7 +2503,15 @@ mod tests {
assert!(y >= 0.0 && x + w <= size.0);
// Undo, over Done, stays clear of the page list.
assert!(cols.button(1)[1] >= y + h);
ui.click(&mut s, (x + w / 2.0, y + h / 2.0), size, FB, false, false);
ui.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
size,
FB,
false,
false,
false,
);
let (_, lines, _) = ui.layout(&s, size.0, size.1);
assert_eq!(ui.current(&lines), Some(Page::Hdr));
}
@@ -2566,7 +2628,7 @@ mod tests {
);
assert_eq!(H::ChunkUpdates.slider(&s), Some(1.0));
assert_eq!(
ui.click(&mut s, (-100.0, -100.0), SIZE, FB, true, false),
ui.click(&mut s, (-100.0, -100.0), SIZE, FB, true, false, false),
[Action::SliderDrag]
);
assert_eq!(H::ChunkUpdates.slider(&s), Some(0.0));
@@ -2574,6 +2636,31 @@ mod tests {
assert!(!ui.release());
}
#[test]
fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let mut s = Settings::default();
s.horizon.hdr = true;
let item = Item::Horizon(H::HdrSmoothGradients);
// Where the device can't, the saved On reads Off.
crate::graphics::offer_smooth_gradients(false);
assert_eq!(
item.blocked(&s),
Some("Your GPU or graphics API doesn't support it.")
);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: Off");
assert_eq!(item.switch(&s), Some(false));
assert!(s.horizon.hdr_smooth_gradients);
// Where it can, it waits only for HDR, like the rest of the page.
crate::graphics::offer_smooth_gradients(true);
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
assert_eq!(item.switch(&s), Some(true));
s.horizon.hdr = false;
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
@@ -2638,7 +2725,7 @@ mod tests {
let (cols, _, content) = ui.layout(&s, SIZE.0, SIZE.1);
let x = cols.bar_x() + 2.0;
let drag = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
ui.click(s, (x, y), SIZE, FB, drag, false)
ui.click(s, (x, y), SIZE, FB, drag, false, false)
};
assert!(drag(&mut ui, &mut s, cols.top + 5.0, false).is_empty());
drag(&mut ui, &mut s, cols.top + 15.0, true);
@@ -2658,13 +2745,13 @@ mod tests {
let mut ui = GraphicsUi::default();
let (cols, ..) = ui.layout(&s, SIZE.0, SIZE.1);
let x = cols.bar_x() + 2.0;
ui.click(&mut s, (x, cols.top + 5.0), SIZE, FB, false, false);
ui.click(&mut s, (x, cols.top + 5.0), SIZE, FB, false, false, false);
assert!(ui.scroll.held());
// A 130-high GUI leaves the list under the 40 px the scroller's thumb needs.
let short = (SIZE.0, 130.0);
assert!(!Columns::of(short.0, short.1).scrolls());
assert!(
ui.click(&mut s, (x, 80.0), short, FB, true, false)
ui.click(&mut s, (x, 80.0), short, FB, true, false, false)
.is_empty()
);
assert!(!ui.scroll.held());
@@ -2974,6 +3061,92 @@ mod tests {
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
#[test]
fn particle_culling_modes_are_opt_in_and_cycle_from_its_row() {
let mut s = Settings::default();
let item = Item::Horizon(H::ParticleCulling);
assert_eq!(s.horizon.particle_culling, ParticleCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Particle Culling: Off");
for mode in ParticleCullingMode::ALL {
s.horizon.particle_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.particle_culling, mode);
}
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).particle_culling,
mode
);
}
s.horizon.particle_culling = ParticleCullingMode::Off;
let mut ui = GraphicsUi::default();
let r = row(&mut ui, &s, item);
for (mode, label) in [
(ParticleCullingMode::Synchronous, "Synchronous"),
(ParticleCullingMode::Asynchronous, "Asynchronous"),
(ParticleCullingMode::Off, "Off"),
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.particle_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
assert_eq!(item.label(&s).plain(), format!("Particle Culling: {label}"));
}
}
#[test]
fn graphics_cycle_button_cycles_forward_and_shift_click_cycles_backward_with_wrapping() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
let mut ui = GraphicsUi::default();
let r = row(&mut ui, &s, item);
let at = (r[0] + 4.0, r[1] + 4.0);
// 1. Feature without shift (normal click): cycles forward (+1)
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(
press_shift(&mut ui, &mut s, at, false),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Synchronous);
assert_eq!(
press_shift(&mut ui, &mut s, at, false),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
// 3. Wrap-around forward (at max value, normal click wraps to min / 0)
assert_eq!(
press_shift(&mut ui, &mut s, at, false),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
// 4. Wrap-around backward (at min / 0, shift-click wraps to max)
assert_eq!(
press_shift(&mut ui, &mut s, at, true),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
// 2. Feature with shift: cycles backward (-1)
assert_eq!(
press_shift(&mut ui, &mut s, at, true),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Synchronous);
assert_eq!(
press_shift(&mut ui, &mut s, at, true),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
}
/// Translucent Batching: a switch on the Advanced page, on by default, no part of a
/// preset, and greyed out, reading Off, where the device can't batch.
#[test]
@@ -3031,7 +3204,7 @@ mod tests {
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
for label in ["Hidden Faces", "Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, FB);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
@@ -3050,7 +3223,12 @@ mod tests {
.unwrap();
assert_eq!(culling.0, Page::Performance);
let block: Vec<Item> = culling.1.iter().map(|r| r.item).collect();
for h in [H::CaveCulling, H::EntityCulling, H::LeafCulling] {
for h in [
H::CaveCulling,
H::EntityCulling,
H::ParticleCulling,
H::LeafCulling,
] {
assert!(block.contains(&Item::Horizon(h)), "{h:?}");
}
// The block-entity caches are the group after it, in this order.
@@ -3101,7 +3279,7 @@ mod tests {
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hidden);
assert_eq!(s.horizon.preset, Preset::Custom);
}
@@ -3266,36 +3444,47 @@ mod tests {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
s.horizon.particle_culling = ParticleCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text, particle, particle_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
"Partikel-Culling: Asynchron",
"Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
"Pomijanie cząsteczek: Asynchroniczne",
"Pomija cząsteczki poza ekranem, mniejsze niż piksel i zasłonięte przez teren, odciążając wątek renderowania.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
"Відсікання частинок: Асинхронно",
"Пропускає частинки поза екраном, менші за піксель і закриті місцевістю, зменшуючи роботу потоку рендерингу.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
"粒子剔除: 异步",
"跳过屏幕外、小于一个像素和被地形遮挡的粒子,减少渲染线程的工作量。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
"Occlusion des particules: Asynchrone",
"Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
@@ -3304,11 +3493,19 @@ mod tests {
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
item(H::ParticleCulling).label(&s).plain(),
lang::get(description(item(H::ParticleCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
(
leaf.into(),
entity.into(),
leaf_text.into(),
particle.into(),
particle_text.into(),
),
"{code}"
);
}
+10 -2
View File
@@ -58,6 +58,14 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
const BT709_TO_BT2020 = mat3x3<f32>(
vec3(0.6274, 0.0691, 0.0164),
@@ -152,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
return vec4(nits / 80.0, 1.0);
}
let f = textureLoad(frame, at, 0);
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
@@ -162,7 +170,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
+19 -1
View File
@@ -96,6 +96,9 @@ pub struct HudAtlas {
/// 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>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
/// Their emissive overlays (`_e`), packed here too.
@@ -813,6 +816,13 @@ impl HudAtlas {
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
atlas.text_shader = pack_text::load(root, defaults);
atlas.lines_shader = pack_text::load_core(
root,
defaults,
"rendertype_lines",
&[],
&crate::gfx::core_lines::GLUE,
);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
@@ -941,6 +951,7 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
lines_shader: None,
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
@@ -1384,6 +1395,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
lines_shader: None,
random_textures,
emissive,
blinks,
@@ -2996,8 +3008,14 @@ mod tests {
}
/// A resource root holding one animated block texture: a strip of two
/// 16x16 frames, red then blue, whose mcmeta starts on the blue one.
/// Each call gets its own folder: tests run side by side, and one adds
/// a strip of its own that another would otherwise find.
fn animated_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-atlas-anim-{}", std::process::id()));
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("horizon-atlas-anim-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let block = root.join("assets/minecraft/textures/block");
fs::create_dir_all(&block).unwrap();
write_strip(
@@ -29,36 +29,47 @@ const GLUE: Glue = Glue {
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.
/// The packs' text shader, when a pack replaces either stage.
pub(super) fn load(root: &Path, defaults: &Path) -> Option<Arc<CoreProgram>> {
load_core(root, defaults, "text", &["IS_GUI"], &GLUE)
}
/// The packs' core shader `name` (`shaders/core/<name>.vsh` and `.fsh`) over `defaults` (the
/// game's own resources, which have vanilla's and its includes), when a pack replaces either
/// stage.
pub(super) fn load_core(
root: &Path,
defaults: &Path,
name: &str,
defines: &[&str],
glue: &Glue,
) -> 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));
let vertex_path = format!("assets/minecraft/shaders/core/{name}.vsh");
let fragment_path = format!("assets/minecraft/shaders/core/{name}.fsh");
let (own_vertex, own_fragment) = (own(&vertex_path), own(&fragment_path));
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 vertex = own_vertex.or_else(|| read(defaults, &vertex_path))?;
let fragment = own_fragment.or_else(|| read(defaults, &fragment_path))?;
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) {
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
Ok(program) => {
log::info!("the resource pack's text shader draws the GUI's text");
log::info!("the resource pack's {name} shader is used");
Some(Arc::new(program))
}
Err(e) => {
log::warn!("the resource pack's text shader is not used: {e}");
log::warn!("the resource pack's {name} shader is not used: {e}");
None
}
}
+252 -105
View File
@@ -38,10 +38,27 @@ const QUEUED_PER_SAMPLER: usize = 32;
const MAX_REFRESHED: usize = 64;
/// Threads that sample far tiles beside the mesher. Sampling is the cold-load
/// bottleneck (each task evaluates a shared density lattice for 256 columns),
/// so medium machines use half their cores, not a quarter.
/// so medium machines use two thirds of their cores, not half.
///
/// The mesher has little to do until tiles are sampled, so its threads sit
/// idle through the part of a cold build that waits on sampling. Measured on
/// a 12-thread machine building 4336 tiles at LOD distance 256, from the game
/// answering the control socket until every target tile is sampled, four runs
/// a configuration:
///
/// | samplers / meshers | sampling | whole build |
/// |---|---|---|
/// | 6 / 5 (before) | 15.0 s | 23.9 s |
/// | 8 / 3 | 11.8 s | 18.6 s |
/// | 9 / 2 | 10.6 s | 19.1 s |
///
/// Nine samplers finish sampling sooner still, but leave the mesher two
/// threads and the whole build ends later. The cap keeps a large machine
/// from starving its mesher, and `meshing_threads` keeps the frame and the
/// ticks a core either way.
fn sampling_threads(cores: usize) -> usize {
if cores >= 8 {
(cores / 2).min(8)
(cores * 2 / 3).min(8)
} else {
(cores / 4).clamp(1, 3)
}
@@ -69,6 +86,9 @@ 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;
/// A split tile stays split until its distance passes the threshold by this
/// ratio (`Plan::visit`): 1 / 0.95.
const RETURN: (i64, i64) = (20, 19);
/// The farthest `Plan` reaches, in chunks.
pub const MAX_CHUNKS: u32 = 1 << 22;
/// The coarsest tile level whose chunks are generated in full (trees,
@@ -176,7 +196,11 @@ pub struct Column {
}
impl Column {
/// Reduce an entire horizontal footprint, rather than inflating its corner
/// column (a single leaf used to become a whole distant tree-sized slab).
/// column. A height is drawn where any column of the footprint holds a
/// block there, in the material most of them hold: anything joined to
/// the ground stays joined at every width. Drawing only heights most of
/// the footprint held dropped trunks (one column of 16 or 256) under the
/// crowns they hold up, and the crowns hung in the air as slabs.
pub fn reduced(columns: &[Column; 256], x: usize, z: usize, width: usize) -> Self {
if width == 1 {
return columns[z * 16 + x].clone();
@@ -200,7 +224,7 @@ impl Column {
let mut bottom = MIN_Y as i16;
for (y, material, change) in events {
if bottom < y {
if occupied * 2 >= (width * width) as i32
if occupied > 0
&& let Some(&((state, biome), _)) =
votes.iter().max_by_key(|&(material, count)| {
(*count, std::cmp::Reverse((material.0.0, material.1)))
@@ -272,39 +296,6 @@ impl Column {
self.spans.push(span);
}
}
/// Open water and its floor lit as open sky, as the seed's far columns
/// are: a chunk's own light fades a level per block of water, so the
/// chunks a player passed drew a darker band through the seed's sea.
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
self.light_open_water_where(|state| {
let kind = kinds.get(state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
});
}
/// [`Column::light_open_water`] for a caller that knows which states are
/// water without `BlockFacts`: the save decoder, whose palette already
/// names water apart from lava. Saved columns skipping this lit their
/// water a level darker per block, which drew a band through the sea
/// where they met the generator's columns.
pub fn light_open_water_where(&mut self, is_water: impl Fn(BlockStateId) -> bool) {
let water = |span: &Span| is_water(span.state);
if !self.spans.last().is_some_and(water) {
return;
}
let floor = self
.spans
.iter()
.rposition(|span| !water(span))
.unwrap_or(0);
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
if i >= floor {
span.light |= 0xf0;
}
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
@@ -369,7 +360,6 @@ impl Column {
});
}
}
column.light_open_water(kinds);
let mut water = None;
for span in column.spans.iter().rev() {
if kind(span.state) & FLUID != 0 {
@@ -670,20 +660,36 @@ 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 held = previous.split.contains(&key);
let split = if held { 10 } else { 8 };
// 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 {
let world = key.distance(camera) >= WORLD_START;
let threshold = i64::from(key.size())
* i64::from(if world {
WORLD_SPLIT
} else if reduced {
split / 2
} else {
split
};
});
// The 8 to 10 band above is wide already. The ring of full detail
// around the camera and the world-wide threshold have none: a tile
// split last plan stays split until it is 5% past them, or it
// flickered between two levels as the camera moved a block to and
// fro across the line.
let hold = |bound: i64| {
if held {
bound * RETURN.0 / RETURN.1
} else {
bound
}
};
let distance = i64::from(key.distance(camera));
if key.level > 0
&& (key.distance(camera) < detail * 16 + 16
|| key.distance(camera).max(self.elevation) < threshold)
&& (distance < hold(i64::from(detail) * 16 + 16)
|| distance.max(i64::from(self.elevation))
< if world { hold(threshold) } else { threshold })
{
self.split.insert(key);
for child in key.children() {
@@ -823,6 +829,10 @@ struct Snapshot {
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
saved: Saved,
reduced: Reduced,
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
/// range (`Snapshot::planned_leaf`).
nodes: FxHashSet<TileKey>,
split: FxHashSet<TileKey>,
}
/// One column of a mesh input, by reference into the snapshot. Mesh jobs used
/// to clone all 256 columns of a tile (each with its own span allocation);
@@ -853,6 +863,8 @@ struct EdgeSource {
start: i32,
end: i32,
column: ColumnSource,
/// Planned but not drawn yet (`mesh::EdgeColumn::unknown`).
unknown: bool,
}
struct MeshInput {
/// The tile's samples, with `overrides` replacing some of them.
@@ -885,6 +897,7 @@ impl MeshInput {
start: edge.start,
end: edge.end,
column: edge.column.get(),
unknown: edge.unknown,
})
.collect()
})
@@ -1011,6 +1024,18 @@ struct Shared {
approximate: AtomicBool,
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
/// The near terrain is loading: the far threads step behind it
/// (`horizon_threads::far_terrain_behind`) and come back once it is in.
near_loading: AtomicBool,
}
/// Puts the calling far terrain thread behind the near terrain while it
/// loads, and back once it is in. `behind`: where the thread is now.
fn step_behind(shared: &Shared, behind: &mut bool) {
let loading = shared.near_loading.load(Ordering::Relaxed);
if loading != *behind {
horizon_threads::far_terrain_behind(loading);
*behind = loading;
}
}
/// A background worker, sampling and meshing threads, and a bounded queue of
/// sample jobs (`sample_jobs`). Only the current view's quadtree and a
@@ -1139,6 +1164,7 @@ impl Lod {
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
mesh_held: AtomicBool::new(false),
near_loading: AtomicBool::new(false),
});
let worker = shared.clone();
let (tx, results) = mpsc::channel();
@@ -1158,7 +1184,8 @@ impl Lod {
thread::Builder::new()
.name("Far meshing".into())
.spawn(move || {
horizon_threads::behind_server();
horizon_threads::far_terrain_current();
let mut behind = true;
let mut mesher = mesh::Mesher::default();
loop {
let job = {
@@ -1189,6 +1216,7 @@ impl Lod {
}
continue;
}
step_behind(&shared, &mut behind);
let keep_finer = !job.finer.is_empty();
let input = job.snapshot.mesh_input(job.key, job.finer);
let columns = input.columns();
@@ -1244,7 +1272,8 @@ impl Lod {
.name("Far terrain".into())
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
horizon_threads::far_terrain_current();
let mut behind = true;
let mut sampler = source.sampler();
let count = sampling_threads(cores);
let mut sampling = Vec::new();
@@ -1258,7 +1287,8 @@ impl Lod {
thread::Builder::new()
.name("Far sampling".into())
.spawn(move || {
horizon_threads::behind_server();
horizon_threads::far_terrain_current();
let mut behind = true;
loop {
let key = {
let mut jobs = shared.jobs.lock().unwrap();
@@ -1281,6 +1311,7 @@ impl Lod {
jobs = shared.wake.wait(jobs).unwrap();
}
};
step_behind(&shared, &mut behind);
let [x, _, z] = key.origin();
sample.set_approximate(
shared.approximate.load(Ordering::Relaxed),
@@ -1346,6 +1377,7 @@ impl Lod {
}
};
let Some(job) = job else { continue };
step_behind(&worker, &mut behind);
let done = match job {
Job::Observe(key) => {
let chunk = worker.observed.lock().unwrap().remove(&key);
@@ -2091,6 +2123,7 @@ impl Lod {
self.held_sample_at = std::time::Instant::now();
}
self.near_loading = loading;
self.shared.near_loading.store(loading, Ordering::Relaxed);
let held = self.held();
if self.shared.mesh_held.swap(held, Ordering::Release) && !held {
self.shared.mesh_wake.notify_all();
@@ -2223,7 +2256,7 @@ impl Lod {
/// Rebuilds the quadtree and the selection it implies. Only runs when the
/// planned area changes (a chunk boundary), never per finished sample.
fn replan(&mut self, camera: [i32; 2], detail: i32, distance: u32, elevation: i32) {
self.plan = Plan::with_previous(
let plan = Plan::with_previous(
camera,
detail,
distance,
@@ -2231,6 +2264,17 @@ impl Lod {
elevation,
self.reduced_detail,
);
// A tile that leaves the range before it was drawn stops being
// unknown to its neighbours (`Snapshot::planned_leaf`): they remesh
// with walls against it, as against any terrain out of range.
let gone: Vec<_> = self
.plan
.nodes
.iter()
.filter(|key| !plan.nodes.contains(key) && !self.ready.contains_key(key))
.copied()
.collect();
self.plan = plan;
self.ready.retain(|key, _| self.plan.nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
@@ -2254,6 +2298,9 @@ impl Lod {
for key in standins {
self.refresh_path(key);
}
for key in gone {
self.mark_side_neighbors_of(key);
}
}
/// Recomputes every active tile after a plan change, then marks only the
/// tiles whose selection changed (plus their neighbours).
@@ -2559,6 +2606,8 @@ impl Lod {
.collect(),
saved: self.saved.clone(),
reduced: self.reduced.clone(),
nodes: self.plan.nodes.clone(),
split: self.plan.split.clone(),
});
let covered = Arc::new(self.covered.clone());
let mut queued = self.shared.mesh_jobs.lock().unwrap();
@@ -2706,6 +2755,8 @@ impl Lod {
.collect(),
saved: self.saved.clone(),
reduced: self.reduced.clone(),
nodes: self.plan.nodes.clone(),
split: self.plan.split.clone(),
}
}
#[cfg(test)]
@@ -2777,6 +2828,23 @@ impl Snapshot {
}
None
}
/// Whether block column (wx, wz) lies in a planned tile that is not
/// split, whatever drew it (nothing, here): the finest planned tile
/// there. Past a split tile's out-of-range children, or past the plan,
/// there is no terrain to wait for.
fn planned_leaf(&self, wx: i32, wz: i32) -> bool {
(0..=MAX_LEVEL)
.map(|level| {
let size = 16 << level;
TileKey {
x: wx.div_euclid(size),
z: wz.div_euclid(size),
level,
}
})
.find(|key| self.nodes.contains(key))
.is_some_and(|key| !self.split.contains(&key))
}
fn mesh_input(&self, key: TileKey, finer: Vec<TileKey>) -> MeshInput {
let stride = key.size() / CELLS as i32;
let [ox, _, oz] = key.origin();
@@ -2800,9 +2868,12 @@ impl Snapshot {
2 => (ox + offset, oz - 1),
_ => (ox + offset, oz + key.size()),
};
let (column, neighbor_stride) = self
.neighbor_source(wx, wz)
.unwrap_or((ColumnSource::Empty, stride));
// Nothing drawn across the edge: still to come when planned,
// so no wall is drawn against it; empty past the plan.
let (column, neighbor_stride, unknown) = match self.neighbor_source(wx, wz) {
Some((column, stride)) => (column, stride, false),
None => (ColumnSource::Empty, stride, self.planned_leaf(wx, wz)),
};
let coordinate = if direction < 2 { wz } else { wx };
let width = (neighbor_stride - coordinate.rem_euclid(neighbor_stride))
.min(key.size() - offset);
@@ -2810,6 +2881,7 @@ impl Snapshot {
start: offset,
end: offset + width,
column,
unknown,
});
offset += width;
}
@@ -3148,6 +3220,27 @@ mod tests {
assert!(after.nodes.len() < 16000);
}
#[test]
fn a_split_tile_stays_split_a_little_past_the_detail_ring() {
// Detail 40 puts the ring of full detail at 656 blocks, past where
// level 2 tiles split by their width (8 * 64 = 512): the ring alone
// splits this one, 640 blocks away.
let tile = TileKey {
x: 10,
z: 0,
level: 2,
};
let before = Plan::new([0, 0], 40, 256);
assert!(before.split.contains(&tile));
// 670 blocks: past the ring, inside 5% of it.
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false);
assert!(held.split.contains(&tile));
// Not held without having been split: it was never in the ring.
assert!(!Plan::new([-30, 0], 40, 256).split.contains(&tile));
// 700 blocks: past the margin.
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false);
assert!(!gone.split.contains(&tile));
}
#[test]
fn reduced_material_votes_ignore_light_and_retain_exposure() {
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
for i in 0..16 {
@@ -3185,49 +3278,6 @@ mod tests {
);
}
#[test]
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
let span = |min, max, state, light| Span {
min,
max,
state: BlockStateId(state),
biome: 0,
light,
};
// 0 air, 1 stone, 2 water, 3 lava.
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
// A chunk's own light: a cave, the floor, water fading a level a block.
let mut sea = Column {
surface: None,
spans: vec![
span(30, 40, 1, 0),
span(40, 48, 1, 0x10 | 2 << 12),
span(48, 61, 2, 0x20),
span(61, 62, 2, 0xe0),
span(62, 63, 2, 0xf0),
]
.into(),
};
sea.light_open_water(&kinds);
assert_eq!(
sea.spans,
[
span(30, 40, 1, 0),
span(40, 48, 1, 0xf0 | 2 << 12),
span(48, 63, 2, 0xf0)
]
);
// Land, and lava, keep their light.
for top in [1, 3] {
let mut column = Column {
surface: None,
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)].into(),
};
let before = column.spans.clone();
column.light_open_water(&kinds);
assert_eq!(column.spans, before);
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
@@ -3262,7 +3312,7 @@ mod tests {
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
fn coarse_voxels_keep_what_any_column_holds_so_crowns_stay_on_trunks() {
let span = |min, max, state| Span {
min,
max,
@@ -3274,8 +3324,8 @@ mod tests {
surface: None,
spans: vec![span(0, 4, 1)].into(),
});
columns[0].spans.push(span(8, 10, 2));
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, [span(0, 4, 1)]);
// One trunk column under a crown over the whole footprint.
columns[0].spans.push(span(4, 12, 2));
for z in 0..4 {
for x in 0..4 {
columns[z * 16 + x].spans.push(span(12, 14, 3));
@@ -3283,17 +3333,20 @@ mod tests {
}
assert_eq!(
Column::reduced(&columns, 0, 0, 4).spans,
[span(0, 4, 1), span(12, 14, 3)]
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
);
assert_eq!(Column::reduced(&columns, 0, 0, 1), columns[0]);
let reduced = ReducedColumns::new(Arc::new(columns));
assert_eq!(
reduced.get(0, 0, 1).spans,
[span(0, 4, 1), span(8, 10, 2), span(12, 14, 3)]
reduced.get(0, 0, 4).spans,
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
);
assert_eq!(reduced.get(0, 0, 4).spans, [span(0, 4, 1), span(12, 14, 3)]);
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
assert_eq!(
reduced.get(0, 0, 16).spans,
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)],
"a crown over one corner of a wide cell still sits on its trunk"
);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
@@ -3999,9 +4052,22 @@ mod tests {
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
assert_eq!([12, 16, 64].map(sampling_threads), [6, 8, 8]);
assert_eq!([2, 8, 12, 16].map(meshing_threads), [1, 3, 5, 7]);
// Small machines are unchanged: a quarter of their cores, never more
// than three, so the near chunks and the frame keep theirs.
assert_eq!([1, 2, 4].map(sampling_threads), [1, 1, 1]);
// From eight, two thirds rather than half, still capped at eight.
assert_eq!([8, 11, 12].map(sampling_threads), [5, 7, 8]);
assert_eq!([16, 64].map(sampling_threads), [8, 8]);
assert_eq!([2, 8, 12, 16].map(meshing_threads), [1, 2, 3, 7]);
// From four cores up the two pools together leave one for the frame
// and the ticks. Below that both clamp to one and there is nothing
// to spare, which is the existing behaviour.
for cores in [4usize, 8, 11, 12, 16, 64] {
assert!(
sampling_threads(cores) + meshing_threads(cores) < cores,
"{cores} cores leaves nothing for the frame"
);
}
}
/// Review point 3 on #4747: a result from a batch that is no longer the
@@ -4060,6 +4126,87 @@ mod tests {
assert_eq!(meshes.len(), 2, "a tile that left the view is dropped");
}
/// A neighbour inside the plan that is not drawn yet is unknown, not
/// empty, and its arrival remeshes the tile that faced it. One past the
/// planned range is still empty.
#[test]
fn a_planned_neighbor_that_is_not_ready_is_unknown_not_empty() {
let mut lod = Lod::scheduler_for_test();
let a = TileKey {
x: 0,
z: 0,
level: 0,
};
let b = TileKey {
x: 1,
z: 0,
level: 0,
};
lod.plan = Plan::default();
lod.plan.roots = vec![a, b];
lod.plan.nodes.extend([a, b]);
lod.finish_sample(a, vec![Column::default(); CELLS * CELLS]);
assert!(lod.active.contains(&a));
let sides = |lod: &Lod| {
let input = lod.snapshot().mesh_input(a, Vec::new());
[0, 1].map(|side| {
input.neighbors[side]
.iter()
.map(|edge| (edge.unknown, matches!(edge.column, ColumnSource::Tile(..))))
.collect::<Vec<_>>()
})
};
let [west, east] = sides(&lod);
assert!(west.iter().all(|&edge| edge == (false, false)), "range end");
assert!(east.iter().all(|&edge| edge == (true, false)), "planned");
// Its arrival draws the neighbour and remeshes the tile facing it.
lod.dirty.clear();
lod.finish_sample(b, vec![Column::default(); CELLS * CELLS]);
assert!(lod.dirty.contains_key(&a), "the tile facing it is remeshed");
let [_, east] = sides(&lod);
assert!(east.iter().all(|&edge| edge == (false, true)), "drawn");
// Past a split tile's out-of-range child there is nothing to wait for.
let parent = TileKey {
x: 4,
z: 4,
level: 1,
};
lod.plan.nodes.insert(parent);
lod.plan.split.insert(parent);
lod.plan.nodes.extend(parent.children().into_iter().take(3));
let snapshot = lod.snapshot();
let [x, _, z] = parent.origin();
assert!(snapshot.planned_leaf(x + 1, z + 1), "a planned leaf");
assert!(!snapshot.planned_leaf(x + 17, z + 17), "no child planned");
assert!(!snapshot.planned_leaf(x - 40, z), "outside the plan");
}
#[test]
fn a_planned_neighbor_leaving_the_range_unready_remeshes_the_tile_that_waited() {
let mut lod = Lod::scheduler_for_test();
let a = TileKey {
x: 0,
z: 0,
level: 0,
};
let b = TileKey {
x: 1,
z: 0,
level: 0,
};
lod.plan = Plan::default();
lod.plan.roots = vec![a, b];
lod.plan.nodes.extend([a, b]);
lod.finish_sample(a, vec![Column::default(); CELLS * CELLS]);
lod.dirty.clear();
// A range of one chunk around (-10, 0) keeps `a` and drops `b`.
lod.replan([-10, 0], 0, 1, 0);
assert!(lod.plan.nodes.contains(&a) && !lod.plan.nodes.contains(&b));
assert!(lod.dirty.contains_key(&a), "its east wall is drawn now");
}
#[test]
fn far_meshing_holds_while_small_machine_near_terrain_loads() {
let mut lod = Lod::scheduler_for_test();
+88 -7
View File
@@ -16,6 +16,9 @@ pub(super) struct EdgeColumn<'a> {
pub start: i32,
pub end: i32,
pub column: &'a Column,
/// The neighbour is planned but not drawn yet: its terrain is not known
/// to be empty, so the sides facing it are left out, not drawn as walls.
pub unknown: bool,
}
#[derive(Clone, Copy)]
@@ -319,6 +322,7 @@ fn mesh_columns_with_neighbors(
start: row as i32 * stride,
end: (row as i32 + 1) * stride,
column,
unknown: false,
})
.collect()
});
@@ -347,13 +351,17 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
..*span
};
let mut spans = Spans::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)),
if !fluid(top) {
spans.push(ground(top));
} else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) {
// Shaded water is a span per light level; the bed is under all of it.
spans.push(ground(under));
spans.push(Span {
min: under.max,
..*top
});
} else {
spans.push(*top);
}
Column {
surface: column.surface,
@@ -396,6 +404,7 @@ fn mesh_columns_with_edges(
start: edge.start,
end: edge.end,
column,
unknown: edge.unknown,
})
.collect()
});
@@ -660,6 +669,9 @@ fn mesh_columns_with_edges(
// A coarse face can meet several finer columns.
// Clip each interval independently to avoid missing walls.
for (i, edge) in neighbors[edge_side].iter().enumerate() {
if edge.unknown {
continue;
}
let start = u0.max(edge.start as f32);
let end = u1.min(edge.end as f32);
if start < end {
@@ -785,6 +797,70 @@ mod refinement_tests {
}
assert_eq!(area, (parent.size() * parent.size() - 256) as f32);
}
/// A neighbour that is still to come draws no wall against it; an empty
/// one (out of range) does.
#[test]
fn unknown_neighbor_leaves_its_side_out() {
let key = TileKey {
x: 0,
z: 0,
level: 0,
};
let columns = vec![
Column {
surface: None,
spans: vec![Span {
min: 0,
max: 8,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into()
};
256
];
let columns: Vec<&Column> = columns.iter().collect();
let empty = Column::default();
let east_quads = |unknown: bool| {
let edge = |column| EdgeColumn {
start: 0,
end: 16,
column,
unknown,
};
let neighbors = [Vec::new(), vec![edge(&empty)], Vec::new(), Vec::new()];
let mesh = mesh_columns_with_edges(
key,
&columns,
&FxHashSet::default(),
None,
&neighbors,
&[],
|_, _, _| Material {
faces: [
Some(Paint {
layer: 1,
light: 240,
color: u32::MAX,
translucent: false,
}),
None,
],
fluid: 0,
opaque: true,
},
);
mesh.solid
.as_chunks::<4>()
.0
.iter()
.filter(|quad| quad.iter().all(|v| v.pos[0] == 16.0))
.count()
};
assert!(east_quads(false) > 0, "a wall faces the empty neighbour");
assert_eq!(east_quads(true), 0, "none faces the unknown one");
}
}
/// The tile's columns with the gaps no open air reaches filled with the
@@ -1442,6 +1518,7 @@ mod tests {
start: 0,
end: CELLS as i32,
column: &hill_column,
unknown: false,
};
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
@@ -1583,6 +1660,7 @@ mod tests {
start: 0,
end: 32,
column: &columns[0],
unknown: false,
}]
});
// One fine column is lower than both the coarse cell and its sibling.
@@ -1596,16 +1674,19 @@ mod tests {
start: 0,
end: 1,
column: &columns[0],
unknown: false,
},
EdgeColumn {
start: 1,
end: 2,
column: &low,
unknown: false,
},
EdgeColumn {
start: 2,
end: 32,
column: &columns[0],
unknown: false,
},
];
let columns: Vec<&Column> = columns.iter().collect();
+57 -12
View File
@@ -6,6 +6,7 @@
mod fluid;
mod leaf_culling;
mod lighting;
pub mod packed;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
@@ -265,6 +266,12 @@ pub struct SectionMesh {
/// model order before.
pub sides: Sides,
pub translucent: Vec<TerrainVertex>,
/// `solid` in the compact layout (`packed`), when the renderer reads it and it is
/// smaller; empty otherwise. Made by `sort_solid`.
pub packed: Vec<[u32; 6]>,
/// The tint of each solid quad as the mesher multiplied it (`packed::UNKNOWN_TINT` where
/// it does not say), for `packed`; dropped once that is made.
pub(crate) solid_tints: Vec<u32>,
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
/// order) are connected through non-opaque blocks.
pub visibility: u64,
@@ -290,6 +297,8 @@ impl Default for SectionMesh {
solid: Vec::new(),
sides: Sides::default(),
translucent: Vec::new(),
packed: Vec::new(),
solid_tints: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
@@ -322,6 +331,8 @@ impl SectionMesh {
pub fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.packed.clear();
self.solid_tints.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
@@ -333,15 +344,27 @@ impl SectionMesh {
pub(crate) fn capacity_bytes(&self) -> usize {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.packed.capacity() * std::mem::size_of::<[u32; 6]>()
+ self.solid_tints.capacity() * std::mem::size_of::<u32>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
/// Then lays them out compactly (`packed`) while the renderer reads that.
pub fn sort_solid(&mut self) {
self.sides = sides::sort(&mut self.solid);
let quads = self.solid.len() / 4;
if !self.solid_tints.is_empty() {
self.solid_tints.resize(quads, packed::UNKNOWN_TINT);
}
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
self.bounds = self.compute_bounds();
self.packed.clear();
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
self.packed = packed::layout(&self.solid, &self.solid_tints);
}
self.solid_tints.clear();
}
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
@@ -1519,12 +1542,24 @@ impl Mesher<'_> {
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
let culled_leaf = match self.leaf_culling {
LeafCullingMode::Hollow | LeafCullingMode::Hidden => {
state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz)
&& (self.leaf_culling == LeafCullingMode::Hollow
|| leaf_culling::backed(
pad,
info,
region,
base_y,
[x + 2 * dx, y + 2 * dy, z + 2 * dz],
))
}
_ => false,
};
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& !culled_leaf
&& self
.visibility
.should_render_face(
@@ -1770,8 +1805,8 @@ impl Mesher<'_> {
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0), tint),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -1779,7 +1814,8 @@ impl Mesher<'_> {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
let piece = piece.map(|c| c.0);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece, tint);
}
}
}
@@ -1823,7 +1859,7 @@ impl Mesher<'_> {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&whole, s, t).0);
self.quad_as(out, false, tile, piece, false);
self.quad_as(out, false, tile, piece, false, tint);
}
}
}
@@ -1836,8 +1872,9 @@ impl Mesher<'_> {
solid_leaves: bool,
tile: TileLayer,
v: [TerrainVertex; 4],
tint: Option<u32>,
) {
self.quad_as(out, solid_leaves, tile, v, true);
self.quad_as(out, solid_leaves, tile, v, true, tint);
}
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
fn translucent(&self, tile: TileLayer) -> bool {
@@ -1856,7 +1893,8 @@ impl Mesher<'_> {
}
}
/// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only
/// when `by_sprite`: an overlay's pass is its rule's.
/// when `by_sprite`: an overlay's pass is its rule's. `tint`: what its grey levels were
/// multiplied by, for the compact layout (`packed`).
fn quad_as(
&self,
out: &mut SectionMesh,
@@ -1864,6 +1902,7 @@ impl Mesher<'_> {
tile: TileLayer,
mut v: [TerrainVertex; 4],
by_sprite: bool,
tint: Option<u32>,
) {
let TileLayer::Layer {
layer,
@@ -1897,6 +1936,12 @@ impl Mesher<'_> {
vertex.layer = layer;
}
target.extend(v);
if !translucent {
// Quads other code put in `solid` (lava) have no tint known.
let quad = out.solid.len() / 4 - 1;
out.solid_tints.resize(quad, packed::UNKNOWN_TINT);
out.solid_tints.push(tint.unwrap_or(packed::WHITE));
}
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
@@ -12,7 +12,9 @@
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
//! the section, preserving boundaries and cavity faces. `Hidden` drops only
//! those whose faced leaf has another leaf or an opaque cube behind it
//! ([`backed`]), so the layer seen through the canopy's outer holes stays.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
@@ -229,6 +231,21 @@ pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// `Hidden`: whether the padded cell `[x, y, z]`, the one behind a faced leaf,
/// is a leaf or a full opaque cube. A cell without real data never is.
#[inline]
pub(super) fn backed(
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
[x, y, z]: [i32; 3],
) -> bool {
info.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves || state.opaque_cube)
&& known(region, base_y, x, y, z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
@@ -0,0 +1,343 @@
//! The compact layout of a section's solid quads in the arena: most of them are whole faces
//! of a block's cube on the block grid, and those take one 24-byte record each instead of
//! four 24-byte vertices. terrain.wgsl's `section_corner` expands a record into the very
//! vertex words `TerrainVertex` would hold (`packed_vertex` here is the same on the CPU, and
//! a record is kept only when it gives back the quad's four vertices bit for bit), so every
//! pass draws the same picture from either layout.
//!
//! A mesh's solid range is then one record per quad in mesh order (so `Sides` and the draws
//! count quads as before), padding to a multiple of 4 slots, and the quads no record fits as
//! their four vertices: their record holds the distance to those (`FULL`). A record:
//!
//! - word 0: the tint (0xRRGGBB as the mesher multiplies it) and the colour's alpha byte;
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
use std::sync::atomic::{AtomicBool, Ordering};
/// A solid quad's tint the mesher does not know (fluids): only untinted colours pack.
pub const UNKNOWN_TINT: u32 = u32::MAX;
/// The tint of an untinted quad, whose colour is its grey level.
pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
/// Whether meshes sorted from now on get the compact layout: set by the renderer while its
/// passes read it (no shader pack).
static PACK: AtomicBool = AtomicBool::new(false);
pub fn pack_quads(on: bool) {
PACK.store(on, Ordering::Relaxed);
}
pub fn packing() -> bool {
PACK.load(Ordering::Relaxed)
}
/// The order corners go round a face: (du, dw) of each step.
const ROUND: [[u32; 2]; 4] = [[0, 0], [1, 0], [1, 1], [0, 1]];
/// `quad`'s record, if one gives back its four vertices exactly. `tint`: what the mesher
/// multiplied its grey levels by (`UNKNOWN_TINT` when it does not say).
pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
// Corners on the block grid of the section.
let mut p = [[0u32; 3]; 4];
for (to, v) in p.iter_mut().zip(quad) {
for (c, &x) in to.iter_mut().zip(&v.pos) {
if !(0.0..=16.0).contains(&x) || x.fract() != 0.0 {
return None;
}
*c = x as u32;
}
}
let axis = (0..3).find(|&a| p.iter().all(|c| c[a] == p[0][a]))?;
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
let lo = [u, w].map(|k| p.iter().map(|c| c[k]).min().unwrap_or(0));
if lo.iter().any(|&l| l > 15) {
return None;
}
let mut steps = [0u32; 4];
for (step, c) in steps.iter_mut().zip(&p) {
let d = [c[u] - lo[0], c[w] - lo[1]];
*step = ROUND.iter().position(|r| *r == d)? as u32;
}
let (start, dir) = (0..8u32).map(|n| (n & 3, n >> 2)).find(|&(start, dir)| {
(0..4u32).all(|i| {
let step = if dir == 1 { 4 - i } else { i };
steps[i as usize] == (start + step) & 3
})
})?;
let plane = p[0][axis];
let mut block = [0u32; 3];
block[axis] = plane.min(15);
block[u] = lo[0];
block[w] = lo[1];
let side = plane - block[axis];
let orient = block[0]
| block[1] << 4
| block[2] << 8
| (axis as u32) << 12
| side << 14
| start << 15
| dir << 17;
// UVs in 1/32 of the sprite, at most two values each way.
let mut ranges = [[u32::MAX, 0u32]; 2];
let mut uvs = [[0u32; 2]; 4];
for (uv, v) in uvs.iter_mut().zip(quad) {
for k in 0..2 {
let raw = u32::from(v.uv[k]);
if raw & 1023 != 0 {
return None;
}
uv[k] = raw >> 10;
ranges[k] = [ranges[k][0].min(uv[k]), ranges[k][1].max(uv[k])];
}
}
let mut select = 0u32;
for (i, uv) in uvs.iter().enumerate() {
for k in 0..2 {
if uv[k] == ranges[k][1] && uv[k] != ranges[k][0] {
select |= 1 << (2 * i + k);
} else if uv[k] != ranges[k][0] {
return None;
}
}
}
let rect = ranges[0][0] | ranges[0][1] << 6 | ranges[1][0] << 12 | ranges[1][1] << 18;
// Light in steps of 4 (smooth light averages four levels of 16).
let mut light = [0u32; 4];
for (l, v) in light.iter_mut().zip(quad) {
let (sky, block) = (u32::from(v.light & 0xFF), u32::from(v.light >> 8));
if sky & 3 != 0 || block & 3 != 0 {
return None;
}
*l = sky >> 2 | (block >> 2) << 6;
}
// The colour: each channel the grey level times the tint's, over 255.
let tint = if tint == UNKNOWN_TINT {
WHITE
} else {
tint & WHITE
};
let alpha = quad[0].color & 0xFF00_0000;
let mut grays = 0u32;
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
let (a, b) = (packed_vertex(&words, c), quad[c]);
bytemuck::bytes_of(&a) == bytemuck::bytes_of(&b)
});
exact.then_some(words)
}
/// The tint's channels, as the mesher takes them for red, green and blue.
fn channels(tint: u32) -> [u32; 3] {
[tint >> 16 & 255, tint >> 8 & 255, tint & 255]
}
/// A grey level that `tint` turns into `color`'s red, green and blue.
fn gray_of(color: u32, tint: u32) -> Option<u32> {
let want = [color & 255, color >> 8 & 255, color >> 16 & 255];
let t = channels(tint);
let gives = |g: u32| (0..3).all(|k| g * t[k] / 255 == want[k]);
let k = (0..3).max_by_key(|&k| t[k]).unwrap_or(0);
if t[k] == 0 {
return gives(0).then_some(0);
}
let lo = (want[k] * 255).div_ceil(t[k]);
let hi = ((want[k] * 255 + 254) / t[k]).min(255);
(lo..=hi).find(|&g| gives(g))
}
/// Corner `corner` of the quad `words` holds, as terrain.wgsl's `packed_corner` expands it.
pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let [w0, w1, w2, w3, w4, w5] = *words;
let orient = w5 >> 12 & 0x3_FFFF;
let block = [orient & 15, orient >> 4 & 15, orient >> 8 & 15];
let axis = (orient >> 12 & 3) as usize;
let side = orient >> 14 & 1;
let (start, dir) = (orient >> 15 & 3, orient >> 17 & 1);
let i = corner as u32;
let step = (start + if dir == 1 { 4 - i } else { i }) & 3;
let [du, dw] = ROUND[step as usize];
let mut pos = block;
pos[axis] += side;
pos[(axis + 1) % 3] += du;
pos[(axis + 2) % 3] += dw;
let select = w2 >> 24;
let us = select >> (2 * i) & 1;
let vs = select >> (2 * i + 1) & 1;
let uv = [w2 >> (6 * us) & 63, w2 >> (12 + 6 * vs) & 63].map(|k| (k << 10) as u16);
let light = [w3 >> 16, w4, w4 >> 12, w5][corner] & 0xFFF;
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
}
}
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
/// nothing when it would be no smaller than their vertices.
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
let quads = solid.as_chunks::<4>().0;
let records: Vec<Option<[u32; 6]>> = quads
.iter()
.zip(tints)
.map(|(q, &t)| pack_quad(q, t))
.collect();
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
if n + pad + 4 * full >= 4 * n {
return Vec::new();
}
let mut out = Vec::with_capacity(n + pad + 4 * full);
let mut next = n + pad;
for (i, record) in records.iter().enumerate() {
out.push(record.unwrap_or_else(|| {
let at = next;
next += 4;
[(at - i) as u32, 0, 0, 0, 0, FULL]
}));
}
out.resize(n + pad, [0; 6]);
for (q, record) in quads.iter().zip(&records) {
if record.is_none() {
out.extend(
q.iter()
.map(|v| bytemuck::cast::<TerrainVertex, [u32; 6]>(*v)),
);
}
}
out
}
/// Quads `layout` gave a record of their own.
pub fn packed_quads(layout: &[[u32; 6]], quads: usize) -> usize {
layout
.iter()
.take(quads)
.filter(|w| w[5] & FULL == 0)
.count()
}
#[cfg(test)]
mod tests {
use super::*;
const TINT: u32 = 0x7F_B238;
/// An opaque colour the mesher makes of grey level `g` times `tint`.
fn tinted(g: u32, tint: u32) -> u32 {
let [r, gr, b] = channels(tint).map(|t| g * t / 255);
r | gr << 8 | b << 16 | 0xFF00_0000
}
/// The top face of block (3, 15, 5), corners in `order`: tinted, smooth lit, with
/// ambient occlusion darkening its corners differently.
fn top(order: [usize; 4]) -> [TerrainVertex; 4] {
let pos = [
[3.0, 16.0, 5.0],
[3.0, 16.0, 6.0],
[4.0, 16.0, 6.0],
[4.0, 16.0, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
let grays = [200, 180, 160, 200];
order.map(|i| TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 7,
light: 0xF0 | 0x40 << 8,
color: tinted(grays[i], TINT),
})
}
fn same(a: &TerrainVertex, b: &TerrainVertex) -> bool {
bytemuck::bytes_of(a) == bytemuck::bytes_of(b)
}
#[test]
fn a_whole_cube_face_takes_one_record_either_way_round() {
for order in [[0, 1, 2, 3], [2, 3, 0, 1], [0, 3, 2, 1]] {
let quad = top(order);
let words = pack_quad(&quad, TINT).expect("a whole face on the block grid packs");
for (corner, v) in quad.iter().enumerate() {
assert!(
same(&packed_vertex(&words, corner), v),
"{order:?} corner {corner}"
);
}
}
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
let mut slab = top([0, 1, 2, 3]);
for v in &mut slab {
v.pos[1] = 8.5;
}
assert!(pack_quad(&slab, TINT).is_none());
// A UV between the 1/32 steps.
let mut uv = top([0, 1, 2, 3]);
uv[2].uv[0] += 1;
assert!(pack_quad(&uv, TINT).is_none());
// Light between the steps of 4.
let mut light = top([0, 1, 2, 3]);
light[1].light += 1;
assert!(pack_quad(&light, TINT).is_none());
}
#[test]
fn a_quad_without_a_record_points_at_its_own_vertices() {
let mut odd = top([0, 1, 2, 3]);
for v in &mut odd {
v.pos[1] = 8.5;
}
let mut solid: Vec<TerrainVertex> = Vec::new();
for _ in 0..4 {
solid.extend(top([0, 1, 2, 3]));
}
solid.extend(odd);
let out = layout(&solid, &[TINT; 5]);
// Five records, padded to eight slots, then the odd quad's four vertices.
assert_eq!(out.len(), 12);
assert_eq!(packed_quads(&out, 5), 4);
assert_eq!(out[4][5], FULL);
let at = 4 + out[4][0] as usize;
for (slot, v) in out[at..at + 4].iter().zip(&odd) {
assert!(same(&bytemuck::cast::<[u32; 6], TerrainVertex>(*slot), v));
}
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
}
@@ -74,7 +74,7 @@ fn slot(grouped: bool, n: usize) -> (usize, bool) {
/// Where each part of each bucket of a mesh's solid quads ends, and the
/// planes that tell whether a bucket can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Sides {
/// The solid quads up to the end of each part of each bucket, in mesh
/// order (`slot`).
@@ -205,13 +205,21 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
#[cfg(test)]
fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_tinted(solid, &mut Vec::new())
}
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
let grouped = GROUPED.load(Ordering::Relaxed);
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
}
/// `sort`, with all the opaque parts first when `grouped`.
#[cfg(test)]
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, &mut Vec::new(), grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
@@ -222,6 +230,7 @@ struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
tints: Vec<u32>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
@@ -233,9 +242,15 @@ fn total_order_key(v: f32) -> u32 {
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
fn sort_with(
solid: &mut Vec<TerrainVertex>,
tints: &mut Vec<u32>,
grouped: bool,
scratch: &mut SortScratch,
) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let quads_len = quads.len();
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
// Each quad's part, by its place in mesh order (`slot`).
let position = |b: usize, cutout: bool| match grouped {
@@ -297,6 +312,12 @@ fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortSc
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
if tints.len() == quads_len {
let sorted_tints = &mut scratch.tints;
sorted_tints.clear();
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
std::mem::swap(tints, sorted_tints);
}
}
Sides {
ends: slot_ends,
+8 -3
View File
@@ -99,9 +99,14 @@ pub(crate) fn prepare_with_models(
.filter(|name| registry.default_state(name).is_some())
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
.collect();
let mut catalog =
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
catalog.bake_standalone(root, sprites, models, standalone())?;
let mut catalog = {
let _step = crate::spans::startup::step("core.bake_states");
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?
};
{
let _step = crate::spans::startup::step("core.bake_standalone");
catalog.bake_standalone(root, sprites, models, standalone())?;
}
let mut placements = Vec::new();
let mut states = Vec::new();
for (index, (name, properties)) in GALLERY.iter().enumerate() {
@@ -6,7 +6,7 @@
// the depth bounds stage, which sees the unfaded alpha.
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
let faded = 1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y);
return vec4(color.rgb, color.a * select(1.0, faded, fade));
}
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
+223 -7
View File
@@ -289,6 +289,7 @@ impl Opt {
"options.modelPart.right_pants_leg",
"options.modelPart.hat",
][i],
Opt::SkinLayers3d => "horizon.options.three_d_skin_layers",
Opt::MainHand => "options.mainHand",
Opt::AllowServerListing => "options.allowServerListing",
Opt::RealmsNotifications => "options.realmsNotifications.button",
@@ -729,6 +730,11 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::SkinLayers3d => tr(if s.extras.three_d_skin_layers {
"options.on"
} else {
"options.off"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
@@ -1013,6 +1019,7 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::SkinLayers3d => s.extras.three_d_skin_layers = !s.extras.three_d_skin_layers,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
@@ -2612,6 +2619,8 @@ impl OptionsUi {
Page::Skin => {
// SkinCustomizationScreen: the seven layers, then Main Hand.
let mut opts: Vec<Opt> = (0..MODEL_PART_KEYS.len()).map(Opt::ModelPart).collect();
// Horizon's own: the outer layer as real pixels, not one box.
opts.push(Opt::SkinLayers3d);
opts.push(Opt::MainHand);
pairs(&opts)
}
@@ -3595,6 +3604,12 @@ impl OptionsUi {
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
let hover = pointer_over || is_focused;
// The tooltip is the pointer's (`setTooltip`, drawn while hovered);
// a keyboard-focused widget keeps it against the widget
// (`MenuTooltipPositioner`). Focus a mouse click leaves behind
// does not: the description goes when the mouse leaves, like
// vanilla's.
let tip_hover = pointer_over || (is_focused && self.focus_from_keyboard);
// A pack's option: its label within the widget (the name shortened), and on hover
// the whole label when it was, then what the pack says of it.
let shader = s.shader_options.as_ref();
@@ -3609,7 +3624,7 @@ impl OptionsUi {
let fitted = shader.zip(option).map(|(o, name)| {
o.option_label_within(name, wd.w - 8.0, |t| atlas.font.width_text(t))
});
if hover && let Some(o) = shader {
if tip_hover && let Some(o) = shader {
let comment = match (&wd.kind, option) {
(_, Some(name)) => o.comment(&format!("option.{name}")),
(
@@ -3641,7 +3656,7 @@ impl OptionsUi {
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
}
if hover && let Some(tip) = tooltip_text(s, wd) {
if tip_hover && let Some(tip) = tooltip_text(s, wd) {
tooltip = Some(match &wd.kind {
// The duplicate keybinds tooltip keeps its own lines.
WKind::Button {
@@ -3907,9 +3922,9 @@ impl OptionsUi {
};
return self.bind_mouse(s, button);
}
let actions = self
.graphics
.click(s, (x, y), size, framebuffer, drag, right);
let actions =
self.graphics
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if self.page() == Page::Packs {
@@ -4102,13 +4117,13 @@ impl OptionsUi {
}
Act::Done => events.extend(self.back(s)),
Act::Cycle(opt) => {
events.extend(self.cycle_option(s, opt, right, framebuffer));
events.extend(self.cycle_option(s, opt, self.shift, framebuffer));
}
Act::Extra(item) => {
if let Some(name) = item.key_name() {
self.selected_key = Some(name);
} else {
item.cycle(&mut s.extras, right);
item.cycle(&mut s.extras, self.shift);
events.push(Event::Changed);
}
}
@@ -6010,6 +6025,65 @@ mod tests {
assert_eq!(s.file.fullscreen_resolution, None);
}
#[test]
fn a_clicked_extras_tooltip_leaves_with_the_pointer() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
let extra = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::Extra(item),
..
} if !matches!(item, crate::extras::Item::SortKey)
)
})
.unwrap();
ui.click(
&mut s,
&atlas,
extra.x + 1.0,
extra.y + 1.0,
(480.0, 480.0),
[960, 540],
false,
false,
);
// The pointer is still on the clicked widget: it carries the
// description (`setTooltip`, drawn while hovered).
let mut on = HudBatch::new([480, 480]);
ui.draw(
&s,
&mut on,
&atlas,
(extra.x + extra.w / 2.0, extra.y + extra.h / 2.0),
);
// The pointer moved off: the description goes with it. A mouse
// focus a click left behind (the look-up keeps the click's widget,
// `focus_from_keyboard` false) does not keep the tooltip against
// the widget, like vanilla's `MenuTooltipPositioner` does for a
// keyboard focus.
let mut off = HudBatch::new([480, 480]);
ui.draw(
&s,
&mut off,
&atlas,
(extra.x + extra.w + 40.0, extra.y + extra.h + 40.0),
);
assert!(
off.vertices.len() < on.vertices.len(),
"a click left {} tooltip vertices behind when the pointer left ({} vs {})",
on.vertices.len() - off.vertices.len(),
on.vertices.len(),
off.vertices.len(),
);
}
#[test]
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
let mut s = Settings::default();
@@ -7853,4 +7927,146 @@ mod tests {
assert!(events.contains(&Event::Changed));
assert!(s.extras.music_off.is_empty());
}
#[test]
fn options_cycle_button_cycles_forward_and_shift_click_cycles_backward_with_wrapping() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Chat);
let size = (480.0, 480.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let button = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Cycle(Opt::ChatVisibility),
..
}
)
})
.expect("ChatVisibility must be a cycle button");
let (x, y) = (button.x + 1.0, button.y + 1.0);
// Initial chat_visibility is 0
assert_eq!(s.chat_visibility, 0);
// 1. Feature without shift (normal click): cycles forward (+1)
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 1);
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 2);
// 3. Wrap-around forward (at max value, normal click wraps to min / 0)
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 0);
// 4. Wrap-around backward (at min / 0, shift-click wraps to max)
ui.shift = true;
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 2);
// 2. Feature with shift: cycles backward (-1)
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 1);
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::ButtonClick, Event::Changed]
);
assert_eq!(s.chat_visibility, 0);
ui.shift = false;
// Also test Opt::Particles on Page::Video
ui.show(Page::Video);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let button = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Cycle(Opt::Particles),
..
}
)
})
.expect("Particles must be a cycle button");
let (px, py) = (button.x + 1.0, button.y + 1.0);
// Initial particles is 0
assert_eq!(s.particles, 0);
// Forward
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 1);
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 2);
// Wrap forward
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 0);
// Wrap backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 2);
// Backward with shift
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 1);
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 0);
ui.shift = false;
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
}) {
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
assert_eq!(s.extras.cosmetics_others, Others::Shown);
// Forward
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::NameTags);
// Backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::Shown);
// Wrap-around backward with shift
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::Hidden);
ui.shift = false;
}
}
}
+368 -29
View File
@@ -19,6 +19,7 @@ use horizon_core::{
};
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{
array,
collections::{HashMap, HashSet},
path::Path,
};
@@ -47,6 +48,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// `VibrationParticleOption`: the listener the signal flies to and its
/// arrival time in ticks (`ParticleEngine::vibration` is the same particle).
pub vibration: Option<([f64; 3], i32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
@@ -62,6 +66,7 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
vibration: None,
mod_particle: None,
}
}
@@ -87,6 +92,13 @@ impl ParticleOptions {
..Self::simple(ty)
}
}
/// `VibrationParticleOption(destination, arrivalInTicks)`.
pub fn vibration(dest: [f64; 3], ticks: i32) -> Self {
Self {
vibration: Some((dest, ticks)),
..Self::simple(ParticleType::Vibration)
}
}
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
pub fn item(layers: Vec<String>) -> Self {
Self {
@@ -516,7 +528,7 @@ pub fn breaking_position(
}
}
// Vanilla consumes all three doubles before replacing the hit-face coordinate.
let mut p = std::array::from_fn(|i| {
let mut p = array::from_fn(|i| {
f64::from(pos[i])
+ random.next_double() * (bounds[i + 3] - bounds[i] - f64::from(0.2f32))
+ f64::from(0.1f32)
@@ -648,18 +660,16 @@ pub fn destroy_positions(
mut emit: impl FnMut([f64; 3], [f64; 3]),
) {
for b in boxes {
let widths: [f64; 3] = std::array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
let widths: [f64; 3] = array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
let counts: [usize; 3] = widths.map(|w| ((w / 0.25).ceil() as usize).max(2));
for x in 0..counts[0] {
for y in 0..counts[1] {
for z in 0..counts[2] {
let cells = [x, y, z];
let relative: [f64; 3] =
std::array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
emit(
std::array::from_fn(|i| {
f64::from(pos[i]) + (relative[i] * widths[i] + b[i])
}),
array::from_fn(|i| f64::from(pos[i]) + (relative[i] * widths[i] + b[i])),
relative.map(|v| v - 0.5),
);
}
@@ -740,7 +750,7 @@ pub fn on_block_faces_between(
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
for _ in 0..count {
// getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55.
let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5));
let speed: [f64; 3] = array::from_fn(|_| next_double_between(random, -0.5, 0.5));
let mut p = [0.0; 3];
let mut v = [0.0; 3];
for i in 0..3 {
@@ -769,12 +779,12 @@ pub fn along_axis(
) {
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
for _ in 0..count {
let p: [f64; 3] = std::array::from_fn(|i| {
let p: [f64; 3] = array::from_fn(|i| {
f64::from(pos[i])
+ 0.5
+ next_double_between(random, -1.0, 1.0) * if i == axis { 0.5 } else { 0.125 }
});
let v: [f64; 3] = std::array::from_fn(|i| {
let v: [f64; 3] = array::from_fn(|i| {
if i == axis {
next_double_between(random, -1.0, 1.0)
} else {
@@ -1257,6 +1267,113 @@ impl ParticleLightCache {
}
}
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
radius.is_finite()
&& radius >= 0.0
&& distance_squared.is_finite()
&& distance_squared > 0.0
&& pixel_focal_length.is_finite()
&& pixel_focal_length > 0.0
&& projected_limit.is_finite()
&& projected_limit * projected_limit < distance_squared
}
/// `ClientExplosionTracker.ExplosionInfo`.
#[derive(Clone, Copy, Debug)]
struct TrackedExplosion {
@@ -1435,15 +1552,14 @@ impl ParticleEngine {
self.particles.clear();
self.pending.clear();
}
/// A vibration signal flying from `from` to `to` in `ticks`
/// (`VibrationParticleOption`).
pub fn vibration(&mut self, from: [f64; 3], to: [f64; 3], ticks: i32) {
/// `VibrationSignalParticle`'s body: the signal leaves `from` aimed at the
/// listener at `to` and lands there in `ticks`.
fn vibration_particle(from: [f64; 3], to: [f64; 3], ticks: i32) -> Particle {
let mut q = Particle::blank(ParticleType::Vibration, from);
q.size = 0.3;
q.lifetime = ticks;
q.has_physics = false;
q.light = Light::Block15;
q.set = self.set_of(ParticleType::Vibration);
let d = [from[0] - to[0], from[1] - to[1], from[2] - to[2]];
let rot = d[0].atan2(d[2]) as f32;
let pitch = d[1].atan2((d[0] * d[0] + d[2] * d[2]).sqrt()) as f32;
@@ -1454,6 +1570,14 @@ impl ParticleEngine {
pitch,
pitch_o: pitch,
};
q
}
/// A vibration signal flying from `from` to `to` in `ticks`
/// (`VibrationParticleOption`), as the client's own vibration system
/// (`VibrationSystem.Ticker`) spawns one.
pub fn vibration(&mut self, from: [f64; 3], to: [f64; 3], ticks: i32) {
let mut q = Self::vibration_particle(from, to, ticks);
q.set = self.set_of(ParticleType::Vibration);
self.pending.push(q);
}
/// `ParticleEngine.add` (via `particlesToAdd`, applied on the next tick).
@@ -1533,7 +1657,7 @@ impl ParticleEngine {
for _ in 0..128 {
let r = &mut self.level_random;
let t = r.next_double();
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let position = [
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) + 0.5,
to[1] + t * (from[1] - to[1]) + r.next_double() - 0.5,
@@ -1561,7 +1685,7 @@ impl ParticleEngine {
for _ in 0..128 {
let r = &mut self.level_random;
let t = r.next_double();
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
// Mth.lerp(t, to, from).
let position = [
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) * WIDTH * 2.0,
@@ -2758,8 +2882,16 @@ impl ParticleEngine {
}
q
}
// Vibration signals are created by `vibration`, aimed at a listener.
T::Vibration => return None,
// VibrationSignalParticle.Provider: `VibrationParticleOption` carries
// the listener the signal flies to and its arrival time; the sprite
// is the set's own draw from the engine's random.
T::Vibration => {
let (dest, ticks) = o.vibration?;
let mut q = Self::vibration_particle(p, dest, ticks);
q.set = set;
q.sprite = self.sprite_from_set(ty);
q
}
// ShriekParticle.
T::Shriek => {
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
@@ -4365,8 +4497,7 @@ impl ParticleEngine {
.filter(|p| p.rgba[3] > 0.0)
.filter_map(|p| {
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
let at =
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
let at = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
})
.collect()
@@ -4381,11 +4512,70 @@ impl ParticleEngine {
partial: f32,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
yaw,
pitch,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
}
/// Render particles after rejecting view-frustum misses and any particles
/// the caller proves hidden behind opaque terrain.
pub fn render_culled(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
culling_enabled: bool,
frustum: Option<&crate::gfx::Frustum>,
mut occluded: impl FnMut([f64; 3], [f64; 3]) -> bool,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, yaw, pitch, partial, light_at, out);
return;
}
self.render_inner::<true>(
camera,
yaw,
pitch,
partial,
pixel_focal_length,
frustum,
&mut occluded,
light_at,
out,
);
}
fn render_inner<const CULL: bool>(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
frustum: Option<&crate::gfx::Frustum>,
occluded: &mut dyn FnMut([f64; 3], [f64; 3]) -> bool,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
for p in &self.particles {
if p.no_render {
continue;
@@ -4397,6 +4587,39 @@ impl ParticleEngine {
{
continue;
}
if CULL && matches!(p.behaviour, Behaviour::Shriek { delay } if delay > 0) {
continue;
}
let culled_geometry = if CULL {
let a = f64::from(partial);
let world = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * a);
let center = (DVec3::from_array(world) - camera).as_vec3();
let size = p.quad_size(partial);
// Every generated quad vertex is sqrt(2) * size from its center,
// including the rotated two-quad particle types.
let radius = f64::from(size.abs()) * std::f64::consts::SQRT_2 + 0.05;
if let (Some(frustum), Some(section_cache)) = (frustum, section_cache.as_mut()) {
match section_cache.result(world, camera, radius, frustum) {
SectionFrustumResult::Outside => continue,
SectionFrustumResult::Intersects
if !frustum.intersects_sphere(center, radius as f32) =>
{
continue;
}
SectionFrustumResult::Inside | SectionFrustumResult::Intersects => {}
}
}
let distance_squared = f64::from(center.length_squared());
if below_half_pixel(radius, distance_squared, pixel_focal_length) {
continue;
}
if occluded(world.map(|v| v - radius), world.map(|v| v + radius)) {
continue;
}
Some((center, size))
} else {
None
};
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
// layer draws after translucent terrain, so a crumb of stained
// glass cannot hide the glass behind it (`getLayer`).
@@ -4412,19 +4635,20 @@ impl ParticleEngine {
},
};
let Some(r) = rect else { continue };
let a = f64::from(partial);
let pos = DVec3::new(
p.previous[0] + (p.position[0] - p.previous[0]) * a,
p.previous[1] + (p.position[1] - p.previous[1]) * a,
p.previous[2] + (p.position[2] - p.previous[2]) * a,
) - camera;
let center = pos.as_vec3();
let (center, size) = culled_geometry.unwrap_or_else(|| {
let a = f64::from(partial);
let pos = DVec3::new(
p.previous[0] + (p.position[0] - p.previous[0]) * a,
p.previous[1] + (p.position[1] - p.previous[1]) * a,
p.previous[2] + (p.position[2] - p.previous[2]) * a,
) - camera;
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.roll != 0.0 {
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
} else {
base
};
let size = p.quad_size(partial);
let mut alpha = p.rgba[3];
// OverlayParticle.extract: the flash fades as it pulses.
if p.ty == ParticleType::Flash {
@@ -4438,7 +4662,7 @@ impl ParticleEngine {
match p.behaviour {
// ShriekParticle.extract: two tilted quads, fading out.
Behaviour::Shriek { delay } => {
if delay > 0 {
if !CULL && delay > 0 {
continue;
}
alpha = 1.0 - ((p.age as f32 + partial) / p.lifetime as f32).clamp(0.0, 1.0);
@@ -4481,7 +4705,7 @@ impl ParticleEngine {
// DustColorTransitionParticle.lerpColors.
Behaviour::Transition { from, to } => {
let t = (p.age as f32 + partial) / (p.lifetime as f32 + 1.0);
rgb = std::array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
rgb = array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
}
_ => {}
}
@@ -4616,6 +4840,86 @@ mod tests {
assert_eq!(reads.get(), 3);
}
#[test]
fn subpixel_culling_uses_the_frame_projection_and_fails_open() {
assert!(below_half_pixel(0.01, 41.0_f64.powi(2), 1_000.0));
assert!(!below_half_pixel(0.01, 40.0_f64.powi(2), 1_000.0));
assert!(!below_half_pixel(0.01, 41.0_f64.powi(2), 0.0));
assert!(!below_half_pixel(0.01, f64::NAN, 1_000.0));
assert!(!below_half_pixel(f64::NAN, 41.0_f64.powi(2), 1_000.0));
for distance in [0.25_f32, 1.0, 8.0, 40.0, 128.0] {
for radius in [0.01, 0.05, 0.2] {
let center = FVec3::new(distance, distance * 0.3, -distance * 0.8);
let old = 2.0 * radius * 900.0 / f64::from(center.length()) < 0.5;
assert_eq!(
below_half_pixel(radius, f64::from(center.length_squared()), 900.0),
old
);
}
}
}
#[test]
fn disabled_culled_render_matches_the_regular_particle_path() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
let mut particle =
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
particle.sprite = Sprite::Named(sprite);
engine.particles.push(particle);
let light_at = |_: [i32; 3]| 0xf0;
let mut regular = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
let mut disabled = Vec::new();
engine.render_culled(
DVec3::ZERO,
0.0,
0.0,
0.5,
900.0,
false,
None,
|_, _| panic!("disabled culling must not query particle visibility"),
&light_at,
&mut disabled,
);
let attributes = |vertices: &[Vertex]| {
vertices
.iter()
.map(|vertex| {
(
vertex.pos,
vertex.uv,
vertex.layer,
vertex.color,
vertex.light,
vertex.normal,
vertex.glint_uv,
)
})
.collect::<Vec<_>>()
};
assert_eq!(attributes(&disabled), attributes(&regular));
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
@@ -5176,6 +5480,41 @@ mod tests {
}
assert_eq!(engine.pending.len(), 1010);
}
/// `VibrationSignalParticle.Provider.createParticle`: the provider path
/// the client's packet handler reaches (`add_with`) makes the signal from
/// the option's destination and arrival time, where it used to refuse it.
#[test]
fn a_vibration_option_makes_the_signal_its_provider_draws() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let options = ParticleOptions::vibration([10.5, 70.5, -3.5], 20);
// Within the camera's 32 blocks, so only the provider decides.
engine.add_with(&options, [0.5, 1.5, 0.5], [0.0; 3], false, false);
assert_eq!(engine.made().get("vibration"), Some(&1));
let q = engine.pending.first().expect("the signal is made");
assert_eq!(q.ty, ParticleType::Vibration);
assert_eq!(q.lifetime, 20);
assert!((q.size - 0.3).abs() < 1e-6);
assert!(!q.has_physics);
match &q.behaviour {
Behaviour::Vibration {
dest, rot, pitch, ..
} => {
assert_eq!(*dest, [10.5, 70.5, -3.5]);
// The constructor aims it from where it starts at the listener.
let d: [f64; 3] = [0.5 - 10.5, 1.5 - 70.5, 0.5 + 3.5];
assert!((*rot - d[0].atan2(d[2]) as f32).abs() < 1e-6);
let flat = (d[0] * d[0] + d[2] * d[2]).sqrt();
assert!((*pitch - d[1].atan2(flat) as f32).abs() < 1e-6);
}
_ => panic!("a vibration signal"),
}
// Only `VibrationParticleOption` carries a listener; a bare type has none.
let bare = ParticleOptions::simple(ParticleType::Vibration);
assert!(
engine.make(&bare, [0.0; 3], [0.0; 3], 1).is_none(),
"no destination, no signal"
);
}
#[test]
fn particle_names_round_trip() {
for &ty in ParticleType::ALL {
@@ -490,7 +490,7 @@ pub struct Globals {
impl Globals {
/// The std140 block: `ivec3, float, vec3, float, vec2, int, int`.
fn bytes(&self, size: [u32; 2], menu_blur_radius: i32) -> [u8; 48] {
pub(crate) fn bytes(&self, size: [u32; 2], menu_blur_radius: i32) -> [u8; 48] {
let mut out = Vec::with_capacity(48);
for v in self.camera_block_pos {
out.extend_from_slice(&v.to_le_bytes());
+41 -9
View File
@@ -337,23 +337,28 @@ impl Settings {
.parent()
.map(crate::gamepad::PadSettings::load)
.unwrap_or_default();
let extras = path
let extras_text = path
.parent()
.map(crate::extras::Extras::load)
.and_then(|dir| fs::read_to_string(dir.join(crate::extras::FILE)).ok())
.unwrap_or_default();
let mut extras = crate::extras::Extras::parse(&extras_text);
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
Ok(bytes) => Self {
horizon,
controller,
extras,
mods,
..Self::parse(&String::from_utf8_lossy(&bytes))
},
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
extras.read_old_options(&extras_text, &text);
Self {
horizon,
controller,
extras,
mods,
..Self::parse(&text)
}
}
Err(_) => Self {
horizon,
controller,
@@ -808,6 +813,8 @@ pub enum Opt {
PadKeyboard,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
SkinLayers3d,
MainHand,
AllowServerListing,
RealmsNotifications,
@@ -844,6 +851,31 @@ mod tests {
);
}
#[test]
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
// #8091 wrote the switch into options.txt; a player who had it on keeps
// it, and the next options.txt is vanilla's again.
let dir = std::env::temp_dir().join(format!(
"horizon-skin-layers-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("options.txt"), "fov:0.5\nthreeDSkinLayers:true\n").unwrap();
let s = Settings::load(&dir.join("options.txt"));
assert!(s.extras.three_d_skin_layers);
assert_eq!(s.fov, 90.0);
assert!(!s.serialize().contains("threeDSkinLayers"));
assert!(crate::extras::Extras::parse(&s.extras.serialize()).three_d_skin_layers);
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
fs::write(dir.join(crate::extras::FILE), "threeDSkinLayers:false\n").unwrap();
assert!(
!Settings::load(&dir.join("options.txt"))
.extras
.three_d_skin_layers
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_option_set_keeps_horizon_files() {
let mut s = Settings::parse("fov:70\n");
s.mods.enabled.insert("zoom".into());
+11
View File
@@ -814,6 +814,17 @@ pub struct Downloader {
/// without end; the disk cache holds everything it drops.
const MAX_SKIN_IMAGES: usize = 1024;
impl Downloader {
/// The decoded 64×64 RGBA image an atlas location holds, when it is
/// still in memory (3D skin layers builds its geometry from it, and the
/// disk cache backs whatever was dropped).
pub fn image(&self, location: &str) -> Option<&[u8]> {
self.images.get(location).map(Vec::as_slice)
}
/// Keeps a decoded image under `location` for the images read back later
/// (a launcher's offline skin, which skips `queue`).
pub fn remember_image(&mut self, location: &str, rgba: &[u8]) {
self.remember(location.to_owned(), rgba.to_vec());
}
pub fn new(root: std::path::PathBuf) -> Self {
let (send, done) = std::sync::mpsc::channel();
Self {
+73
View File
@@ -18,8 +18,15 @@ thread_local! {
static HERE: Cell<bool> = const { Cell::new(false) };
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
static DEPTH: Cell<u8> = const { Cell::new(0) };
/// What this thread's slowest steps were busy with since it last took them ([`note`]).
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
}
/// The shortest step a [`note`] keeps, in milliseconds.
pub const NOTE_MS: f32 = 10.0;
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
const NOTES_KEPT: usize = 3;
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Span {
@@ -83,6 +90,36 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
});
out
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
/// kept, and `what` is built only for a step that is.
pub fn note(ms: f32, what: impl FnOnce() -> String) {
if ms < NOTE_MS || !recording_here() {
return;
}
NOTES.with(|n| {
let mut n = n.borrow_mut();
if n.len() >= NOTES_KEPT {
let Some((i, _)) = n
.iter()
.enumerate()
.filter(|(_, (kept, _))| *kept < ms)
.min_by(|a, b| a.1.0.total_cmp(&b.1.0))
else {
return;
};
n.swap_remove(i);
}
n.push((ms, what()));
});
}
/// This thread's notes ([`note`]), slowest first; clears them.
pub fn take_notes() -> Vec<(f32, String)> {
let mut notes = NOTES.with(|n| std::mem::take(&mut *n.borrow_mut()));
notes.sort_by(|a, b| b.0.total_cmp(&a.0));
notes
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
pub struct Laps(Option<(Instant, Option<Duration>)>);
@@ -404,6 +441,41 @@ pub mod startup {
#[cfg(test)]
mod tests {
use super::Span;
use std::sync::{Mutex, MutexGuard};
/// Spans on for every thread ([`super::enable`]) is process-wide: a test that turns them on
/// would make another, run beside it, record what it expects left out. Those tests take
/// this lock.
fn spans_switch() -> MutexGuard<'static, ()> {
static SWITCH: Mutex<()> = Mutex::new(());
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
}
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
/// thread that records no spans leave none.
#[test]
fn notes_keep_the_slowest_steps() {
let _switch = spans_switch();
super::record_here(false);
super::note(500.0, || "unrecorded".into());
assert!(super::take_notes().is_empty());
super::record_here(true);
for (ms, what) in [
(12.0, "a"),
(900.0, "b"),
(3.0, "short"),
(40.0, "c"),
(11.0, "d"),
(60.0, "e"),
] {
super::note(ms, || what.into());
}
let notes: Vec<(f32, String)> = super::take_notes();
super::record_here(false);
let names: Vec<&str> = notes.iter().map(|(_, w)| w.as_str()).collect();
assert_eq!(names, ["b", "e", "c"]);
assert!(super::take_notes().is_empty(), "taking them clears them");
}
/// Spans of one name add up; a span the thread spent mostly waiting
/// shows its CPU time after the wall time, short ones are left out.
@@ -515,6 +587,7 @@ mod tests {
/// Laps record the steps between them while spans are on, and nothing while off.
#[test]
fn laps_time_consecutive_steps() {
let _switch = spans_switch();
let _ = super::take();
let mut off = super::laps();
off.lap("unseen");
@@ -1,7 +1,7 @@
//! 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
//! (instance-local offline names and the launcher's Microsoft accounts), 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.
use crate::{
@@ -56,10 +56,12 @@ pub fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
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).
/// The profiles; those playing now (none can be picked twice); the signed-in Microsoft
/// accounts among them; and the one this player plays as (switching; none while joining).
pub names: Vec<String>,
pub taken: Vec<String>,
pub accounts: Vec<String>,
pub offline_accounts: 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>,
@@ -127,7 +129,7 @@ impl SplitProfiles {
}
fn editable(&self, name: &str) -> bool {
!self.taken.contains(&name.to_owned())
!self.accounts.contains(&name.to_owned()) && !self.taken.contains(&name.to_owned())
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -179,24 +181,34 @@ impl SplitProfiles {
.skip(self.page() * SHOWN)
.take(SHOWN)
{
let microsoft = self.accounts.contains(name);
let mut label = name.clone();
if microsoft {
label.push_str(&if self.offline_accounts.contains(name) {
tr("horizon.splitscreen.profiles.offline", " (Offline)")
} else {
tr("horizon.splitscreen.profiles.microsoft", " (Microsoft)")
});
}
if self.current.as_ref() == Some(name) {
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
[x, y, 176.0, 20.0],
[x, y, if microsoft { 200.0 } else { 176.0 }, 20.0],
label,
!self.taken.contains(name),
Target::Profile(i),
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
if !microsoft {
buttons.push((
[x + 180.0, y, 20.0, 20.0],
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
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 {
@@ -322,6 +334,19 @@ impl SplitProfiles {
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(_));
if hover && let Target::Profile(i) = target {
let name = &self.names[*i];
if self.accounts.contains(name) {
tip = Some(if self.offline_accounts.contains(name) {
tr(
"horizon.splitscreen.profiles.sign_in",
"Sign in again in the launcher",
)
} else {
tr("horizon.splitscreen.profiles.online", "Microsoft online")
});
}
}
let mut text = Text::literal(if remove { "" } else { label });
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
@@ -584,14 +609,31 @@ mod tests {
names: (0..11).map(|i| format!("Player{i}")).collect(),
..Default::default()
};
p.accounts = vec!["Player10".into()];
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Player10");
press(&mut p, &atlas, "Player10 (Microsoft)");
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 unavailable_accounts_are_marked_and_cannot_be_forgotten() {
let mut p = SplitProfiles {
names: vec!["Cal".into()],
accounts: vec!["Cal".into()],
offline_accounts: vec!["Cal".into()],
..Default::default()
};
assert!(!p.editable("Cal"));
let buttons = p.buttons(427.0, 240.0);
assert!(buttons.iter().any(|b| b.1 == "Cal (Offline)" && b.2));
assert!(!buttons.iter().any(|b| b.1 == "Forget Cal"));
press(&mut p, &atlas(), "Cal (Offline)");
assert_eq!(p.take_choice(), Some(Some("Cal".into())));
}
#[test]
fn profile_names_are_player_names() {
assert!(valid_name("Steve_2"));
+18 -7
View File
@@ -62,6 +62,15 @@ impl SpriteAnimation {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct StillKey {
frame: u64,
pbr: u64,
side: u32,
settings: [u32; 2],
flags: [bool; 4],
}
#[derive(Default)]
pub struct Sprites {
/// Edge length of every array layer and the layers' current frames, set
@@ -90,7 +99,7 @@ pub struct Sprites {
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
still: std::collections::HashMap<StillKey, u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -647,18 +656,20 @@ impl Sprites {
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
[
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
[settings.has_transparent, settings.item, opaque, translucent],
)
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
@@ -1224,6 +1224,14 @@ impl Streaming {
world.layout().sections() as i32,
);
}
// `ChunkTaskPriorityQueue.release` for each of them (`ChunkTaskDispatcher`'s
// `release(col, true)` from the chunk's unload): the jobs still queued for a
// column go with it instead of running for a level they no longer belong to.
if !gone.is_empty()
&& let Ok(mut queue) = self.shared.queue.lock()
{
release_columns(&mut queue, gone);
}
let mut chunks = Vec::with_capacity(gone.len());
for &(x, z) in gone {
self.lit.remove(&(x, z));
@@ -2879,6 +2887,25 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
}
*queue = BinaryHeap::from(jobs);
}
/// `ChunkTaskPriorityQueue.release(pos, clearQueue=true)`: the jobs queued for the
/// `gone` columns are dropped, so a view that moved or shrank does not wait behind the
/// generation of the chunks it left. A job without a column (a save, meshes to free)
/// stays, as vanilla keeps the tasks of a chunk that is still loaded. Returns how many
/// jobs went.
fn release_columns(queue: &mut BinaryHeap<Queued>, gone: &FxHashSet<(i32, i32)>) -> usize {
if queue.is_empty() || gone.is_empty() {
return 0;
}
let queued = std::mem::take(queue).into_vec();
let before = queued.len();
let kept: Vec<Queued> = queued
.into_iter()
.filter(|job| !job.at.is_some_and(|at| gone.contains(&at)))
.collect();
let released = before - kept.len();
*queue = BinaryHeap::from(kept);
released
}
/// Whether the 3x3 a light job for (x, z) reads is all there: every
/// neighbour a finished column, or a decorated proto chunk past the area
/// (the last FULL ring lights against neighbours at FEATURES, as
@@ -3310,6 +3337,59 @@ mod tests {
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
/// `ChunkTaskPriorityQueue.release`: a column that unloaded keeps no queued job, so
/// a teleport or a smaller render distance does not spend its workers on the world
/// it left; the other columns' jobs and the jobs without a column (saves, meshes to
/// free) stay, in the same order.
#[test]
fn a_released_column_keeps_no_queued_job() {
let generate = |x, z, priority, order| Queued {
priority,
at: Some((x, z)),
bias: 0,
order,
job: Job::Generate(x, z),
};
let save = |order| Queued {
priority: i64::MIN,
at: None,
bias: 0,
order,
job: Job::Save(Vec::new()),
};
let queued = || {
BinaryHeap::from(vec![
generate(0, 0, 0, 1),
generate(0, 0, 4, 2),
generate(1, 0, 8, 3),
generate(9, 9, 12, 4),
save(5),
])
};
let pops = |mut queue: BinaryHeap<Queued>| {
std::iter::from_fn(move || queue.pop())
.map(|q| q.at)
.collect::<Vec<_>>()
};
// Nothing goes for a column that is still loaded, or for nothing at all.
let mut queue = queued();
assert_eq!(release_columns(&mut queue, &FxHashSet::default()), 0);
assert_eq!(
release_columns(&mut queue, &[(20, 20)].into_iter().collect()),
0
);
assert_eq!(queue.len(), 5);
// Both of (0, 0)'s jobs go, its neighbour's and the save stay.
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
assert_eq!(release_columns(&mut queue, &gone), 2);
assert_eq!(pops(queue), [None, Some((1, 0)), Some((9, 9))]);
// Every column leaving at once: only the jobs without one are left.
let mut queue = queued();
let all: FxHashSet<(i32, i32)> = [(0, 0), (1, 0), (9, 9)].into_iter().collect();
assert_eq!(release_columns(&mut queue, &all), 4);
assert_eq!(pops(queue), [None]);
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
+202 -29
View File
@@ -32,13 +32,14 @@ struct Globals {
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
// in texels (vanilla's TextureSize).
// in texels (vanilla's TextureSize), z: the mip level at which a sprite is one texel when
// the views reach it, else 0 (`rotated_grid`).
filtering: vec4<f32>,
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@@ -124,12 +125,34 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
// Only the side of the mix that has weight is sampled: near texels (blend 0) take
// sample_nearest's one tap alone, far ones (blend 1) the rotated grid alone.
if blend <= 0.0 {
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled);
}
let rgss = rotated_grid(uv, layer, pixel, du, dv);
if blend >= 1.0 {
return rgss;
}
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
dv: vec2<f32>) -> vec4<f32> {
let min_pixel = min(pixel.x, pixel.y);
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
// From this level on the sprite is a single texel (0: the views stop before one is), and
// the sampler clamps any level past it to it: there every tap reads the same value, so
// one tap gives the four's mean. Distant tiles sit there.
let single = globals.filtering.z;
if single > 0.0 && low >= single {
return sprite_level(uv, layer, low);
}
let high_single = single > 0.0 && low + 1.0 >= single;
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
@@ -137,10 +160,14 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
sum_high += sprite_level(p, layer, low + 1.0);
if !high_single {
sum_high += sprite_level(p, layer, low + 1.0);
}
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
if high_single {
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
}
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
}
// 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.
@@ -148,10 +175,13 @@ 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);
return sprite_at(tiled, layer, dpdx(tiled), dpdy(tiled));
}
// `sprite` with the unwrapped UV's screen gradients given: the visibility buffer's resolve
// (visbuffer.wgsl) works them out from the quad, as its neighbours may be other quads.
fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4<f32> {
// terrain.fsh's two sampling paths: sampleRGSS for RGSS, sampleNearest otherwise.
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
@@ -320,8 +350,10 @@ fn fade_code(w: f32) -> u32 {
// (`mesher::Part`): first the quads with no cutout that are not fading, through
// `fs_opaque`, which never discards, so a tiled GPU (Apple's) keeps hiding the pixels
// behind them; then the rest through `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
// all the terrain it drew. The draw lists give each pass only its own part; a mesh that
// was not sorted (`Sides::unsorted`) gives both passes all of its quads, so each pass
// still leaves out the other's: decided before any of the vertex's work, outside the
// clip volume, it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
}
@@ -366,6 +398,134 @@ fn left_out() -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
// (`TerrainVertex`, six words, as `TERRAIN_ATTRIBUTES` reads them), from PACKED_BASE on a
// corner of the quad whose record is at that slot (`PACKED_BASE + 4 * slot + corner`).
// Either way they come out as the six words of the vertex, so the work after is the same.
// Binding 19: visbuffer.wgsl, in the same module, takes 2 to 18 of its own group 1.
@group(1) @binding(19) var<storage, read> arena_page: array<vec2<u32>>;
const PACKED_BASE: u32 = 0x10000000u;
// A vertex's words: position bits, then UV, layer with light, colour.
struct ArenaCorner {
p: vec3<u32>,
r: vec3<u32>,
}
fn arena_vertex(v: u32) -> ArenaCorner {
let a = arena_page[v * 3u];
let b = arena_page[v * 3u + 1u];
let c = arena_page[v * 3u + 2u];
return ArenaCorner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y));
}
// `packed::packed_vertex`: corner `corner` of the record whose words are lo (0 to 2) and
// hi (3 to 5).
fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let orient = (hi.z >> 12u) & 0x3ffffu;
let block = vec3(orient & 15u, (orient >> 4u) & 15u, (orient >> 8u) & 15u);
let axis = (orient >> 12u) & 3u;
let side = (orient >> 14u) & 1u;
let start = (orient >> 15u) & 3u;
let dir = (orient >> 17u) & 1u;
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
var pos = block;
pos[axis] = block[axis] + side;
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
let u = (lo.z >> (6u * us)) & 63u;
let v = (lo.z >> (12u + 6u * vs)) & 63u;
var light = hi.z;
if corner == 0u {
light = hi.x >> 16u;
} else if corner == 1u {
light = hi.y;
} else if corner == 2u {
light = hi.y >> 12u;
}
light &= 0xfffu;
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
// A solid draw's vertex `vertex`, and the slot its block is found by (`block_slots`, 256
// slots a block): the vertex's own, or its quad's record's. `packed`: it has a record.
struct SectionCorner {
c: ArenaCorner,
slot: u32,
packed: bool,
}
fn section_corner(vertex: u32) -> SectionCorner {
if vertex < PACKED_BASE {
return SectionCorner(arena_vertex(vertex), vertex, false);
}
let slot = (vertex - PACKED_BASE) >> 2u;
let corner = vertex & 3u;
let a = arena_page[slot * 3u];
let b = arena_page[slot * 3u + 1u];
let c = arena_page[slot * 3u + 2u];
// `packed::FULL`: the quad's four vertices, word 0 slots on.
if (c.y & 0x80000000u) != 0u {
return SectionCorner(arena_vertex(slot + a.x + corner), slot, true);
}
return SectionCorner(packed_corner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y), corner), slot,
true);
}
// The colour as the vertex fetch converts a Unorm8x4 attribute, bit for bit (Apple's M1:
// times 1/255; `unpack4x8unorm` and the correctly rounded c / 255 each differ from it for
// some bytes, and moved a few pixels' colour or alpha cutout).
fn unorm8x4(c: u32) -> vec4<f32> {
return vec4<f32>(vec4(c & 255u, (c >> 8u) & 255u, (c >> 16u) & 255u, c >> 24u))
* (1.0 / 255.0);
}
fn corner_layer(c: ArenaCorner) -> u32 {
return c.r.y & 0xffffu;
}
fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
let uv = vec2(c.r.x & 0xffffu, c.r.x >> 16u);
let light = vec2((c.r.y >> 16u) & 0xffu, c.r.y >> 24u);
return terrain_vertex(bitcast<vec3<f32>>(c.p), terrain_uv(uv, origin.w), corner_layer(c),
unorm8x4(c.r.z), light, origin.xyz, origin.w);
}
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
return corner_vertex(section_corner(vertex).c, origin);
}
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
let s = section_corner(vertex);
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
let s = section_corner(vertex);
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
@@ -448,7 +608,9 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
if globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
@@ -463,7 +625,9 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -475,6 +639,11 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
return solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv));
}
// `solid_finish` with the UV's screen gradients given (`sprite_at`).
fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<f32>,
dv: vec2<f32>) -> vec4<f32> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel
// shines, at which tier (mesher::hdr_tier) and how much (hdr_mark), by the
// texel's own brightness, so its light level and face shading don't change
@@ -482,27 +651,31 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
// parts, or all of it. Nothing else reads the frame's alpha.
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
// fully fogged mountain would stand out from the fog behind it.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
}
}
}
if in.emissive_layer != 0xffffffffu {
// The high bit: an OptiFine overlay, drawn cutout (opaque where alpha >= 0.1).
var mask = sprite(in.uv, in.emissive_layer & 0x7fffffffu);
var mask = sprite_at(in.uv, in.emissive_layer & 0x7fffffffu, du, dv);
if (in.emissive_layer & 0x80000000u) != 0u {
mask.a = select(0.0, 1.0, mask.a >= 0.1);
}
@@ -0,0 +1,427 @@
// The visibility buffer for solid terrain (gfx/visbuffer.rs, `HORIZON_VISBUFFER=1`), after
// terrain.wgsl in one module. Pass 1 rasterises the flat solid draws writing depth and, per
// pixel, the quad it shows: its arena page and its quad in that page (`vis_id`). Pass 2, one draw for up to 16 pages, runs the shading of `fs_opaque`/`solid_color`
// once per pixel from that ID: it reads the quad's corners from its page as the vertex shaders
// do, runs `terrain_vertex` on the
// triangle the pixel falls in, interpolates its outputs with perspective-correct barycentrics
// and works out the screen gradients `dpdx`/`dpdy` would give, so the texel, mip level, light
// and fog come out as in the normal path.
// Bit 31: terrain is here; bit 30: the quad has a record (`mesher/packed.rs`); bits 24 to 29:
// the arena page (`visbuffer::MAX_PAGES`); bits 0 to 23: the quad's record's slot in the page,
// else its first vertex over 4 (a page holds at most `arena::PAGE_BYTES` / 24 bytes =
// 11 184 810 slots, under 2^24).
fn vis_id(page: u32, s: SectionCorner) -> u32 {
let at = 0x80000000u | ((page & 0x3fu) << 24u);
if s.packed {
return at | 0x40000000u | s.slot;
}
return at | (s.slot >> 2u);
}
// `sprite_at` (terrain.wgsl's `sample_rgss` over `sample_nearest`), sampling only what its
// blends keep: where RGSS has not faded in (the texels span two pixels or more) the nearest
// texel alone, where it has fully, RGSS alone, and its upper mip level only where it blends one
// in. A blend of 0 or 1 gives exactly the one side, so the result is `sprite_at`'s; near
// faces take one tap instead of nine.
fn vis_sprite(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4<f32> {
let uv = tile_uv(tiled);
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) != 1u {
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
var nearest = vec4(0.0);
if blend < 1.0 {
nearest = sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
if blend <= 0.0 {
return nearest;
}
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
for (var i = 0; i < 4; i++) {
sum_low += sprite_level(uv + offsets[i] * pixel, layer, low);
}
var rgss = sum_low * 0.25;
if mip_blend > 0.0 {
var sum_high = vec4(0.0);
for (var i = 0; i < 4; i++) {
sum_high += sprite_level(uv + offsets[i] * pixel, layer, low + 1.0);
}
rgss = mix(rgss, sum_high * 0.25, mip_blend);
}
return mix(nearest, rgss, blend);
}
struct VisIdOut {
@builtin(position) clip: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
}
// The quads with no cutout that are not fading (`opaque_quad`): no discard, so a tiled GPU
// keeps hiding what lies behind them. `page`: the draw's instance, its arena page
// (`Flat::draw_paged`).
@vertex fn vs_vis_opaque(
@builtin(vertex_index) vertex: u32, @builtin(instance_index) page: u32
) -> VisIdOut {
var out: VisIdOut;
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) {
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
let position = bitcast<vec3<f32>>(s.c.p);
out.clip = fade_depth(globals.view_proj * vec4(position + origin.xyz, 1.0), origin.w);
out.id = vis_id(page, s);
return out;
}
@fragment fn fs_vis_id(in: VisIdOut) -> @location(0) u32 {
return in.id;
}
struct VisCutoutOut {
@builtin(position) clip: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
@location(1) uv: vec2<f32>,
@location(2) @interpolate(flat) layer: u32,
// The vertex colour's alpha (`terrain_vertex`'s `color.a`: the lightmap's).
@location(3) alpha: f32,
@location(4) @interpolate(flat) fade: u32,
@location(5) local: vec3<f32>,
}
// The other quads: `solid_color`'s fade dither and alpha cutout, with no shading.
@vertex fn vs_vis_cutout(
@builtin(vertex_index) vertex: u32, @builtin(instance_index) page: u32
) -> VisCutoutOut {
var out: VisCutoutOut;
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
let layer = corner_layer(s.c);
if opaque_quad(layer, origin.w) {
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
let position = bitcast<vec3<f32>>(s.c.p);
let uv = vec2(s.c.r.x & 0xffffu, s.c.r.x >> 16u);
let light = vec2((s.c.r.y >> 16u) & 0xffu, s.c.r.y >> 24u);
let pos = position + origin.xyz;
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), origin.w);
out.id = vis_id(page, s);
out.uv = terrain_uv(uv, origin.w);
out.layer = layer;
let block = max(f32(light.y), dynamic_block_light(pos));
out.alpha = sample_lightmap(f32(light.x) / 16.0, block / 16.0).a;
out.fade = fade_code(origin.w);
out.local = position;
return out;
}
@fragment fn fs_vis_cutout(in: VisCutoutOut) -> @location(0) u32 {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
let alpha = vis_sprite(in.uv, in.layer & 0x7fffu, dpdx(in.uv), dpdy(in.uv)).a * in.alpha;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return in.id;
}
// Pass 2. Up to 16 arena pages (mesher.rs's TerrainVertex, 24 bytes: position, UV as two u16,
// layer and light as u16 each, RGBA8 colour), a quad's 96 bytes as six vec4s; at bindings 0 and
// 1 the block tables of all pages, page after page, `VIS_BLOCKS_PER_PAGE` entries each
// (`Visbuffer::resolve` copies them from the flat draws' own), and the origins by section id
// (terrain.wgsl's flat draws); pass 1's IDs.
override VIS_BLOCKS_PER_PAGE: u32 = 1u;
@group(1) @binding(2) var<storage, read> vis_page_0: array<vec4<u32>>;
@group(1) @binding(3) var<storage, read> vis_page_1: array<vec4<u32>>;
@group(1) @binding(4) var<storage, read> vis_page_2: array<vec4<u32>>;
@group(1) @binding(5) var<storage, read> vis_page_3: array<vec4<u32>>;
@group(1) @binding(6) var<storage, read> vis_page_4: array<vec4<u32>>;
@group(1) @binding(7) var<storage, read> vis_page_5: array<vec4<u32>>;
@group(1) @binding(8) var<storage, read> vis_page_6: array<vec4<u32>>;
@group(1) @binding(9) var<storage, read> vis_page_7: array<vec4<u32>>;
@group(1) @binding(10) var<storage, read> vis_page_8: array<vec4<u32>>;
@group(1) @binding(11) var<storage, read> vis_page_9: array<vec4<u32>>;
@group(1) @binding(12) var<storage, read> vis_page_10: array<vec4<u32>>;
@group(1) @binding(13) var<storage, read> vis_page_11: array<vec4<u32>>;
@group(1) @binding(14) var<storage, read> vis_page_12: array<vec4<u32>>;
@group(1) @binding(15) var<storage, read> vis_page_13: array<vec4<u32>>;
@group(1) @binding(16) var<storage, read> vis_page_14: array<vec4<u32>>;
@group(1) @binding(17) var<storage, read> vis_page_15: array<vec4<u32>>;
@group(1) @binding(18) var vis_ids: texture_2d<u32>;
// A quad's 96 bytes, six 16-byte words from `i` on, of the page in `slot`: one switch for
// all of them, so a thread group whose pixels show several pages walks its cases once.
struct VisQuad {
q: array<vec4<u32>, 6>,
}
fn vis_quad(slot: u32, i: u32) -> VisQuad {
switch slot {
case 0u: {
return VisQuad(array(vis_page_0[i], vis_page_0[i + 1u], vis_page_0[i + 2u],
vis_page_0[i + 3u], vis_page_0[i + 4u], vis_page_0[i + 5u]));
}
case 1u: {
return VisQuad(array(vis_page_1[i], vis_page_1[i + 1u], vis_page_1[i + 2u],
vis_page_1[i + 3u], vis_page_1[i + 4u], vis_page_1[i + 5u]));
}
case 2u: {
return VisQuad(array(vis_page_2[i], vis_page_2[i + 1u], vis_page_2[i + 2u],
vis_page_2[i + 3u], vis_page_2[i + 4u], vis_page_2[i + 5u]));
}
case 3u: {
return VisQuad(array(vis_page_3[i], vis_page_3[i + 1u], vis_page_3[i + 2u],
vis_page_3[i + 3u], vis_page_3[i + 4u], vis_page_3[i + 5u]));
}
case 4u: {
return VisQuad(array(vis_page_4[i], vis_page_4[i + 1u], vis_page_4[i + 2u],
vis_page_4[i + 3u], vis_page_4[i + 4u], vis_page_4[i + 5u]));
}
case 5u: {
return VisQuad(array(vis_page_5[i], vis_page_5[i + 1u], vis_page_5[i + 2u],
vis_page_5[i + 3u], vis_page_5[i + 4u], vis_page_5[i + 5u]));
}
case 6u: {
return VisQuad(array(vis_page_6[i], vis_page_6[i + 1u], vis_page_6[i + 2u],
vis_page_6[i + 3u], vis_page_6[i + 4u], vis_page_6[i + 5u]));
}
case 7u: {
return VisQuad(array(vis_page_7[i], vis_page_7[i + 1u], vis_page_7[i + 2u],
vis_page_7[i + 3u], vis_page_7[i + 4u], vis_page_7[i + 5u]));
}
case 8u: {
return VisQuad(array(vis_page_8[i], vis_page_8[i + 1u], vis_page_8[i + 2u],
vis_page_8[i + 3u], vis_page_8[i + 4u], vis_page_8[i + 5u]));
}
case 9u: {
return VisQuad(array(vis_page_9[i], vis_page_9[i + 1u], vis_page_9[i + 2u],
vis_page_9[i + 3u], vis_page_9[i + 4u], vis_page_9[i + 5u]));
}
case 10u: {
return VisQuad(array(vis_page_10[i], vis_page_10[i + 1u], vis_page_10[i + 2u],
vis_page_10[i + 3u], vis_page_10[i + 4u], vis_page_10[i + 5u]));
}
case 11u: {
return VisQuad(array(vis_page_11[i], vis_page_11[i + 1u], vis_page_11[i + 2u],
vis_page_11[i + 3u], vis_page_11[i + 4u], vis_page_11[i + 5u]));
}
case 12u: {
return VisQuad(array(vis_page_12[i], vis_page_12[i + 1u], vis_page_12[i + 2u],
vis_page_12[i + 3u], vis_page_12[i + 4u], vis_page_12[i + 5u]));
}
case 13u: {
return VisQuad(array(vis_page_13[i], vis_page_13[i + 1u], vis_page_13[i + 2u],
vis_page_13[i + 3u], vis_page_13[i + 4u], vis_page_13[i + 5u]));
}
case 14u: {
return VisQuad(array(vis_page_14[i], vis_page_14[i + 1u], vis_page_14[i + 2u],
vis_page_14[i + 3u], vis_page_14[i + 4u], vis_page_14[i + 5u]));
}
default: {
return VisQuad(array(vis_page_15[i], vis_page_15[i + 1u], vis_page_15[i + 2u],
vis_page_15[i + 3u], vis_page_15[i + 4u], vis_page_15[i + 5u]));
}
}
}
struct VisResolveOut {
@builtin(position) clip: vec4<f32>,
// The group of 16 pages this draw resolves: its instance (`Visbuffer::resolve`).
@location(0) @interpolate(flat) chunk: u32,
}
@vertex fn vs_vis_resolve(@builtin(vertex_index) i: u32,
@builtin(instance_index) chunk: u32) -> VisResolveOut {
let p = vec2(f32(i32(i & 1u) * 4 - 1), f32(i32(i >> 1u) * 4 - 1));
var out: VisResolveOut;
out.clip = vec4(p, 0.0, 1.0);
out.chunk = chunk;
return out;
}
// A framebuffer position's NDC (the world pass draws to the whole target).
fn vis_ndc(at: vec2<f32>, size: vec2<f32>) -> vec2<f32> {
return vec2(at.x / size.x * 2.0 - 1.0, 1.0 - at.y / size.y * 2.0);
}
// Perspective-correct barycentrics of the point at NDC `p` on the triangle with clip-space
// corners a, b, c (x, y, w): homogeneous 2D rasterisation (Olano and Greer). With M the matrix
// of the corners as columns, the weights are M^-1 (p, 1) normalised, and the rows of M^-1 are
// the corners' cross products over the determinant, which the normalisation cancels. Corners
// behind the camera need no clipping. The rows are given (`vis_rows`), as three points share
// them.
fn vis_bary(rows: mat3x3<f32>, p: vec2<f32>) -> vec3<f32> {
let l = vec3(p, 1.0) * rows;
let sum = l.x + l.y + l.z;
if abs(sum) < 1e-30 {
return vec3(-1.0);
}
return l / sum;
}
fn vis_rows(a: vec3<f32>, b: vec3<f32>, c: vec3<f32>) -> mat3x3<f32> {
return mat3x3(cross(b, c), cross(c, a), cross(a, b));
}
// A corner's `terrain_vertex` from its six words: position, UV, layer and light, colour (the
// vertex attributes `TERRAIN_ATTRIBUTES` in gfx.rs give: Float32x3, Uint16x2, Uint16, Uint8x2
// with sky in the low byte, Unorm8x4).
fn vis_corner(position: vec3<u32>, rest: vec3<u32>, origin: vec4<f32>) -> TerrainOut {
let uv = vec2(rest.x & 0xffffu, rest.x >> 16u);
let light = vec2((rest.y >> 16u) & 0xffu, rest.y >> 24u);
return terrain_vertex(bitcast<vec3<f32>>(position), terrain_uv(uv, origin.w),
rest.y & 0xffffu, unpack4x8unorm(rest.z), light, origin.xyz, origin.w);
}
@fragment fn fs_vis_resolve(in: VisResolveOut) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(floor(in.clip.xy));
let id = textureLoad(vis_ids, pixel, 0).x;
let page = (id >> 24u) & 0x3fu;
if (id & 0x80000000u) == 0u || page / 16u != in.chunk {
discard;
}
let slot = page % 16u;
let index = id & 0xffffffu;
// The quad's first vertex (four of them from a multiple of 4) or its record.
var first = 4u * index;
var record = false;
var lo = vec3(0u);
var hi = vec3(0u);
if (id & 0x40000000u) != 0u {
first = index;
// The record's six words from word 6 * index: two 16-byte words from the one it
// starts in, at its start or its middle.
let w = vis_quad(slot, (6u * index) >> 2u).q;
let middle = ((6u * index) & 3u) != 0u;
lo = select(w[0].xyz, vec3(w[0].zw, w[1].x), middle);
hi = select(vec3(w[0].w, w[1].xy), w[1].yzw, middle);
record = (hi.z & 0x80000000u) == 0u;
if !record {
// `packed::FULL`: its four vertices, word 0 slots on.
first = index + lo.x;
}
}
// The quad's block (256 slots: 64 quads of vertices, 256 records) names its section, as
// for `vs_section_flat`.
let block = select(index >> 6u, index >> 8u, (id & 0x40000000u) != 0u);
let origin = slot_origins[block_slots[page * VIS_BLOCKS_PER_PAGE + block]];
var p0: vec3<u32>;
var r0: vec3<u32>;
var p1: vec3<u32>;
var r1: vec3<u32>;
var p2: vec3<u32>;
var r2: vec3<u32>;
var p3: vec3<u32>;
var r3: vec3<u32>;
if record {
let c0 = packed_corner(lo, hi, 0u);
let c1 = packed_corner(lo, hi, 1u);
let c2 = packed_corner(lo, hi, 2u);
let c3 = packed_corner(lo, hi, 3u);
p0 = c0.p;
r0 = c0.r;
p1 = c1.p;
r1 = c1.r;
p2 = c2.p;
r2 = c2.r;
p3 = c3.p;
r3 = c3.r;
} else {
// Corner k is words 6k to 6k + 5 of the quad's 24.
let words = vis_quad(slot, 6u * (first >> 2u)).q;
let q0 = words[0];
let q1 = words[1];
let q2 = words[2];
let q3 = words[3];
let q4 = words[4];
let q5 = words[5];
p0 = q0.xyz;
r0 = vec3(q0.w, q1.x, q1.y);
p1 = vec3(q1.z, q1.w, q2.x);
r1 = q2.yzw;
p2 = q3.xyz;
r2 = vec3(q3.w, q4.x, q4.y);
p3 = vec3(q4.z, q4.w, q5.x);
r3 = q5.yzw;
}
let c0 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p0) + origin.xyz, 1.0)).xyw;
let c1 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p1) + origin.xyz, 1.0)).xyw;
let c2 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p2) + origin.xyz, 1.0)).xyw;
let c3 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p3) + origin.xyz, 1.0)).xyw;
// The quad's triangles are 0 1 2 and 0 2 3 (`Renderer::quad_indices`, flatten.wgsl),
// either side of the diagonal 0 2. Corner 1's weight in 0 1 2 is `vis_bary`'s row
// cross(c2, c0) over the sum of the rows, whose sign is that of the triangle's determinant
// (the sum is one over the point's w), dot(c1, cross(c2, c0)): the pixel lies in 0 1 2 when
// the two agree. A degenerate 0 1 2 (no area, no pixels) leaves it to 0 2 3.
let size = vec2<f32>(textureDimensions(vis_ids));
let centre = in.clip.xy;
let p = vis_ndc(centre, size);
let diagonal = cross(c2, c0);
let det = dot(c1, diagonal);
let second = dot(vec3(p, 1.0), diagonal) * det < 0.0 || det == 0.0;
let rows = vis_rows(c0, select(c1, c2, second), select(c2, c3, second));
let b = vis_bary(rows, p);
// dpdxFine and dpdyFine: the difference to the other pixel of this one's 2x2 quad in its
// row and column, odd minus even, over the same triangle's plane.
let odd = vec2<f32>(pixel & vec2(1));
let toward = vec2(1.0) - 2.0 * odd;
let bx = vis_bary(rows, vis_ndc(centre + vec2(toward.x, 0.0), size));
let by = vis_bary(rows, vis_ndc(centre + vec2(0.0, toward.y), size));
// `terrain_vertex` on the triangle's corners, one at a time into the sums, so that only
// one corner's outputs are held at once; the first is the provoking one, whose flat
// outputs (layer, emission, fade, emissive layer) the rasteriser keeps.
var t = vis_corner(p0, r0, origin);
let uv0 = t.uv;
t.uv = b.x * uv0;
var uv_x = bx.x * uv0;
var uv_y = by.x * uv0;
t.color = b.x * t.color;
t.spherical = b.x * t.spherical;
t.cylindrical = b.x * t.cylindrical;
t.visibility = b.x * t.visibility;
let t1 = vis_corner(select(p1, p2, second), select(r1, r2, second), origin);
t.uv += b.y * t1.uv;
uv_x += bx.y * t1.uv;
uv_y += by.y * t1.uv;
t.color += b.y * t1.color;
t.spherical += b.y * t1.spherical;
t.cylindrical += b.y * t1.cylindrical;
t.visibility += b.y * t1.visibility;
let t2 = vis_corner(select(p2, p3, second), select(r2, r3, second), origin);
t.uv += b.z * t2.uv;
uv_x += bx.z * t2.uv;
uv_y += by.z * t2.uv;
t.color += b.z * t2.color;
t.spherical += b.z * t2.spherical;
t.cylindrical += b.z * t2.cylindrical;
t.visibility += b.z * t2.visibility;
let du = (uv_x - t.uv) * toward.x;
let dv = (uv_y - t.uv) * toward.y;
// HDR output alone reads the light levels and the face's normal (as in `fs_opaque`):
// `terrain_vertex` gives them as the block light over 16 and the sky light over 16, and the
// section-relative position.
if globals.hdr_light.z != 0.0 {
let local = array(bitcast<vec3<f32>>(p0), bitcast<vec3<f32>>(select(p1, p2, second)),
bitcast<vec3<f32>>(select(p2, p3, second)));
// `t.scene` is still the first corner's.
t.scene = b.x * t.scene + b.y * t1.scene + b.z * t2.scene;
t.local = b.x * local[0] + b.y * local[1] + b.z * local[2];
let dlx = (bx.x * local[0] + bx.y * local[1] + bx.z * local[2] - t.local) * toward.x;
let dly = (by.x * local[0] + by.y * local[1] + by.z * local[2] - t.local) * toward.y;
hdr_normal = normalize(cross(dly, dlx));
}
// `fs_opaque` and `solid_color` past their discards, which pass 1 already made.
let texel = vis_sprite(t.uv, t.layer & 0x7fffu, du, dv);
var color = texel * t.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, t.visibility);
return solid_finish_at(t, texel, color, du, dv);
}
@@ -386,6 +386,7 @@ impl World {
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
})
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.chunks
.get(&(x >> 4, z >> 4))
@@ -0,0 +1,482 @@
{
"horizon.options.controller": "Beheerder",
"horizon.options.vibration": "Vibrasie",
"horizon.options.adaptive_triggers": "Aanpasbare snellers",
"horizon.options.lightbar": "Ligbalk",
"horizon.options.look_sensitivity": "Kyksensitiwiteit",
"horizon.options.invert_x": "Omgekeerde kyk X",
"horizon.options.invert_y": "Omgekeerde kyk Y",
"horizon.options.sneak_button": "Sluipknoppie",
"horizon.options.move_deadzone": "Bewegingsdooiesone",
"horizon.options.look_deadzone": "Kykdooiesone",
"horizon.options.button_glyphs": "Knoppie Glyphs",
"horizon.options.menu_navigation": "Kieslysnavigasie",
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
"horizon.options.chat_delay": "Chat Vertraging",
"horizon.options.lightbar.health": "Gesondheid",
"horizon.options.lightbar.dimension": "Dimensie",
"horizon.options.glyphs.auto": "Outo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wyser",
"horizon.options.keyboard.auto": "Outo",
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"horizon.options.search": "Soek instellings...",
"horizon.pages.shader_packs": "Shader Pakke",
"horizon.pages.shader_options": "Shader Opsies",
"horizon.pages.controller": "Beheerderinstellings",
"horizon.pages.controller_binds": "Kontroleerder bind",
"horizon.pages.extras": "Horizon-ekstras",
"horizon.pages.settings": "Horizon-instellings",
"horizon.pages.agent": "KI Agent",
"horizon.split.leave": "Los",
"horizon.pages.split_settings": "Verdeelskerm-instellings",
"horizon.pages.split_profiles": "Wie speel?",
"horizon.pages.split_setup": "Verdeelskerm-opstelling",
"horizon.pages.music": "Musiek",
"horizon.pages.music.menu": "Spyskaart",
"horizon.pages.music.overworld": "Oorwêreld",
"horizon.pages.music.creative": "Kreatief",
"horizon.pages.music.underwater": "Onderwater",
"horizon.pages.music.end": "Die Einde",
"horizon.pages.music.boss": "Baas",
"horizon.pages.music.credits": "Krediete",
"horizon.pages.music.overworld_biomes": "Oorwêreld biome",
"horizon.pages.music.nether_biomes": "Onder biome",
"horizon.pages.music.end_biomes": "Eindig biome",
"horizon.pages.music.other": "Ander musiek",
"horizon.pages.music.empty": "Geen musieksnitte nie: die speletjie se klanke is nie gelaai nie.",
"horizon.pages.music.tooltip": "Die ingeboude snitte, volgens situasie: speel nou een, of skakel enige af sodat die speletjie ophou om dit te kies.",
"horizon.pages.settings.tooltip": "Horizon se eie instellings: grafika, beheerders, ekstras en mods.",
"horizon.pages.graphics.tooltip": "Grafika, eenvoudig: elke instelling sê wat dit kos.",
"horizon.pages.graphics_advanced.tooltip": "Elke grafiese instelling, Horizon s'n ingesluit, met wat elkeen kos.",
"horizon.pages.controller.tooltip": "Beheerders: hul knoppies, stokke, vibrasie en spyskaartnavigasie.",
"horizon.pages.extras.tooltip": "Opsionele toevoegings, alles by verstek af: die speletjie bly vanielje totdat jy een aanskakel.",
"horizon.pages.agent.tooltip": "Laat jou eie KI-agent speel: horizon-ctl en MCP-kliënte op hierdie rekenaar.",
"horizon.pages.split_profiles.tooltip": "Wie speel: 'n profiel wat in hierdie speletjie-lêergids gehou word.",
"horizon.pages.split_setup.tooltip": "Die vensters en monitors, en wie speel waar.",
"horizon.pages.shader_packs.tooltip": "Shader-pakke vanaf die shaderpacks-lêergids: Horizon se eie en dié in die shaders/formaat. Een hardloop op 'n slag.",
"horizon.pages.mods.tooltip": "Mods gemaak vir Horizon, vanaf die horizon-mods-lêergids. Elkeen loop in 'n sandbox en bly af totdat jy dit aanskakel.",
"horizon.pages.open.graphics": "Grafika...",
"horizon.pages.open.graphics_advanced": "Gevorderde grafika...",
"horizon.pages.open.shader_packs": "Shader-pakke...",
"horizon.pages.open.controller": "Beheerderinstellings...",
"horizon.pages.open.split_setup": "Verdeelskerm...",
"horizon.pages.open.extras": "Ekstras...",
"horizon.pages.open.mods": "Modusse...",
"horizon.pages.open.agent": "KI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon-grafika...",
"horizon.pages.open.controller_binds": "Beheerder bind...",
"horizon.pages.open.horizon_settings": "Horizon-instellings...",
"horizon.pages.open.music": "Musiek...",
"horizon.mods.empty": "Geen mods: plaas .hmod-lêers in die horizon-mods-lêergids",
"horizon.mods.stopped": " (Gestop)",
"horizon.mods.not_built": " (Nie gebou nie)",
"horizon.mods.dev": " (Ontwikkelaar)",
"horizon.mods.settings": "Instellings",
"horizon.mods.in_world": " (los die wêreld om te verander)",
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
"horizon.shader_packs.empty": "Geen shader packs: plaas dit in die shaderpacks-lêergids",
"horizon.shader_options.open": "Shader-opsies...",
"horizon.shader_options.profile": "Profiel",
"horizon.shader_options.loading": "Lees tans die shader-pakket se opsies...",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
"horizon.multiplayer.cancel_reconnecting": "Kanselleer Herkoppel",
"horizon.multiplayer.join_friend": "Sluit aan by 'n Vriend",
"horizon.multiplayer.invite_code": "Uitnodigingskode",
"horizon.multiplayer.relay": "Aflos (opsioneel)",
"Preset": "Vooraf ingesteld",
"LOD Distance": "LOD Afstand",
"Far Detail": "Verre Detail",
"Cave Culling": "Grotuitdunning",
"Entity Culling": "Entiteit Uitdunning",
"Upload Budget": "Laai begroting op",
"Sky": "Hemel",
"Fog": "Mis",
"Weather": "Weer",
"Animated Textures": "Geanimeerde teksture",
"Present Mode": "Aanbiedingsmodus",
"Frame Pacing": "Raamtempo",
"Worker Threads": "Werkerdrade",
"Resolution Scale": "Resolusieskaal",
"Upscaling": "Opskaling",
"Paper White": "Papier Wit",
"Peak Brightness": "Piek helderheid",
"Black Level": "Swart vlak",
"UI Brightness": "UI Helderheid",
"Battery Mode": "Batterymodus",
"Graphics API": "Grafiese API",
"Translucent Batching": "Deurskynende Batching",
"Performance Overlay": "Prestasie-oorleg",
"Full Detail Distance": "Volledige detail Afstand",
"On": "Aan",
"Off": "Af",
"Unlimited": "Onbeperk",
"Auto": "Outo",
"Custom": "Pasgemaak",
"Performance": "Prestasie",
"Balanced": "Gebalanseerd",
"Quality": "Kwaliteit",
"Ultra Performance": "Ultra prestasie",
"Native": "Inheems",
"Full": "Vol",
"Reduced": "Verminder",
"Efficient": "Doeltreffend",
"Precise": "Presies",
"Auto (VSync)": "Outo (VSync)",
"FIFO": "EIEU",
"Mailbox": "Posbus",
"Immediate": "Onmiddellik",
"FPS and Graph": "FPS en grafiek",
"No cost": "Geen koste nie",
"Low cost": "Lae koste",
"Medium cost": "Medium koste",
"High cost": "Hoë koste",
"Saves time": "Spaar tyd",
"Graphics": "Grafika",
"Done": "Klaar",
"This feature is not yet available in Horizon.": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"Your display or system doesn't offer HDR output.": "Jou skerm of stelsel bied nie HDR-uitvoer nie.",
"Turn HDR on to use it.": "Skakel HDR aan om dit te gebruik.",
"Your GPU or graphics API doesn't support it.": "Jou GPU of grafiese API ondersteun dit nie.",
"%s chunks": "%s stukke",
"%s per frame": "%s per raam",
"%s MiB/frame": "%s MiB/raam",
"%s KiB/frame": "%s KiB/raam",
"Auto (%s)": "Outo (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Volledige detail terreinreeks. LOD Distance kan plaaslike wêrelde verder uitbrei.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Growwe verre terrein in plaaslike wêrelde. Geïntegreerde GPU's stop by 128 stukke.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Verminder trek veel terrein growwer: vinniger op hierdie geïntegreerde GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Verminder trek veel terrein growwer, vir GPU's word dit stadiger.",
"Field of view. Wider shows more terrain at once.": "Gesigsveld. Wyer wys meer terrein op een slag.",
"Caps frames per second; a cap saves power and heat.": "Caps rame per sekonde; 'n pet spaar krag en hitte.",
"Waits for the display's refresh: no tearing, a little latency.": "Wag vir die verversing van die skerm: geen skeur nie, 'n bietjie vertraging.",
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Slaan terrein oor wat agter geslote gedeeltes versteek is. Af kos baie.",
"Hides entities behind opaque blocks.": "Versteek entiteite agter ondeursigtige blokke.",
"No entity occlusion culling.": "Geen entiteit-okklusion uitskot nie.",
"CPU occlusion test runs this frame.": "SVE-okklusietoets loop hierdie raam.",
"Occlusion checks run on a worker thread.": "Okklusiekontroles loop op 'n werkersdraad.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Maasafdelings per raam opgelaai. Laer is gladder tydens laai.",
"Soft shadows in corners. Costs chunk building time only.": "Sagte skaduwees in hoeke. Dit kos slegs 'n stuk boutyd.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleiner kopieë van blokteksture vir verafgeleë terrein: minder glans.",
"Blends grass and water colours across biomes when building chunks.": "Vermeng gras en waterkleure oor biome wanneer stukke gebou word.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolklaag: af, plat (vinnig) of 3D (fancy).",
"How far clouds reach, in chunks.": "Hoe ver wolke reik, in stukke.",
"Draws the sky, sun, moon and stars.": "Teken die lug, son, maan en sterre.",
"Distance fog. Off shows the edge of the loaded world.": "Afstand mis. Af wys die rand van die gelaaide wêreld.",
"Draws rain and snow.": "Trek reën en sneeu.",
"How far rain and snow fall around you.": "Hoe ver val reën en sneeu om jou.",
"How many particles effects spawn.": "Hoeveel deeltjies effekte kuit.",
"Round shadows under mobs and items.": "Ronde skaduwees onder skares en items.",
"Leaves with see-through gaps. Off draws them solid.": "Blare met deursigtige gapings. Off trek hulle stewig.",
"Sorts water and glass more exactly behind each other.": "Sorteer water en glas meer presies agter mekaar.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Watter stuk herbou wag vir die raam: geen, naby of joune.",
"Animates water, lava, fire and other moving textures.": "Animeer water, lawa, vuur en ander bewegende teksture.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Wêreldpixels per skermpiksel. Onder 100% is vinniger op die GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Maak gekartelde rande glad. FXAA: sagter. TAA: gladste, bewegende dinge kan loop.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Maak die wêreld kleiner en herbou dit met TAA. Vinniger, sagter.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-uitset op 'n HDR-skerm: lawa, vuur en sonlig skyn helderder.",
"How frames reach the screen while VSync is off.": "Hoe rame die skerm bereik terwyl VSync af is.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Doeltreffende slaap tot die volgende raam; Presiese draaie, soos vanielje.",
"Fills the screen (borderless).": "Vul die skerm (randloos).",
"Brightens dark places.": "Verhelder donker plekke.",
"Lowers the frame rate when minimised, or idle too.": "Verlaag die raamtempo wanneer dit geminimaliseer word, of ook ledig.",
"Sways the view while walking.": "Swaai die uitsig terwyl jy stap.",
"Fades newly loaded chunks in.": "Vervaag nuutgelaaide stukke in.",
"Darkens the screen's edges.": "Maak die skerm se rande donkerder.",
"Shows when the world saves.": "Wys wanneer die wêreld red.",
"Shows attack cooldown.": "Toon aanvalafkoeling.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS pet (30 in spyskaarte of ongefokus), die helfte van die werkers: minder krag.",
"Threads that generate, light and mesh chunks. Next start.": "Drade wat stukke genereer, lig en maas. Volgende begin.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, dan DirectX 12 (Windows) of OpenGL (Linux) waar dit misluk. Volgende begin.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Water en glas in een buffer: minder trekoproepe, 'n bietjie meer VRAM. Volgende begin.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Maasdata per raam na die GPU gestuur. Laer is gladder tydens laai.",
"Frame rate and frame times over the game, with a graph.": "Raamtempo en raamtye oor die spel, met 'n grafiek.",
"horizon.multiplayer.finding_friend": "Soek jou vriend se wêreld...",
"horizon.multiplayer.not_signed_in": "Hierdie bediener is in aanlynmodus. Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om daarby aan te sluit.",
"horizon.multiplayer.invalid_address": "Ongeldige bedieneradres: %s",
"horizon.multiplayer.connected_friend_direct": "Reguit gekoppel aan jou vriend...",
"horizon.multiplayer.connected_friend_relay": "Gekoppel aan jou vriend deur die aflos...",
"General": "Algemeen",
"Advanced": "Gevorderd",
"Undo": "Ontdoen",
"Search options": "Soek opsies",
"Shortcut": "Kortpad",
"Distance": "Afstand",
"Display": "Vertoon",
"Interface": "Koppelvlak",
"Sky and weather": "Lug en weer",
"Lighting and entities": "Beligting en entiteite",
"Textures": "Teksture",
"Image": "Beeld",
"Chunk loading": "Chunk laai",
"Culling": "Uitskot",
"Power": "Krag",
"Frames": "Rame",
"Overlay": "Oorleg",
"Output": "Uitset",
"Brightness": "Helderheid",
"How far you see, the window and the HUD.": "Hoe ver jy sien, die venster en die HUD.",
"The sky and weather, lighting, textures and the image.": "Die lug en weer, beligting, teksture en die beeld.",
"Chunk loading, culling and power use.": "Chunk laai, uitskot en kraggebruik.",
"How frames reach the screen, and the performance overlay.": "Hoe rame die skerm bereik, en die prestasie-oorleg.",
"Brighter-than-white output on an HDR display.": "Helderder-as-wit uitset op 'n HDR-skerm.",
"Results for \"%s\"": "Resultate vir \"%s\"",
"No settings match.": "Geen instellings pas nie.",
"horizon.title.not_affiliated": "Nie geaffilieer met Mojang of Microsoft nie",
"horizon.extras.header.lighting": "Beligting",
"horizon.extras.header.third_person": "Derde persoon",
"horizon.extras.header.particles": "Deeltjies",
"horizon.extras.header.inventory": "Voorraad",
"horizon.extras.header.chat": "Gesels",
"horizon.extras.header.multiplayer": "Multispeler",
"horizon.extras.header.sound": "Klank",
"horizon.extras.header.menus": "Spyskaarte",
"horizon.extras.fullbright": "Vol helder",
"horizon.extras.dynamic_lights": "Dinamiese ligte",
"horizon.extras.own_name_tag": "Eie naamplaatjie",
"horizon.extras.hide_block_particles": "Versteek blokbreekdeeltjies",
"horizon.extras.auto_reconnect": "Herkoppel outomaties",
"horizon.extras.ui_sound_reserve": "Hou kanale vir UI-klanke",
"horizon.extras.music_page": "Musiek bladsy",
"horizon.extras.mouse_tweaks": "Muis Tweaks kontroles",
"horizon.extras.mouse_tweaks_wheel": "Rolwiel beweeg items",
"horizon.extras.sort": "Sorteer knoppies",
"horizon.extras.sort_key": "Sorteer sleutel",
"horizon.extras.no_ai": "Geen KI",
"horizon.extras.agent_control": "Agent beheer",
"horizon.extras.discord_presence": "Ryk teenwoordigheid op Discord",
"horizon.extras.splitscreen": "Verdeelskerm",
"horizon.extras.chat_animation": "Glad gesels",
"horizon.extras.chat_indicator": "Boodskap aanwyser",
"horizon.extras.wheel_cycles_settings": "Rolwiel verander instellings",
"horizon.extras.pack_browser": "Hulpbronpakketblaaier",
"horizon.extras.fullbright.tooltip": "Wys blokke en entiteite in volle lig, selfs in donker areas.",
"horizon.extras.dynamic_lights.tooltip": "Fakkels en ander ligbronne wat vasgehou, laat val word of aan die brand steek, steek die blokke om hulle aan.",
"horizon.extras.own_name_tag.tooltip": "Wys jou naam bo jou kop in derde persoon, soos ander spelers dit sien. Versteek wanneer die bediener jou span se name versteek of jy onsigbaar is.",
"horizon.extras.hide_block_particles.tooltip": "Versteek die puindeeltjies wat uit 'n gebreekte blok bars en verkrummel van die een wat jy myn. Ander deeltjies bly.",
"horizon.extras.auto_reconnect.tooltip": "Sluit weer by 'n bediener aan na 'n verlore verbinding of 'n herbegin, nooit na 'n skop of verbod nie.",
"horizon.extras.ui_sound_reserve.tooltip": "Klanke in die wêreld laat 16 van die 247 klankkanale vry, so 'n vloed van hulle ('n groot ontploffing) kan nie kieslysklikke stilmaak nie.",
"horizon.extras.music_page.tooltip": "Musiek en Klanke kry 'n Musiek...-bladsy: speel nou enige ingeboude snit, of skakel een af sodat die speletjie ophou om dit te kies. Niks verander totdat jy 'n snit afskakel nie.",
"horizon.extras.mouse_tweaks.tooltip": "Regs-sleep plaas een per gleuf, links-sleep versamel die gehou item, Shift + links-sleep skuif elke gleuf.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Af stuur een item van die gleuf na die ander kant, op bring een terug.",
"horizon.extras.sort.tooltip": "Sorteer die voorraad, kiste, vate, eindkiste en shulkerbokse in kreatiewe oortjievolgorde, en voeg stapels saam.",
"horizon.extras.sort_key.tooltip": "Sorteer die deel onder die wyser. Klik en druk dan 'n sleutel; Ontvlugting ontbind dit.",
"horizon.extras.no_ai.tooltip": "Versteek en skakel elke KI-kenmerk af: agentbeheer, horizon-ctl en die MCP-bedieners. Die lanseerder se skakelaar ook.",
"horizon.extras.agent_control.tooltip": "Jou eie KI-agent kan hierdie speletjie deur horizon-ctl of 'n MCP-kliënt speel, slegs op hierdie rekenaar.",
"horizon.extras.discord_presence.tooltip": "Wys Playing Horizon op jou Discord-profiel terwyl Discord loop: spyskaarte, enkelspeler of multispeler, die dimensie en die speeltyd. Nooit 'n wêreld se naam of 'n bediener se adres nie.",
"Calibrate...": "Kalibreer...",
"Calibrate Anyway...": "Kalibreer in elk geval...",
"Use Automatic Setup": "Gebruik outomatiese opstelling",
"Shortcut: %s": "Kortpad: %s",
"Next": "Volgende",
"HDR Calibration %s/%s: %s": "HDR-kalibrasie %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Stel die drie hieronder volskerm, een toetsprent op 'n slag.",
"A key that opens this screen from the game. Not bound at first.": "'n Sleutel wat hierdie skerm vanaf die speletjie oopmaak. Eers nie gebonde nie.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Jou skerm het nie gesê hoe helder dit word nie: kalibreer om dit op te stel.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Reeds opgestel vir jou vertoning. Kalibreer slegs as HDR verkeerd lyk.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Plaas Peak Brightness en Paper White terug na jou skerm se opstelling.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Stel op vir jou vertoning. Los op Auto tensy dit verkeerd lyk.",
"Lower it until the logo just disappears into the black.": "Laat sak dit totdat die logo net in die swart verdwyn.",
"Raise it until the logo just disappears into the bright square.": "Lig dit op totdat die logo net in die helder vierkant verdwyn.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Maak die kaart 'n wit wat gemaklik is om lank na te kyk, in die lig waarin jy speel.",
"horizon.splitscreen.profiles.forget": "Vergeet",
"horizon.splitscreen.profiles.create": "Skep",
"horizon.splitscreen.profiles.you": " - jy",
"horizon.splitscreen.profiles.forget_named": "Vergeet %s",
"horizon.splitscreen.profiles.previous": "Vorige",
"horizon.splitscreen.profiles.next": "Volgende",
"horizon.splitscreen.profiles.new_button": "Nuwe profiel...",
"horizon.splitscreen.profiles.forget_title": "Vergeet profiel?",
"horizon.splitscreen.profiles.forget_leaves": "Dit verlaat hierdie instansie se lys.",
"horizon.splitscreen.profiles.forget_kept": "Wêreldvordering en instellings word behou.",
"horizon.splitscreen.profiles.new_title": "Nuwe profiel",
"horizon.splitscreen.profiles.name_hint": "Naam (3 tot 16 letters, syfers, _)",
"horizon.splitscreen.profiles.switch_title": "Wissel profiel",
"horizon.splitscreen.profiles.offline_note": "Vanlyn profiele behoort aan hierdie geval",
"horizon.splitscreen.profiles.move": "Beweeg waar jy speel",
"horizon.splitscreen.profiles.move_place": "Beweeg waar jy speel: %s",
"horizon.friends.title": "Speel met vriende",
"horizon.friends.hint": "Vriende plak die kode na die titelskerm se Vriende-knoppie, in Direkte verbinding, of op die lanseerder se Speel-bladsy.",
"horizon.friends.copied": "Gekopieer",
"horizon.friends.copy_code": "Kopieer kode",
"horizon.friends.stop_sharing": "Hou op deel",
"horizon.friends.none_yet": "Nog geen vriende hier nie.",
"horizon.friends.here_now": "Hier nou: %s",
"horizon.friends.share_intro": "Deel hierdie wêreld agter 'n uitnodigingskode: dit maak oop na LAN, en vriende op die internet sluit aan met die kode. Geen bediener van ons is betrokke nie.",
"horizon.friends.start_sharing": "Begin deel",
"horizon.friends.troubleshooting": "Foutsporing …",
"horizon.friends.stopped": "Het opgehou om die uitnodigingkode te deel.",
"horizon.friends.short_code_changed": "Iemand het aanhou om 'n verkeerde kort kode te probeer, so dit het verander.",
"horizon.friends.wrong_code": "Iemand het 'n verkeerde kode probeer.",
"horizon.friends.code_ready": "Jou uitnodigingskode is gereed: Speletjie-kieslys, Speel met vriende. Kopieer dit en stuur dit aan jou vriende.",
"horizon.friends.share_failed": "Kon nie die wêreld deel nie: %s",
"horizon.friends.finding_route": "Vind tans hoe vriende jou kan bereik …",
"horizon.osk.shift": "Verskuiwing",
"horizon.osk.space": "Ruimte",
"horizon.osk.delete": "Vee uit",
"horizon.osk.type": "Tik",
"horizon.osk.symbols": "Simbole",
"horizon.osk.close": "Maak toe",
"horizon.extras.chat_animation.tooltip": "'n Nuwe kletslyn gly van links af terwyl dit vervaag, in plaas daarvan om heel te lyk.",
"horizon.extras.chat_indicator.tooltip": "Die gekleurde balk aan die linkerkant van 'n kletslyn.",
"horizon.extras.wheel_cycles_settings.tooltip": "Die rolwiel oor 'n knoppie in die instellings verander die waarde daarvan, soos in vanielje. Af, die wiel blaai net deur die lys.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs kry 'n Voeg by-knoppie wat Modrinth deursoek en 'n pakket in jou pakke-lêergids installeer. Af, die speletjie kontak nooit Modrinth nie.",
"horizon.extras.splitscreen.tooltip": "In 'n wêreld kan 'n ander beheerder as 'n nuwe speler aansluit: druk A wanneer dit vra, kies dan 'n profiel en waar om te speel.",
"horizon.splitscreen.setup.monitor_unknown": "'n monitor",
"horizon.splitscreen.setup.window": "Venster %s",
"horizon.splitscreen.setup.new_window": "Nuwe venster",
"horizon.splitscreen.setup.fill_monitor": "Vul %s",
"horizon.splitscreen.setup.done": "Klaar",
"horizon.splitscreen.setup.on": "Aan",
"horizon.splitscreen.setup.off": "Af",
"horizon.splitscreen.setup.player": "Speler %s",
"horizon.splitscreen.setup.revert": "Keer terug",
"horizon.splitscreen.setup.apply": "Doen aansoek",
"horizon.splitscreen.setup.warning.empty_main": "Niemand speel in Venster 1 nie: dit skuil",
"horizon.splitscreen.setup.warning.empty_close": "Niemand speel in Venster %s nie: dit sluit op Toepassing",
"horizon.splitscreen.setup.warning.empty_new": "Niemand speel in Venster %s nie: dit sal nie oopmaak nie",
"horizon.splitscreen.setup.warning.on_main": "Venster %s vul Venster 1 se monitor: sy spelers speel in Venster 1",
"horizon.splitscreen.setup.warning.unplugged": "Venster %s se monitor is nie gekoppel nie: sy spelers speel in Venster 1",
"horizon.splitscreen.setup.status.off": "Verdeelskerm is af.",
"horizon.splitscreen.setup.status.enable_hint": "Skakel dit aan en druk dan 'n knoppie op 'n ander beheerder in 'n wêreld.",
"horizon.splitscreen.setup.status.join_hint": "Druk A om aan te sluit en kies 'n profiel.",
"horizon.splitscreen.setup.status.idle": "Gedeelskerm ledig",
"horizon.splitscreen.setup.status.alone": "Geen ander plaaslike spelers nie.",
"horizon.splitscreen.setup.status.solo_controls": "Solo-kontroles en kontroleerder-terugvoer is onveranderd.",
"horizon.splitscreen.setup.window_empty": "Venster %s: leeg",
"horizon.splitscreen.setup.note.select": "Klik 'n speler, of sleep dit na 'n venster of 'n monitor.",
"horizon.splitscreen.setup.note.apply": "Doen aansoek om op hierdie manier te speel, Keer terug om te ontdoen.",
"horizon.splitscreen.setup.note.place": "Waar moet %s speel?",
"horizon.splitscreen.setup.this_player": "hierdie speler",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen skakel af sodra die ander vertrek het (hul pousekieslys).",
"horizon.splitscreen.setup.window_place": "Venster %s (%s van %s)",
"horizon.splitscreen.setup.names": "Name",
"horizon.splitscreen.setup.identify": "Identifiseer",
"horizon.splitscreen.setup.layout.two": "2 deel: %s",
"horizon.splitscreen.setup.layout.three": "3 deel: %s",
"horizon.splitscreen.setup.layout.two.0": "Outo",
"horizon.splitscreen.setup.layout.two.1": "Sy aan sy",
"horizon.splitscreen.setup.layout.two.2": "Bo en onder",
"horizon.splitscreen.setup.layout.three.0": "Een breed bo-op",
"horizon.splitscreen.setup.layout.three.1": "Een lang aan die linkerkant",
"horizon.splitscreen.setup.layout.three.2": "Sy aan sy",
"horizon.splitscreen.setup.help.player": "Klik of sleep %s om hulle te skuif.",
"horizon.splitscreen.setup.help.layout_auto": "%s (naas mekaar wanneer die venster wyd is). Druk om te verander.",
"horizon.splitscreen.setup.help.layout": "%s. Druk om te verander.",
"horizon.splitscreen.setup.help.switch": "Verdeelskerm vir hierdie rekenaar, aan of af.",
"horizon.splitscreen.setup.help.identify": "Elke speler se aansig wys in watter venster dit is.",
"horizon.splitscreen.setup.help.names": "Elke speler se naam in die hoek van hul aansig.",
"horizon.splitscreen.setup.help.revert": "Ontdoen die veranderinge wat nog nie toegepas is nie.",
"horizon.splitscreen.setup.help.apply": "Speel soos die kaart wys.",
"horizon.splitscreen.setup.help.unchanged": "Niks het nog verander nie.",
"horizon.splitscreen.setup.give_up_pad": "Gee op Pad",
"horizon.splitscreen.prompt.controller_connected": "Beheerder gekoppel",
"horizon.splitscreen.prompt.controller_disconnected": "Beheerder ontkoppel",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Koppel dit weer, of druk 'n knoppie op 'n ander",
"horizon.splitscreen.prompt.join.one": "%s wil aansluit",
"horizon.splitscreen.prompt.join.many": "%s beheerders wil aansluit",
"horizon.splitscreen.prompt.join.yes": "Sluit aan",
"horizon.splitscreen.prompt.join.as_p1": "Speel as P1",
"horizon.splitscreen.prompt.join.no": "Nee",
"horizon.splitscreen.prompt.lost": "%s: beheerder ontkoppel",
"horizon.splitscreen.prompt.lost.use_another": "Gebruik 'n ander",
"horizon.splitscreen.prompt.lost.leave": "Los",
"horizon.splitscreen.setup.help.give_up_pad": "Jou beheerder hou op om jou te bestuur en word aangebied om aan te sluit (A).",
"horizon.splitscreen.setup.note.held_at": "%s tot %s: A laat val dit hier, B sit dit terug.",
"horizon.splitscreen.setup.note.held": "%s opgetel: links/regs kies waar, A laat val, B sit dit terug.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteek modelle agter ondeursigtige blokke. Async kontroleer af-draad; entiteite merk.",
"Block Entities": "Blok Entiteite",
"Reuses settled boxes; lid movement stays animated.": "Hergebruik afgewikkelde bokse; dekselbeweging bly geanimeerd.",
"Signs": "Tekens",
"Reuses sign text until text, light, or font changes.": "Hergebruik tekenteks totdat teks, lig of lettertipe verander.",
"Decorated Pots": "Versierde potte",
"Reuses still pots; break wobble and sherd changes redraw.": "Hergebruik stilpotte; breek wankel en skeur veranderinge herteken.",
"Bells": "Klokke",
"Reuses still bells; ringing stays animated.": "Hergebruik stilstaande klokke; lui bly geanimeerd.",
"Chests": "Kiste",
"Reuses closed chests; moving lids stay animated.": "Hergebruik toe kiste; bewegende deksels bly geanimeerd.",
"Banners": "Baniere",
"Reuses poles; flags and pattern layers still update.": "Hergebruik pale; vlae en patroonlae werk steeds by.",
"Copper Golem Statues": "Koper Golem-standbeelde",
"Reuses statue geometry until its block state changes.": "Hergebruik standbeeldgeometrie totdat die bloktoestand daarvan verander.",
"Leaf Culling": "Blaaruitsny",
"Every leaf face. Vanilla's fancy leaves.": "Elke blaar gesig. Vanielje se spoggerige blare.",
"Distant Plant Dither": "Verre Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Hou fyn plante in hoë-resolusie-pakke om ver weg te vervaag.",
"Screenshot Quality": "Skermkiekie Kwaliteit",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 skermkiekies teen hierdie baie keer die spel se resolusie. Hoër neem langer.",
"Frame Generation": "Raam Generasie",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Toon 'n opgemaakte raam tussen elke twee regte: gladder, 'n bietjie meer vertraging.",
"Shadow Cache": "Skadu-kas",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Hergebruik die pakskadukaart wanneer son, uitsig en wêreld ooreenstem. Af by verstek.",
"Find BlueMap": "Vind BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Op bedieners, vind hul BlueMap-webkaart vir ver terrein. Sluit volgende aan.",
"BlueMap Radius": "Blou Kaart Radius",
"How far around you BlueMap tiles are downloaded. Next join.": "Hoe ver om jou is BlueMap-teëls afgelaai. Sluit volgende aan.",
"Synchronous": "Sinchronies",
"Asynchronous": "Asinchronies",
"Solid": "Solied",
"Solid Aggressive": "Stewige aggressiewe",
"Hollow": "Hol",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blare volledig binne-in 'n blaredak trek solied: minder deurskynende lae.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Blare met een oop kant trek ook solied: vinniger, bonkiger rande.",
"Drops faces between leaves: faster, but gaps look through.": "Laat val gesigte tussen blare: vinniger, maar gapings kyk deur.",
"Hotbar: Previous Slot": "Hotbar: Vorige Slot",
"Hotbar: Next Slot": "Hotbar: Volgende Slot",
"horizon.agent.connected.one": "Gekoppel: 1 agentverbinding",
"horizon.agent.connected.many": "Gekoppel: %s agentverbindings",
"horizon.agent.connected.one_ago": "Gekoppel: 1 agentverbinding, laaste opdrag %s s gelede",
"horizon.agent.connected.many_ago": "Gekoppel: %s agentverbindings, laaste opdrag %s s gelede",
"horizon.agent.waiting": "Wag vir 'n agent.",
"horizon.agent.off": "Af: agente kan nie hierdie speletjie bereik nie.",
"horizon.agent.session_opened": "Geopen vir hierdie sessie deur die program wat die speletjie begin het.",
"horizon.agent.socket": "Sok: %s (kopieer die besonderhede vir sy volledige pad)",
"horizon.agent.local_only": "Slegs plaaslik: agente op hierdie rekenaar, nooit die netwerk nie.",
"%s nits": "%s nete",
"Auto (%s nits)": "Outo (%s nete)",
"%.2f nits": "%.2f nete",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteek modelle agter ondeursigtige blokke. Entiteite-opdatering. Af met pasgemaakte pakke.",
"horizon.extras.repro_keys": "Opname kortpaaie",
"horizon.extras.repro_keys.tooltip": "F3+R begin of stop 'n repro-opname en F3+E skryf sy bundel. Af by verstek: 'n opname kopieer die wêreld.",
"horizon.agent.record_session": "Begin opnamesessie",
"horizon.agent.record_stop": "Stop opnamesessie",
"horizon.agent.export_repro": "Voer Repro-bundel uit",
"horizon.repro.started": "Opname het begin; F3+R stop dit en skryf die bundel.",
"horizon.repro.stopped": "Opname gestop: %s",
"horizon.repro.bundle": "Repro-bundel: %s",
"horizon.repro.no_recording": "Daar is nog geen opname om uit te voer nie.",
"horizon.repro.replay_started": "Herhaling het begin: %s",
"horizon.pack.addInfo": "Voeg 'n hulpbronpakket van Modrinth by",
"horizon.pack.addWorking": "Vra vir Modrinth...",
"horizon.pack.addNone": "Geen hulpbronpakke gevind nie",
"horizon.pack.addTitle": "Voeg 'n hulpbronpakket by",
"horizon.pack.install": "Installeer",
"horizon.pack.installing": "Installeer tans",
"horizon.pack.installed": "Geïnstalleer",
"horizon.pack.search": "Soek",
"horizon.pack.searchHint": "Soek Modrinth...",
"horizon.pack.filter.realistic": "Realisties",
"horizon.pack.filter.simplistic": "Simplistiese",
"horizon.pack.filter.vanillaLike": "Vanielje-agtig",
"horizon.pack.addFailed": "Die pakkie kon nie bygevoeg word nie: %s",
"horizon.extras.header.cosmetics": "Skoonheidsmiddels",
"horizon.extras.cosmetics_mine": "My skoonheidsmiddels",
"horizon.extras.cosmetics_mine.tooltip": "Versteek: elke Horizon-speler sien jou sonder jou skoonheidsmiddels, jy ingesluit. Dieselfde skakelaar as die lanseerder se klerekas.",
"horizon.extras.cosmetics_others": "Ander spelers se skoonheidsmiddels",
"horizon.extras.cosmetics_others.tooltip": "Wat ander Horizon-spelers dra: alles, net hul naamplaatjiestyle, of niks. Versteek, die speletjie vra nooit wat hulle dra nie.",
"horizon.extras.cosmetics_auras": "Ander se aura's en roetes",
"horizon.extras.cosmetics_auras.tooltip": "Auras en roetes wat ander spelers dra. Af by verstek.",
"horizon.extras.cosmetics.shown": "Getoon",
"horizon.extras.cosmetics.hidden": "Versteek",
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies"
}
@@ -0,0 +1,501 @@
{
"horizon.options.controller": "المراقب المالي",
"horizon.options.vibration": "الاهتزاز",
"horizon.options.adaptive_triggers": "المشغلات التكيفية",
"horizon.options.lightbar": "الشريط الضوئي",
"horizon.options.look_sensitivity": "حساسية النظر",
"horizon.options.invert_x": "عكس محور النظر X",
"horizon.options.invert_y": "عكس محور النظر Y",
"horizon.options.sneak_button": "زر التسلل",
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
"horizon.options.menu_navigation": "التنقل في القائمة",
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
"horizon.options.chat_delay": "تأخير الدردشة",
"horizon.options.lightbar.health": "الصحة",
"horizon.options.lightbar.dimension": "البعد",
"horizon.options.glyphs.auto": "تلقائي",
"horizon.options.glyphs.xbox": "اكس بوكس",
"horizon.options.glyphs.playstation": "بلاي ستيشن",
"horizon.options.glyphs.nintendo": "نينتندو",
"horizon.options.menu_focus": "التركيز",
"horizon.options.menu_pointer": "المؤشر",
"horizon.options.keyboard.auto": "تلقائي",
"horizon.options.keyboard.steam": "البخار",
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
"horizon.options.search": "إعدادات البحث...",
"horizon.pages.mods": "تعديل",
"horizon.pages.shader_packs": "حزم شادر",
"horizon.pages.shader_options": "خيارات التظليل",
"horizon.pages.controller": "إعدادات وحدة التحكم",
"horizon.pages.controller_binds": "ربط وحدة التحكم",
"horizon.pages.extras": "إضافات Horizon",
"horizon.pages.settings": "إعدادات Horizon",
"horizon.pages.agent": "وكيل منظمة العفو الدولية",
"horizon.split.leave": "غادر",
"horizon.pages.split_settings": "إعدادات تقسيم الشاشة",
"horizon.pages.split_profiles": "من يلعب؟",
"horizon.pages.split_setup": "إعداد تقسيم الشاشة",
"horizon.pages.music": "موسيقى",
"horizon.pages.music.menu": "القائمة",
"horizon.pages.music.overworld": "العالم الخارجي",
"horizon.pages.music.creative": "إبداعي",
"horizon.pages.music.underwater": "تحت الماء",
"horizon.pages.music.end": "النهاية",
"horizon.pages.music.boss": "رئيس",
"horizon.pages.music.credits": "الاعتمادات",
"horizon.pages.music.overworld_biomes": "المناطق الأحيائية في العالم الخارجي",
"horizon.pages.music.nether_biomes": "المناطق الأحيائية السفلى",
"horizon.pages.music.end_biomes": "نهاية المناطق الأحيائية",
"horizon.pages.music.other": "موسيقى أخرى",
"horizon.pages.music.empty": "لا توجد مقطوعات موسيقية: لا يتم تحميل أصوات اللعبة.",
"horizon.pages.music.tooltip": "المسارات المضمنة، حسب الموقف: العب واحدة الآن، أو قم بإيقاف تشغيل أي منها حتى تتوقف اللعبة عن اختيارها.",
"horizon.pages.settings.tooltip": "إعدادات Horizon الخاصة: الرسومات ووحدات التحكم والإضافات والتعديلات.",
"horizon.pages.graphics.tooltip": "الرسومات ببساطة: كل إعداد يوضح تكلفته.",
"horizon.pages.graphics_advanced.tooltip": "كل إعدادات الرسومات، بما في ذلك Horizon، وتكلفة كل منها.",
"horizon.pages.controller.tooltip": "وحدات التحكم: الأزرار والعصي والاهتزاز والتنقل في القائمة.",
"horizon.pages.extras.tooltip": "الإضافات الاختيارية، كلها متوقفة بشكل افتراضي: تظل اللعبة عادية حتى تقوم بتشغيلها.",
"horizon.pages.agent.tooltip": "اسمح لوكيل الذكاء الاصطناعي الخاص بك باللعب: عملاء Horizon-ctl وMCP على هذا الكمبيوتر.",
"horizon.pages.split_profiles.tooltip": "من يلعب: ملف تعريف محفوظ في مجلد اللعبة هذا.",
"horizon.pages.split_setup.tooltip": "النوافذ والشاشات ومن يلعب أين.",
"horizon.pages.shader_packs.tooltip": "حزم Shader من مجلد Shaderpacks: الخاصة بشركة Horizon وتلك الموجودة في تنسيق التظليل/. واحد يعمل في وقت واحد.",
"horizon.pages.mods.tooltip": "التعديلات المعدة لـ Horizon، من مجلد Horizon-mods. يعمل كل منها في وضع الحماية ويظل مغلقًا حتى تقوم بتشغيله.",
"horizon.pages.open.graphics": "الرسومات...",
"horizon.pages.open.graphics_advanced": "رسومات متقدمة...",
"horizon.pages.open.shader_packs": "حزم التظليل...",
"horizon.pages.open.controller": "إعدادات وحدة التحكم...",
"horizon.pages.open.split_setup": "شاشة مقسمة...",
"horizon.pages.open.extras": "إضافات...",
"horizon.pages.open.mods": "تعديل...",
"horizon.pages.open.agent": "وكيل الذكاء الاصطناعي...",
"horizon.pages.open.horizon_graphics": "رسومات Horizon...",
"horizon.pages.open.controller_binds": "ربط وحدة التحكم...",
"horizon.pages.open.horizon_settings": "إعدادات Horizon...",
"horizon.pages.open.music": "موسيقى...",
"horizon.mods.empty": "لا توجد تعديلات: ضع ملفات .hmod في مجلد Horizon-mods",
"horizon.mods.stopped": " (متوقف)",
"horizon.mods.not_built": " (لم يتم بناؤه)",
"horizon.mods.dev": " (ديف)",
"horizon.mods.settings": "الإعدادات",
"horizon.mods.in_world": " (اترك العالم ليتغير)",
"horizon.mods.open_folder": "افتح مجلد التعديل",
"horizon.shader_packs.empty": "لا توجد حزم تظليل: ضعها في مجلدshaderpacks",
"horizon.shader_options.open": "خيارات التظليل...",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.shader_options.loading": "قراءة خيارات حزمة التظليل...",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
"horizon.multiplayer.cancel_reconnecting": "إلغاء إعادة الاتصال",
"horizon.multiplayer.join_friend": "انضم إلى صديق",
"horizon.multiplayer.invite_code": "رمز الدعوة",
"horizon.multiplayer.relay": "التتابع (اختياري)",
"Preset": "محددة مسبقا",
"LOD Distance": "مسافة اللد",
"Far Detail": "تفاصيل بعيدة",
"Cave Culling": "إعدام الكهف",
"Entity Culling": "إعدام الكيان",
"Chunk Updates": "تحديثات القطعة",
"Upload Budget": "تحميل الميزانية",
"Sky": "السماء",
"Fog": "ضباب",
"Weather": "الطقس",
"Animated Textures": "القوام المتحركة",
"Present Mode": "الوضع الحالي",
"Frame Pacing": "سرعة الإطار",
"Worker Threads": "خيوط العمال",
"Resolution Scale": "مقياس القرار",
"Anti-aliasing": "مكافحة التعرج",
"Upscaling": "الارتقاء",
"Paper White": "ورقة بيضاء",
"Peak Brightness": "ذروة السطوع",
"Black Level": "المستوى الأسود",
"UI Brightness": "سطوع واجهة المستخدم",
"Battery Mode": "وضع البطارية",
"Graphics API": "واجهة برمجة تطبيقات الرسومات",
"Translucent Batching": "الخلط الشفاف",
"Performance Overlay": "تراكب الأداء",
"Full Detail Distance": "مسافة التفاصيل الكاملة",
"On": "على",
"Off": "إيقاف",
"Unlimited": "غير محدود",
"Auto": "تلقائي",
"Custom": "مخصص",
"Performance": "الأداء",
"Balanced": "متوازن",
"Quality": "الجودة",
"Ultra": "الترا",
"Ultra Performance": "أداء فائق",
"Native": "أصلي",
"Full": "كامل",
"Reduced": "مخفض",
"Efficient": "كفاءة",
"Precise": "دقيق",
"Auto (VSync)": "تلقائي (VSync)",
"FIFO": "يصرف أولاً",
"Mailbox": "صندوق البريد",
"Immediate": "فوري",
"Vulkan": "فولكان",
"DirectX 12": "دايركت اكس 12",
"OpenGL": "برنامج OpenGL",
"FPS and Graph": "FPS والرسم البياني",
"FPS": "إطارا في الثانية",
"TAA": "تأ",
"FXAA": "فكسا",
"No cost": "لا تكلفة",
"Low cost": "تكلفة منخفضة",
"Medium cost": "تكلفة متوسطة",
"High cost": "تكلفة عالية",
"Saves time": "يوفر الوقت",
"HDR": "تقرير التنمية البشرية",
"Graphics": "الرسومات",
"Done": "تم",
"This feature is not yet available in Horizon.": "هذه الميزة غير متوفرة بعد في Horizon.",
"Your display or system doesn't offer HDR output.": "لا توفر شاشتك أو نظامك مخرجات HDR.",
"Turn HDR on to use it.": "قم بتشغيل HDR لاستخدامه.",
"Your GPU or graphics API doesn't support it.": "وحدة معالجة الرسومات (GPU) أو واجهة برمجة تطبيقات الرسومات الخاصة بك لا تدعمها.",
"%s chunks": "%s قطع",
"%s per frame": "%s لكل إطار",
"%s MiB/frame": "%s MiB/الإطار",
"%s KiB/frame": "%s كيلو بايت/الإطار",
"Auto (%s)": "تلقائي (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "نطاق التضاريس كامل التفاصيل. يمكن لمسافة LOD أن تمتد إلى عوالم محلية أبعد.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "التضاريس البعيدة الخشنة في العوالم المحلية. تتوقف وحدات معالجة الرسومات المدمجة عند 128 قطعة.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "تم تقليل الرسم على التضاريس البعيدة: بشكل أسرع على وحدة معالجة الرسومات المدمجة هذه.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "يتم تقليل الرسوم على التضاريس البعيدة بشكل أكثر خشونة، أما بالنسبة لوحدات معالجة الرسومات فإنها تبطئ.",
"Field of view. Wider shows more terrain at once.": "مجال الرؤية. يُظهر العرض الأوسع المزيد من التضاريس في وقت واحد.",
"Caps frames per second; a cap saves power and heat.": "قبعات الإطارات في الثانية الواحدة؛ غطاء يوفر الطاقة والحرارة.",
"Waits for the display's refresh: no tearing, a little latency.": "في انتظار تحديث الشاشة: بدون تمزق، وقليل من زمن الوصول.",
"Size of menus and the HUD.": "حجم القوائم وHUD.",
"Sets the options below together. Changing one makes it Custom.": "يضبط الخيارات أدناه معًا. تغيير واحد يجعله مخصصًا.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "الحمم البركانية والنار وأشعة الشمس تتألق أكثر من اللون الأبيض. يحتاج إلى شاشة HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "اللون الأسود الداكن لشاشتك. 0 لـ OLED؛ تتوهج شاشات LCD قليلاً.",
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
"How bright the HUD and menus are, against paper white.": "مدى سطوع شاشة HUD والقوائم مقابل الورق الأبيض.",
"How far mobs, crops and machines keep working. CPU time.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "يتخطى التضاريس المخفية خلف الأقسام المغلقة. قبالة تكاليف الكثير.",
"Hides entities behind opaque blocks.": "يخفي الكيانات خلف الكتل غير الشفافة.",
"No entity occlusion culling.": "لا يوجد إعدام انسداد الكيان.",
"CPU occlusion test runs this frame.": "يقوم اختبار انسداد وحدة المعالجة المركزية بتشغيل هذا الإطار.",
"Occlusion checks run on a worker thread.": "يتم تشغيل عمليات فحص الانسداد على مؤشر ترابط عامل.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "تم تحميل المقاطع المتشابكة لكل إطار. الجزء السفلي أكثر سلاسة أثناء التحميل.",
"Soft shadows in corners. Costs chunk building time only.": "ظلال ناعمة في الزوايا. تكاليف بناء قطعة قطعة فقط.",
"Smaller copies of block textures for distant terrain: less shimmer.": "نسخ أصغر من أنسجة الكتل للتضاريس البعيدة: أقل وميضًا.",
"Blends grass and water colours across biomes when building chunks.": "يمزج ألوان العشب والماء عبر المناطق الأحيائية عند بناء القطع.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "طبقة السحابة: معطلة، مسطحة (سريعة) أو ثلاثية الأبعاد (فاخرة).",
"How far clouds reach, in chunks.": "إلى أي مدى تصل السحب، في قطع.",
"Draws the sky, sun, moon and stars.": "يرسم السماء والشمس والقمر والنجوم.",
"Distance fog. Off shows the edge of the loaded world.": "ضباب المسافة. يظهر Off حافة العالم المحمل.",
"Draws rain and snow.": "يرسم المطر والثلج.",
"How far rain and snow fall around you.": "إلى أي مدى تسقط الأمطار والثلوج من حولك.",
"How many particles effects spawn.": "كم عدد الجسيمات التي تفرخ الآثار.",
"Round shadows under mobs and items.": "ظلال مستديرة تحت الغوغاء والعناصر.",
"Leaves with see-through gaps. Off draws them solid.": "الأوراق بها فجوات شفافة. قبالة يرسمهم الصلبة.",
"Sorts water and glass more exactly behind each other.": "يقوم بفرز الماء والزجاج بشكل أكثر دقة خلف بعضهما البعض.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "أي قطعة سيتم إعادة بنائها تنتظر الإطار: لا شيء، قريب أو خاص بك.",
"Animates water, lava, fire and other moving textures.": "ينعش الماء والحمم والنار وغيرها من القوام المتحرك.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "بكسل العالم لكل بكسل الشاشة. أقل من 100% أسرع على وحدة معالجة الرسومات.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "ينعم الحواف الخشنة. FXAA: أكثر ليونة. TAA: قد تتبع الأشياء الأكثر سلاسة وتحريكًا.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "يجعل العالم أصغر حجمًا ويعيد بنائه باستخدام TAA. أسرع وأكثر ليونة.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "إخراج HDR على شاشة HDR: تتألق الحمم البركانية والنار وأشعة الشمس بشكل أكثر سطوعًا.",
"How frames reach the screen while VSync is off.": "كيفية وصول الإطارات إلى الشاشة أثناء إيقاف تشغيل VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "ينام بكفاءة حتى الإطار التالي؛ يدور بدقة، مثل الفانيليا.",
"Fills the screen (borderless).": "يملأ الشاشة (بلا حدود).",
"Brightens dark places.": "يضيء الأماكن الداكنة.",
"Lowers the frame rate when minimised, or idle too.": "يخفض معدل الإطارات عند تصغيره أو في وضع الخمول أيضًا.",
"Sways the view while walking.": "يتأرجح المنظر أثناء المشي.",
"Fades newly loaded chunks in.": "يتلاشى القطع المحملة حديثًا.",
"Darkens the screen's edges.": "لتغميق حواف الشاشة.",
"Shows when the world saves.": "يظهر عندما ينقذ العالم.",
"Shows attack cooldown.": "يظهر تباطؤ الهجوم.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 إطارًا في الثانية (30 في القوائم أو غير مركزة)، نصف العمال: طاقة أقل.",
"Threads that generate, light and mesh chunks. Next start.": "الخيوط التي تولد القطع الخفيفة والشبكية. البداية التالية.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan، ثم DirectX 12 (Windows) أو OpenGL (Linux) حيث يفشل. البداية التالية.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "الماء والزجاج في مخزن مؤقت واحد: عدد أقل من مكالمات السحب، والمزيد من VRAM. البداية التالية.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "يتم إرسال بيانات الشبكة إلى وحدة معالجة الرسومات لكل إطار. الجزء السفلي أكثر سلاسة أثناء التحميل.",
"Frame rate and frame times over the game, with a graph.": "معدل الإطارات وأوقات الإطار خلال اللعبة، مع رسم بياني.",
"horizon.multiplayer.finding_friend": "البحث عن عالم صديقك...",
"horizon.multiplayer.not_signed_in": "هذا الخادم في وضع الاتصال بالإنترنت. قم بتسجيل الدخول باستخدام حساب Microsoft الخاص بك في Horizon Launcher للانضمام إليه.",
"horizon.multiplayer.invalid_address": "عنوان الخادم غير صالح: %s",
"horizon.multiplayer.connected_friend_direct": "تواصل مباشرة مع صديقك..",
"horizon.multiplayer.connected_friend_relay": "متصل بصديقك من خلال التتابع...",
"General": "عام",
"Advanced": "متقدم",
"Undo": "تراجع",
"Search options": "خيارات البحث",
"Shortcut": "الاختصار",
"Distance": "المسافة",
"Display": "عرض",
"Interface": "واجهة",
"Sky and weather": "السماء والطقس",
"Lighting and entities": "الإضاءة والكيانات",
"Textures": "القوام",
"Image": "صورة",
"Chunk loading": "تحميل قطعة",
"Culling": "إعدام",
"Power": "القوة",
"Frames": "إطارات",
"Overlay": "تراكب",
"Output": "الإخراج",
"Brightness": "السطوع",
"How far you see, the window and the HUD.": "إلى أي مدى ترى النافذة وشاشة HUD.",
"The sky and weather, lighting, textures and the image.": "السماء والطقس والإضاءة والقوام والصورة.",
"Chunk loading, culling and power use.": "تحميل القطع وإعدامها واستخدام الطاقة.",
"How frames reach the screen, and the performance overlay.": "كيفية وصول الإطارات إلى الشاشة وتراكب الأداء.",
"Brighter-than-white output on an HDR display.": "إخراج أكثر سطوعًا من اللون الأبيض على شاشة HDR.",
"Results for \"%s\"": "نتائج ل \"%s\"",
"No settings match.": "لا توجد إعدادات متطابقة.",
"horizon.title.not_affiliated": "لا ينتمي إلى Mojang أو Microsoft",
"horizon.extras.header.lighting": "الإضاءة",
"horizon.extras.header.third_person": "شخص ثالث",
"horizon.extras.header.particles": "الجسيمات",
"horizon.extras.header.inventory": "المخزون",
"horizon.extras.header.chat": "الدردشة",
"horizon.extras.header.multiplayer": "متعددة اللاعبين",
"horizon.extras.header.sound": "الصوت",
"horizon.extras.header.menus": "القوائم",
"horizon.extras.fullbright": "فولبرايت",
"horizon.extras.dynamic_lights": "أضواء ديناميكية",
"horizon.extras.own_name_tag": "علامة الاسم الخاصة",
"horizon.extras.hide_block_particles": "إخفاء جزيئات كسر الكتلة",
"horizon.extras.auto_reconnect": "إعادة الاتصال التلقائي",
"horizon.extras.ui_sound_reserve": "احتفظ بقنوات لأصوات واجهة المستخدم",
"horizon.extras.music_page": "صفحة الموسيقى",
"horizon.extras.mouse_tweaks": "الضوابط بتعديل الماوس",
"horizon.extras.mouse_tweaks_wheel": "عجلة التمرير تنقل العناصر",
"horizon.extras.sort": "أزرار الفرز",
"horizon.extras.sort_key": "مفتاح الفرز",
"horizon.extras.no_ai": "لا يوجد ذكاء اصطناعي",
"horizon.extras.agent_control": "مراقبة الوكيل",
"horizon.extras.discord_presence": "وجود الخلاف الغني",
"horizon.extras.splitscreen": "تقسيم الشاشة",
"horizon.extras.chat_animation": "دردشة سلسة",
"horizon.extras.chat_indicator": "مؤشر الرسالة",
"horizon.extras.wheel_cycles_settings": "عجلة التمرير تغير الإعدادات",
"horizon.extras.pack_browser": "متصفح حزمة الموارد",
"horizon.extras.fullbright.tooltip": "إظهار الكتل والكيانات في الضوء الكامل، حتى في المناطق المظلمة.",
"horizon.extras.dynamic_lights.tooltip": "تم حمل المشاعل ومصادر الضوء الأخرى أو إسقاطها أو إشعال النار فيها على الكتل المحيطة بها.",
"horizon.extras.own_name_tag.tooltip": "يظهر اسمك فوق رأسك بصيغة الغائب، كما يراه اللاعبون الآخرون. يكون مخفيًا عندما يخفي الخادم أسماء فريقك أو عندما تكون غير مرئي.",
"horizon.extras.hide_block_particles.tooltip": "يخفي جزيئات الحطام التي تنفجر من كتلة مكسورة وتتفتت من تلك التي قمت بتعدينها. وتبقى الجزيئات الأخرى.",
"horizon.extras.auto_reconnect.tooltip": "إعادة الانضمام إلى الخادم بعد انقطاع الاتصال أو إعادة التشغيل، وليس بعد ركلة أو حظر أبدًا.",
"horizon.extras.ui_sound_reserve.tooltip": "تترك الأصوات في العالم 16 قناة صوتية من أصل 247 قناة مجانية، لذا فإن طوفانًا منها (انفجار كبير) لا يمكنه إسكات نقرات القائمة.",
"horizon.extras.music_page.tooltip": "تحصل الموسيقى والأصوات على صفحة موسيقى...: قم بتشغيل أي مسار مضمن الآن، أو قم بإيقاف تشغيله حتى تتوقف اللعبة عن اختياره. لا شيء يتغير حتى تقوم بإيقاف تشغيل المسار.",
"horizon.extras.mouse_tweaks.tooltip": "يؤدي السحب الأيمن إلى وضع عنصر واحد في كل فتحة، بينما يقوم السحب الأيسر بتجميع العنصر المحفوظ، بينما يقوم Shift + السحب الأيسر بنقل كل فتحة.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "يرسل الجزء السفلي عنصرًا واحدًا من الفتحة إلى الجانب الآخر، بينما يقوم الجزء العلوي بإحضار عنصر واحد مرة أخرى.",
"horizon.extras.sort.tooltip": "يقوم بفرز المخزون والصناديق والبراميل وصناديق النهاية وصناديق الشولكر بترتيب علامات تبويب إبداعية، ودمج الأكوام.",
"horizon.extras.sort_key.tooltip": "فرز الجزء الموجود تحت المؤشر. انقر، ثم اضغط على مفتاح؛ الهروب يفككه.",
"horizon.extras.no_ai.tooltip": "يخفي ويوقف تشغيل كل ميزات الذكاء الاصطناعي: التحكم بالوكيل، Horizon-ctl وخوادم MCP. مفتاح المشغل أيضًا.",
"horizon.extras.agent_control.tooltip": "يمكن لوكيل الذكاء الاصطناعي الخاص بك لعب هذه اللعبة من خلال Horizon-ctl أو عميل MCP، على هذا الكمبيوتر فقط.",
"horizon.extras.discord_presence.tooltip": "يعرض Playing Horizon على ملف تعريف Discord الخاص بك أثناء تشغيل Discord: القوائم، أو اللاعب الفردي أو المتعدد، والبعد ووقت اللعب. لا يوجد أبدًا اسم عالمي أو عنوان خادم.",
"Calibrate...": "معايرة...",
"Calibrate Anyway...": "معايرة على أية حال...",
"Use Automatic Setup": "استخدم الإعداد التلقائي",
"Shortcut: %s": "الاختصار: %s",
"Next": "التالي",
"HDR Calibration %s/%s: %s": "معايرة HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "لتعيين الصور الثلاثة أدناه بملء الشاشة، صورة اختبارية واحدة في كل مرة.",
"A key that opens this screen from the game. Not bound at first.": "المفتاح الذي يفتح هذه الشاشة من اللعبة. غير ملزمة في البداية.",
"Your display didn't say how bright it gets: calibrate to set it up.": "لم توضح شاشتك مدى سطوعها: قم بالمعايرة لإعدادها.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "تم إعداده بالفعل للعرض الخاص بك. قم بالمعايرة فقط إذا كان تقرير HDR يبدو خاطئًا.",
"Puts Peak Brightness and Paper White back to your display's setup.": "يعيد ذروة السطوع والورق الأبيض إلى إعداد شاشتك.",
"Set up for your display. Leave on Auto unless it looks wrong.": "قم بإعداد العرض الخاص بك. اتركه على الوضع التلقائي ما لم يبدو خاطئًا.",
"Lower it until the logo just disappears into the black.": "قم بخفضه حتى يختفي الشعار باللون الأسود.",
"Raise it until the logo just disappears into the bright square.": "ارفعه حتى يختفي الشعار في المربع المشرق.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "اجعل البطاقة بيضاء اللون بحيث تكون مريحة عند النظر إليها لفترة طويلة، في الضوء الذي تلعب فيه.",
"horizon.splitscreen.profiles.forget": "ننسى",
"horizon.splitscreen.profiles.create": "إنشاء",
"horizon.splitscreen.profiles.you": " - أنت",
"horizon.splitscreen.profiles.forget_named": "ننسى %s",
"horizon.splitscreen.profiles.previous": "السابق",
"horizon.splitscreen.profiles.next": "التالي",
"horizon.splitscreen.profiles.new_button": "ملف تعريف جديد...",
"horizon.splitscreen.profiles.forget_title": "هل نسيت الملف الشخصي؟",
"horizon.splitscreen.profiles.forget_leaves": "فإنه يترك قائمة هذا المثيل.",
"horizon.splitscreen.profiles.forget_kept": "يتم الاحتفاظ بالتقدم العالمي والإعدادات.",
"horizon.splitscreen.profiles.new_title": "ملف تعريف جديد",
"horizon.splitscreen.profiles.name_hint": "الاسم (من 3 إلى 16 حرفًا ورقمًا _)",
"horizon.splitscreen.profiles.switch_title": "تبديل الملف الشخصي",
"horizon.splitscreen.profiles.offline_note": "تنتمي الملفات الشخصية غير المتصلة بالإنترنت إلى هذه الحالة",
"horizon.splitscreen.profiles.move": "تحرك حيث تلعب",
"horizon.splitscreen.profiles.move_place": "تحرك حيث تلعب: %s",
"horizon.friends.title": "العب مع الأصدقاء",
"horizon.friends.hint": "يقوم الأصدقاء بلصق الرمز بعد زر الأصدقاء على شاشة العنوان، أو في الاتصال المباشر، أو على صفحة التشغيل الخاصة بالمشغل.",
"horizon.friends.copied": "منقول",
"horizon.friends.copy_code": "نسخ الرمز",
"horizon.friends.stop_sharing": "توقف عن المشاركة",
"horizon.friends.none_yet": "لا يوجد أصدقاء هنا بعد.",
"horizon.friends.here_now": "هنا الآن: %s",
"horizon.friends.share_intro": "شارك هذا العالم خلف رمز الدعوة: فهو يفتح على شبكة LAN، وينضم الأصدقاء على الإنترنت باستخدام الرمز. لا يوجد خادم خاص بنا متورط.",
"horizon.friends.start_sharing": "ابدأ المشاركة",
"horizon.friends.troubleshooting": "استكشاف الأخطاء وإصلاحها...",
"horizon.friends.stopped": "تم إيقاف مشاركة رمز الدعوة.",
"horizon.friends.short_code_changed": "استمر شخص ما في تجربة رمز قصير خاطئ، لذلك تغير.",
"horizon.friends.wrong_code": "حاول شخص ما رمزا خاطئا.",
"horizon.friends.code_ready": "رمز الدعوة الخاص بك جاهز: قائمة الألعاب، العب مع الأصدقاء. انسخها وأرسلها إلى أصدقائك.",
"horizon.friends.share_failed": "لا يمكن مشاركة العالم: %s",
"horizon.friends.finding_route": "التعرف على كيفية وصول الأصدقاء إليك...",
"horizon.osk.shift": "التحول",
"horizon.osk.space": "الفضاء",
"horizon.osk.delete": "حذف",
"horizon.osk.type": "اكتب",
"horizon.osk.symbols": "الرموز",
"horizon.osk.close": "إغلاق",
"horizon.extras.chat_animation.tooltip": "ينزلق خط الدردشة الجديد من اليسار بينما يتلاشى للداخل، بدلاً من أن يظهر كاملاً.",
"horizon.extras.chat_indicator.tooltip": "الشريط الملون الموجود على يسار خط الدردشة.",
"horizon.extras.wheel_cycles_settings.tooltip": "عجلة التمرير فوق زر في الإعدادات تغير قيمته، كما هو الحال في الفانيليا. مع إيقاف التشغيل، تقوم العجلة بتمرير القائمة فقط.",
"horizon.extras.pack_browser.tooltip": "تحديد حزم الموارد يحصل على زر \"إضافة\" الذي يبحث في Modrinth ويقوم بتثبيت حزمة في مجلد الحزم الخاص بك. مع إيقاف التشغيل، لن تتصل اللعبة أبدًا بـ Modrinth.",
"horizon.extras.splitscreen.tooltip": "في عالم ما، يمكن لوحدة تحكم أخرى الانضمام كلاعب جديد: اضغط على A عندما يُطلب منك ذلك، ثم اختر ملفًا شخصيًا ومكان اللعب.",
"horizon.splitscreen.setup.monitor": "مراقب %s",
"horizon.splitscreen.setup.monitor_unknown": "شاشة",
"horizon.splitscreen.setup.window": "نافذة %s",
"horizon.splitscreen.setup.new_window": "نافذة جديدة",
"horizon.splitscreen.setup.fill_monitor": "املأ %s",
"horizon.splitscreen.setup.done": "تم",
"horizon.splitscreen.setup.on": "على",
"horizon.splitscreen.setup.off": "إيقاف",
"horizon.splitscreen.setup.player": "اللاعب %s",
"horizon.splitscreen.setup.revert": "العودة",
"horizon.splitscreen.setup.apply": "تطبيق",
"horizon.splitscreen.setup.warning.empty_main": "لا أحد يلعب في النافذة 1: إنه يخفي",
"horizon.splitscreen.setup.warning.empty_close": "لا أحد يلعب في النافذة %s: يتم إغلاقها عند تطبيق",
"horizon.splitscreen.setup.warning.empty_new": "لا أحد يلعب في النافذة %s: لن يتم فتحها",
"horizon.splitscreen.setup.warning.on_main": "النافذة %s تملأ شاشة النافذة 1: يلعب لاعبوها في النافذة 1",
"horizon.splitscreen.setup.warning.unplugged": "شاشة النافذة %s غير متصلة: يتم تشغيل مشغلاتها في النافذة 1",
"horizon.splitscreen.setup.status.off": "تقسيم الشاشة معطل.",
"horizon.splitscreen.setup.status.enable_hint": "قم بتشغيله، ثم اضغط على زر على وحدة تحكم أخرى في العالم.",
"horizon.splitscreen.setup.status.join_hint": "اضغط على A للانضمام واختيار ملف التعريف.",
"horizon.splitscreen.setup.status.idle": "تقسيم الشاشة خاملاً",
"horizon.splitscreen.setup.status.alone": "لا يوجد لاعبين محليين آخرين.",
"horizon.splitscreen.setup.status.solo_controls": "لم تتغير عناصر التحكم الفردية وملاحظات وحدة التحكم.",
"horizon.splitscreen.setup.window_empty": "النافذة %s: فارغة",
"horizon.splitscreen.setup.note.select": "انقر فوق أحد اللاعبين، أو اسحبه إلى نافذة أو شاشة.",
"horizon.splitscreen.setup.note.apply": "تقدم للعب بهذه الطريقة، ثم ارجع للتراجع.",
"horizon.splitscreen.setup.note.place": "أين يجب أن يلعب %s؟",
"horizon.splitscreen.setup.this_player": "هذا اللاعب",
"horizon.splitscreen.setup.switch_disabled": "يتم إيقاف تشغيل الشاشة المنقسمة بمجرد مغادرة الآخرين (قائمة الإيقاف المؤقت الخاصة بهم).",
"horizon.splitscreen.setup.window_place": "النافذة %s (%s من %s)",
"horizon.splitscreen.setup.names": "الأسماء",
"horizon.splitscreen.setup.identify": "تحديد",
"horizon.splitscreen.setup.layout.two": "2 المشاركة: %s",
"horizon.splitscreen.setup.layout.three": "3 المشاركة: %s",
"horizon.splitscreen.setup.layout.two.0": "تلقائي",
"horizon.splitscreen.setup.layout.two.1": "جنبا إلى جنب",
"horizon.splitscreen.setup.layout.two.2": "أعلى وأسفل",
"horizon.splitscreen.setup.layout.three.0": "واحدة واسعة في الأعلى",
"horizon.splitscreen.setup.layout.three.1": "واحد طويل القامة على اليسار",
"horizon.splitscreen.setup.layout.three.2": "جنبا إلى جنب",
"horizon.splitscreen.setup.help.player": "انقر أو اسحب %s لتحريكها.",
"horizon.splitscreen.setup.help.layout_auto": "%s (جنبًا إلى جنب عندما تكون النافذة واسعة). اضغط للتغيير.",
"horizon.splitscreen.setup.help.layout": "%s. اضغط للتغيير.",
"horizon.splitscreen.setup.help.switch": "تقسيم الشاشة لهذا الكمبيوتر، أو إيقاف تشغيله.",
"horizon.splitscreen.setup.help.identify": "تُظهر طريقة عرض كل لاعب النافذة التي يوجد بها.",
"horizon.splitscreen.setup.help.names": "اسم كل لاعب في زاوية رؤيته.",
"horizon.splitscreen.setup.help.revert": "التراجع عن التغييرات التي لم يتم تطبيقها بعد.",
"horizon.splitscreen.setup.help.apply": "العب بالطريقة التي تظهر بها الخريطة.",
"horizon.splitscreen.setup.help.unchanged": "لم يتغير شيء بعد.",
"horizon.splitscreen.setup.give_up_pad": "التخلي عن الوسادة",
"horizon.splitscreen.prompt.controller_connected": "وحدة التحكم متصلة",
"horizon.splitscreen.prompt.controller_disconnected": "وحدة التحكم مفصولة",
"horizon.splitscreen.prompt.controller_disconnected.hint": "أعد توصيله، أو اضغط على زر على آخر",
"horizon.splitscreen.prompt.join.one": "%s يريد الانضمام",
"horizon.splitscreen.prompt.join.many": "%s وحدات التحكم تريد الانضمام",
"horizon.splitscreen.prompt.join.yes": "انضم",
"horizon.splitscreen.prompt.join.as_p1": "العب بشخصية P1",
"horizon.splitscreen.prompt.join.no": "لا",
"horizon.splitscreen.prompt.lost": "%s: تم فصل وحدة التحكم",
"horizon.splitscreen.prompt.lost.use_another": "استخدم آخر",
"horizon.splitscreen.prompt.lost.leave": "غادر",
"horizon.splitscreen.setup.help.give_up_pad": "تتوقف وحدة التحكم الخاصة بك عن قيادتك ويُعرض عليك الانضمام (أ).",
"horizon.splitscreen.setup.note.held_at": "%s إلى %s: يسقطها A هنا، ويعيدها B.",
"horizon.splitscreen.setup.note.held": "%s تم الالتقاط: يسار/يمين يختار المكان، يسقط A، ويعيده B.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "يخفي النماذج خلف الكتل المعتمة. غير متزامن يتحقق من خارج الموضوع؛ علامة الكيانات.",
"Block Entities": "حظر الكيانات",
"Shulker Boxes": "صناديق شوكر",
"Reuses settled boxes; lid movement stays animated.": "إعادة استخدام الصناديق المستقرة؛ تبقى حركة الغطاء متحركة.",
"Signs": "علامات",
"Reuses sign text until text, light, or font changes.": "يعيد استخدام نص التوقيع حتى يتغير النص أو الضوء أو الخط.",
"Decorated Pots": "الأواني المزخرفة",
"Reuses still pots; break wobble and sherd changes redraw.": "إعادة استخدام الأواني الثابتة؛ كسر تمايل وتغييرات الكسر إعادة رسم.",
"Bells": "أجراس",
"Reuses still bells; ringing stays animated.": "إعادة استخدام الأجراس الثابتة؛ يبقى الرنين متحركًا.",
"Chests": "الصناديق",
"Reuses closed chests; moving lids stay animated.": "إعادة استخدام الصناديق المغلقة؛ الأغطية المتحركة تبقى متحركة.",
"Banners": "لافتات",
"Reuses poles; flags and pattern layers still update.": "إعادة استخدام الأعمدة؛ لا يزال يتم تحديث الأعلام وطبقات الأنماط.",
"Copper Golem Statues": "تماثيل النحاس جوليم",
"Reuses statue geometry until its block state changes.": "يعيد استخدام هندسة التمثال حتى تتغير حالة الكتلة الخاصة به.",
"Leaf Culling": "إعدام الأوراق",
"Every leaf face. Vanilla's fancy leaves.": "وجه كل ورقة. أوراق الفانيليا الفاخرة.",
"Distant Plant Dither": "ثبات النبات البعيد",
"Keeps fine plants in high-res packs from fading far away.": "يحافظ على النباتات الجميلة في عبوات عالية الدقة من البهتان بعيدًا.",
"Screenshot Quality": "جودة لقطة الشاشة",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "لقطات شاشة F2 بهذا دقة اللعبة عدة مرات. أعلى يستغرق وقتا أطول.",
"Frame Generation": "جيل الإطار",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "يُظهر إطارًا مكوّنًا بين كل إطارين حقيقيين: أكثر سلاسة وتأخرًا قليلًا.",
"Shadow Cache": "ذاكرة التخزين المؤقت الظل",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "يعيد استخدام خريطة ظل الحزمة عندما تتطابق الشمس والعرض والعالم. إيقاف بشكل افتراضي.",
"Find BlueMap": "ابحث عن بلو ماب",
"On servers, finds their BlueMap web map for far terrain. Next join.": "على الخوادم، يتم العثور على خريطة الويب BlueMap الخاصة بهم للتضاريس البعيدة. الانضمام التالي.",
"BlueMap Radius": "نصف قطر الخريطة الزرقاء",
"How far around you BlueMap tiles are downloaded. Next join.": "إلى أي مدى يتم تنزيل بلاط BlueMap من حولك. الانضمام التالي.",
"Synchronous": "متزامن",
"Asynchronous": "غير متزامن",
"Solid": "صلب",
"Solid Aggressive": "العدوانية الصلبة",
"Hollow": "جوفاء",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "يتم رسم الأوراق بالكامل داخل المظلة بشكل صلب: طبقات شفافة أقل.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "الأوراق ذات جانب واحد مفتوح ترسم بشكل صلب أيضًا: حواف أسرع وأكثر كثافة.",
"Drops faces between leaves: faster, but gaps look through.": "يسقط الوجوه بين الأوراق: بشكل أسرع، لكن الفجوات تبدو من خلالها.",
"Hotbar: Previous Slot": "Hotbar: الفتحة السابقة",
"Hotbar: Next Slot": "Hotbar: الفتحة التالية",
"horizon.agent.connected.one": "متصل: اتصال وكيل واحد",
"horizon.agent.connected.many": "متصل: %s اتصالات الوكيل",
"horizon.agent.connected.one_ago": "متصل: اتصال وكيل واحد، آخر أمر %s مضت",
"horizon.agent.connected.many_ago": "متصل: %s اتصالات الوكيل، آخر أمر %s مضت",
"horizon.agent.waiting": "في انتظار وكيل.",
"horizon.agent.off": "إيقاف: لا يمكن للعملاء الوصول إلى هذه اللعبة.",
"horizon.agent.session_opened": "تم فتحه لهذه الجلسة بواسطة البرنامج الذي بدأ اللعبة.",
"horizon.agent.socket": "المقبس: %s (انسخ التفاصيل لمساره الكامل)",
"horizon.agent.local_only": "محلي فقط: الوكلاء على هذا الكمبيوتر، وليس الشبكة أبدًا.",
"%s nits": "%s القمل",
"Auto (%s nits)": "تلقائي (%s شمعة في المتر المربع)",
"%.2f nits": "%.2f القمل",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "يخفي النماذج خلف الكتل المعتمة. تحديث الكيانات مع حزم مخصصة.",
"horizon.extras.repro_keys": "تسجيل الاختصارات",
"horizon.extras.repro_keys.tooltip": "يقوم F3+R ببدء التسجيل النسخي أو إيقافه ويقوم F3+E بكتابة الحزمة الخاصة به. متوقف بشكل افتراضي: تسجيل ينسخ العالم.",
"horizon.agent.record_session": "ابدأ جلسة التسجيل",
"horizon.agent.record_stop": "إيقاف جلسة التسجيل",
"horizon.agent.export_repro": "تصدير حزمة النسخ المتماثل",
"horizon.repro.started": "بدأ التسجيل؛ F3+R يوقفه ويكتب الحزمة.",
"horizon.repro.stopped": "توقف التسجيل: %s",
"horizon.repro.bundle": "حزمة النسخ: %s",
"horizon.repro.error": "استنساخ: %s",
"horizon.repro.no_recording": "لا يوجد تسجيل للتصدير حتى الآن.",
"horizon.repro.replay_started": "بدأت إعادة التشغيل: %s",
"horizon.pack.addInfo": "إضافة حزمة موارد من Modrinth",
"horizon.pack.addWorking": "يسأل مودرنث...",
"horizon.pack.addNone": "لم يتم العثور على حزم الموارد",
"horizon.pack.addTitle": "أضف حزمة الموارد",
"horizon.pack.install": "تثبيت",
"horizon.pack.installing": "التثبيت",
"horizon.pack.installed": "تم التثبيت",
"horizon.pack.search": "بحث",
"horizon.pack.searchHint": "بحث مودرينث...",
"horizon.pack.filters": "المرشحات",
"horizon.pack.filter.realistic": "واقعية",
"horizon.pack.filter.simplistic": "تبسيط",
"horizon.pack.filter.vanillaLike": "تشبه الفانيليا",
"horizon.pack.addFailed": "لا يمكن إضافة الحزمة: %s",
"horizon.extras.header.cosmetics": "مستحضرات التجميل",
"horizon.extras.cosmetics_mine": "مستحضرات التجميل الخاصة بي",
"horizon.extras.cosmetics_mine.tooltip": "مخفي: يراك كل لاعب في Horizon بدون مستحضرات التجميل الخاصة بك، بما في ذلك أنت. نفس مفتاح خزانة المشغل.",
"horizon.extras.cosmetics_others": "مستحضرات التجميل للاعبين الآخرين",
"horizon.extras.cosmetics_others.tooltip": "ما يرتديه لاعبو Horizon الآخرون: كل شيء، فقط أنماط بطاقة الاسم الخاصة بهم، أو لا شيء. مخفيًا، اللعبة لا تسأل أبدًا عما يرتدونه.",
"horizon.extras.cosmetics_auras": "هالات ومسارات الآخرين",
"horizon.extras.cosmetics_auras.tooltip": "الهالات والمسارات التي يرتديها اللاعبون الآخرون. إيقاف بشكل افتراضي.",
"horizon.extras.cosmetics.shown": "معروض",
"horizon.extras.cosmetics.hidden": "مخفي",
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط"
}
@@ -0,0 +1,473 @@
{
"horizon.options.controller": "Nəzarətçi",
"horizon.options.vibration": "Vibrasiya",
"horizon.options.adaptive_triggers": "Adaptiv tətiklər",
"horizon.options.lightbar": "İşıq çubuğu",
"horizon.options.look_sensitivity": "Baxış həssaslığı",
"horizon.options.invert_x": "Tərs baxış X",
"horizon.options.invert_y": "Tərs baxış Y",
"horizon.options.sneak_button": "Gizlənmə düyməsi",
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
"horizon.options.look_deadzone": "Baxış ölü zonası",
"horizon.options.button_glyphs": "Düymə Qlifləri",
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
"horizon.options.chat_delay": "Söhbət Gecikməsi",
"horizon.options.lightbar.health": "Sağlamlıq",
"horizon.options.lightbar.dimension": "Ölçü",
"horizon.options.glyphs.auto": "Avtomatik",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Göstərici",
"horizon.options.keyboard.auto": "Avtomatik",
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"horizon.options.search": "Axtarış parametrləri...",
"horizon.pages.mods": "Modlar",
"horizon.pages.shader_packs": "Shader Paketləri",
"horizon.pages.shader_options": "Shader Seçimləri",
"horizon.pages.controller": "Nəzarətçi Parametrləri",
"horizon.pages.controller_binds": "Nəzarətçi Bağlayır",
"horizon.pages.extras": "Horizon Əlavələr",
"horizon.pages.settings": "Horizon Parametrlər",
"horizon.pages.agent": "AI agenti",
"horizon.split.leave": "tərk et",
"horizon.pages.split_settings": "Splitscreen Parametrləri",
"horizon.pages.split_profiles": "Kim oynayır?",
"horizon.pages.split_setup": "Splitscreen Quraşdırma",
"horizon.pages.music": "Musiqi",
"horizon.pages.music.menu": "Menyu",
"horizon.pages.music.overworld": "Yerüstü dünya",
"horizon.pages.music.creative": "Yaradıcı",
"horizon.pages.music.underwater": "Sualtı",
"horizon.pages.music.end": "Son",
"horizon.pages.music.boss": "patron",
"horizon.pages.music.credits": "Kreditlər",
"horizon.pages.music.overworld_biomes": "Yerüstü biomlar",
"horizon.pages.music.nether_biomes": "Nether biomları",
"horizon.pages.music.end_biomes": "Biomları bitir",
"horizon.pages.music.other": "Digər musiqi",
"horizon.pages.music.empty": "Musiqi parçaları yoxdur: oyunun səsləri yüklənmir.",
"horizon.pages.music.tooltip": "Vəziyyətə görə quraşdırılmış treklər: birini indi oynayın və ya hər hansı birini söndürün ki, oyun onu seçməyi dayandırsın.",
"horizon.pages.settings.tooltip": "Horizon öz parametrləri: qrafika, nəzarətçilər, əlavələr və modlar.",
"horizon.pages.graphics.tooltip": "Qrafika, sadəcə olaraq: hər parametr nəyə başa gəldiyini bildirir.",
"horizon.pages.graphics_advanced.tooltip": "Hər bir qrafik parametri, Horizon daxil edilir, hər birinin dəyəri ilə.",
"horizon.pages.controller.tooltip": "Nəzarətçilər: onların düymələri, çubuqları, vibrasiya və menyu naviqasiyası.",
"horizon.pages.extras.tooltip": "Könüllü əlavələr, hamısı defolt olaraq söndürülür: siz birini yandırana qədər oyun vanil olaraq qalır.",
"horizon.pages.agent.tooltip": "Öz AI agentinizin oynamasına icazə verin: bu kompüterdə horizon-ctl və MCP müştəriləri.",
"horizon.pages.split_profiles.tooltip": "Kim oynayır: bu oyun qovluğunda saxlanılan profil.",
"horizon.pages.split_setup.tooltip": "Pəncərələr və monitorlar və kim harada oynayır.",
"horizon.pages.shader_packs.tooltip": "shaderpacks qovluğundan şader paketləri: Horizon öz və shaders/formatında olanlar. Bir dəfə qaçır.",
"horizon.pages.mods.tooltip": "Horizon-mods qovluğundan Horizon üçün hazırlanmış modlar. Hər biri sandboxda işləyir və siz onu yandırana qədər sönür.",
"horizon.pages.open.graphics": "Qrafika...",
"horizon.pages.open.graphics_advanced": "Qabaqcıl Qrafika...",
"horizon.pages.open.shader_packs": "Shader Paketləri...",
"horizon.pages.open.controller": "Nəzarətçi Parametrləri...",
"horizon.pages.open.split_setup": "Ayrılmış ekran...",
"horizon.pages.open.extras": "Əlavələr...",
"horizon.pages.open.mods": "Modlar...",
"horizon.pages.open.horizon_graphics": "Horizon Qrafika...",
"horizon.pages.open.controller_binds": "Nəzarətçi Bağlayır...",
"horizon.pages.open.horizon_settings": "Horizon Parametrlər...",
"horizon.pages.open.music": "Musiqi...",
"horizon.mods.empty": "Mod yoxdur: .hmod fayllarını horizon-mods qovluğuna qoyun",
"horizon.mods.stopped": " (Dayandı)",
"horizon.mods.not_built": " (tikilməyib)",
"horizon.mods.settings": "Parametrlər",
"horizon.mods.in_world": " (Dəyişmək üçün dünyanı tərk et)",
"horizon.mods.open_folder": "Mod Qovluğunu açın",
"horizon.shader_packs.empty": "Şeyder paketləri yoxdur: onları shaderpacks qovluğuna qoyun",
"horizon.shader_options.open": "Shader Seçimləri...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Şeyder paketinin seçimləri oxunur...",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
"horizon.multiplayer.cancel_reconnecting": "Yenidən qoşulmanı ləğv edin",
"horizon.multiplayer.join_friend": "Dosta Qoşulun",
"horizon.multiplayer.invite_code": "Dəvət Kodu",
"horizon.multiplayer.relay": "Rele (isteğe bağlı)",
"Preset": "Əvvəlcədən təyin",
"LOD Distance": "LOD məsafəsi",
"Far Detail": "Uzaq Detal",
"Cave Culling": "Mağaraların kəsilməsi",
"Entity Culling": "Müəssisə Culling",
"Chunk Updates": "Parça Yeniləmələri",
"Upload Budget": "Büdcə yükləyin",
"Sky": "Göy",
"Fog": "Duman",
"Weather": "Hava",
"Animated Textures": "Animasiyalı Dokular",
"Present Mode": "İndiki rejim",
"Worker Threads": "İşçi ipləri",
"Resolution Scale": "Qətnamə Ölçüsü",
"Upscaling": "Təkmilləşdirmə",
"Paper White": "Kağız Ağ",
"Peak Brightness": "Pik Parlaqlıq",
"Black Level": "Qara Səviyyə",
"UI Brightness": "UI Parlaqlığı",
"Battery Mode": "Batareya rejimi",
"Graphics API": "Qrafik API",
"Translucent Batching": "Şəffaf Batching",
"Performance Overlay": "Performans örtüyü",
"Full Detail Distance": "Tam Detal Məsafə",
"On": "Aktiv",
"Off": "Söndür",
"Unlimited": "Limitsiz",
"Auto": "Avtomatik",
"Custom": "Xüsusi",
"Performance": "Performans",
"Balanced": "Balanslı",
"Quality": "Keyfiyyət",
"Ultra Performance": "Ultra Performans",
"Native": "Yerli",
"Full": "Tam",
"Reduced": "Azaldılmış",
"Efficient": "Səmərəli",
"Precise": "Dəqiq",
"Auto (VSync)": "Avtomatik (VSync)",
"FIFO": "FİFO",
"Mailbox": "Poçt qutusu",
"Immediate": "Dərhal",
"FPS and Graph": "FPS və Qrafik",
"No cost": "Xərc yoxdur",
"Low cost": "Aşağı qiymət",
"Medium cost": "Orta qiymət",
"High cost": "Yüksək qiymət",
"Saves time": "Vaxta qənaət edir",
"Graphics": "Qrafika",
"Done": "Bitdi",
"This feature is not yet available in Horizon.": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"Your display or system doesn't offer HDR output.": "Ekranınız və ya sisteminiz HDR çıxışı təklif etmir.",
"Turn HDR on to use it.": "İstifadə etmək üçün HDR-ni yandırın.",
"Your GPU or graphics API doesn't support it.": "GPU və ya qrafik API onu dəstəkləmir.",
"%s chunks": "%s parçalar",
"%s per frame": "%s hər çərçivə üçün",
"%s MiB/frame": "%s MiB/çərçivə",
"%s KiB/frame": "%s KiB/çərçivə",
"Auto (%s)": "Avtomatik (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Tam detallı ərazi diapazonu. LOD məsafəsi yerli dünyaları daha da uzada bilər.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerli dünyalarda kobud uzaq ərazi. İnteqrasiya edilmiş GPU-lar 128 hissədə dayanır.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Azaldılmış ərazini daha qaba çəkir: bu inteqrasiya olunmuş GPU-da daha sürətli.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Azaldılmış ərazini daha qaba çəkir, GPU-lar üçün isə yavaşlayır.",
"Field of view. Wider shows more terrain at once.": "Baxış sahəsi. Daha geniş bir anda daha çox ərazini göstərir.",
"Caps frames per second; a cap saves power and heat.": "Saniyədə kadrların sayı; qapaq enerjiyə və istiliyə qənaət edir.",
"Waits for the display's refresh: no tearing, a little latency.": "Ekranın təzələnməsini gözləyir: cırılma, bir az gecikmə.",
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Qapalı hissələrin arxasında gizlənmiş ərazini ötür. Sıxılma çox baha başa gəlir.",
"Hides entities behind opaque blocks.": "Obyektləri qeyri-şəffaf blokların arxasında gizlədir.",
"No entity occlusion culling.": "Heç bir obyektin okklyuziyası məhv edilmir.",
"CPU occlusion test runs this frame.": "CPU tıxanma testi bu çərçivəni idarə edir.",
"Occlusion checks run on a worker thread.": "Tıxanma yoxlanışı işçi ipində aparılır.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed bölmələr hər çərçivəyə yüklənir. Yükləyərkən aşağı daha hamar olur.",
"Soft shadows in corners. Costs chunk building time only.": "Künclərdə yumşaq kölgələr. Yalnız tikinti vaxtı xərcləri.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzaq ərazilər üçün blok dokuların daha kiçik nüsxələri: daha az parıltı.",
"Blends grass and water colours across biomes when building chunks.": "Parçalar qurarkən biomlar arasında ot və su rənglərini qarışdırır.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Bulud təbəqəsi: sönük, düz (sürətli) və ya 3D (fantastik).",
"How far clouds reach, in chunks.": "Buludlar nə qədər uzağa çatır, parça-parça.",
"Draws the sky, sun, moon and stars.": "Göyü, günəşi, ayı və ulduzları çəkir.",
"Distance fog. Off shows the edge of the loaded world.": "Məsafə dumanı. Off yüklənmiş dünyanın kənarını göstərir.",
"Draws rain and snow.": "Yağış və qar çəkir.",
"How far rain and snow fall around you.": "Ətrafınıza yağış və qar nə qədər yağır.",
"How many particles effects spawn.": "Nə qədər hissəcik effektləri kürü verir.",
"Round shadows under mobs and items.": "Kütlələrin və əşyaların altında dairəvi kölgələr.",
"Leaves with see-through gaps. Off draws them solid.": "Yarpaqları göz qabağındadır. Off onları möhkəm çəkir.",
"Sorts water and glass more exactly behind each other.": "Suyu və şüşəni bir-birinin arxasında daha dəqiq şəkildə sıralayır.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Hansı hissənin yenidən qurulması çərçivəni gözləyir: heç biri, yaxınlıqda və ya sizin.",
"Animates water, lava, fire and other moving textures.": "Su, lava, od və digər hərəkətli dokuları canlandırır.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Kəsik kənarları hamarlayır. FXAA: daha yumşaq. TAA: ən hamar, hərəkətli şeylər cığır çəkə bilər.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Dünyanı daha kiçik edir və TAA ilə yenidən qurur. Daha sürətli, daha yumşaq.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR displeydə HDR çıxışı: lava, yanğın və günəş işığı daha parlaq parlayır.",
"How frames reach the screen while VSync is off.": "VSync söndürüldükdə çərçivələr ekrana necə çatır.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Növbəti kadra qədər səmərəli yuxular; Vanil kimi dəqiq spinlər.",
"Fills the screen (borderless).": "Ekranı doldurur (sərhədsiz).",
"Brightens dark places.": "Qaranlıq yerləri işıqlandırır.",
"Lowers the frame rate when minimised, or idle too.": "Minimumlaşdırıldıqda və ya boş olduqda kadr sürətini azaldır.",
"Sways the view while walking.": "Gəzərkən mənzərəni yelləyir.",
"Fades newly loaded chunks in.": "Yeni yüklənmiş parçaları sönür.",
"Darkens the screen's edges.": "Ekranın kənarlarını qaraldır.",
"Shows when the world saves.": "Dünyanın nə vaxt xilas etdiyini göstərir.",
"Shows attack cooldown.": "Hücumun soyumasını göstərir.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS qapağı (menyularda 30 və ya diqqətsiz), işçilərin yarısı: daha az güc.",
"Threads that generate, light and mesh chunks. Next start.": "Yaradan iplər, yüngül və mesh parçaları. Növbəti başlanğıc.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonra uğursuz olduğu DirectX 12 (Windows) və ya OpenGL (Linux). Növbəti başlanğıc.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Bir buferdə su və şüşə: daha az çəkiliş zəngi, bir az daha çox VRAM. Növbəti başlanğıc.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Hər kadra GPU-ya göndərilən mesh datası. Yükləyərkən aşağı daha hamar olur.",
"Frame rate and frame times over the game, with a graph.": "Qrafik ilə oyun üzrə çərçivə sürəti və kadr vaxtları.",
"horizon.multiplayer.finding_friend": "Dostunuzun dünyasını tapmaq...",
"horizon.multiplayer.not_signed_in": "Bu server onlayn rejimdədir. Qoşulmaq üçün Horizon başlatma cihazında Microsoft hesabınızla daxil olun.",
"horizon.multiplayer.invalid_address": "Yanlış server ünvanı: %s",
"horizon.multiplayer.connected_friend_direct": "Dostunuza birbaşa qoşulun...",
"horizon.multiplayer.connected_friend_relay": "Rele vasitəsilə dostunuza qoşulun...",
"Advanced": "Qabaqcıl",
"Undo": "Geri alın",
"Search options": "Axtarış seçimləri",
"Shortcut": "Qısayol",
"Distance": "Məsafə",
"Display": "Ekran",
"Interface": "İnterfeys",
"Sky and weather": "Səma və hava",
"Lighting and entities": "İşıqlandırma və obyektlər",
"Textures": "Dokular",
"Image": "Şəkil",
"Chunk loading": "Parça yükləmə",
"Power": "Güc",
"Frames": "Çərçivələr",
"Output": "Çıxış",
"Brightness": "Parlaqlıq",
"How far you see, the window and the HUD.": "Nə qədər görürsən, pəncərə və HUD.",
"The sky and weather, lighting, textures and the image.": "Səma və hava, işıqlandırma, faktura və görüntü.",
"Chunk loading, culling and power use.": "Parçaların yüklənməsi, kəsilməsi və enerji istifadəsi.",
"How frames reach the screen, and the performance overlay.": "Çərçivələrin ekrana necə çatması və performans örtüyü.",
"Brighter-than-white output on an HDR display.": "HDR displeydə ağdan daha parlaq çıxış.",
"Results for \"%s\"": "\"%s\" üçün nəticələr",
"No settings match.": "Heç bir parametr uyğun gəlmir.",
"horizon.title.not_affiliated": "Mojang və ya Microsoft ilə əlaqəli deyil",
"horizon.extras.header.lighting": "İşıqlandırma",
"horizon.extras.header.third_person": "Üçüncü şəxs",
"horizon.extras.header.particles": "hissəciklər",
"horizon.extras.header.inventory": "İnventar",
"horizon.extras.header.chat": "Söhbət",
"horizon.extras.header.sound": "Səs",
"horizon.extras.header.menus": "Menyular",
"horizon.extras.fullbright": "Tam parlaq",
"horizon.extras.dynamic_lights": "Dinamik işıqlar",
"horizon.extras.own_name_tag": "Öz ad etiketi",
"horizon.extras.hide_block_particles": "Blok qırılan hissəcikləri gizlədin",
"horizon.extras.auto_reconnect": "Avtomatik yenidən qoşulun",
"horizon.extras.ui_sound_reserve": "UI səsləri üçün kanalları saxlayın",
"horizon.extras.music_page": "Musiqi səhifəsi",
"horizon.extras.mouse_tweaks": "Mouse Tweaks nəzarətləri",
"horizon.extras.mouse_tweaks_wheel": "Sürüşdürmə çarxı elementləri hərəkət etdirir",
"horizon.extras.sort": "Çeşidləmə düymələri",
"horizon.extras.sort_key": "Sıralama açarı",
"horizon.extras.no_ai": "AI yoxdur",
"horizon.extras.agent_control": "Agent Nəzarəti",
"horizon.extras.chat_animation": "Rahat söhbət",
"horizon.extras.chat_indicator": "Mesaj göstəricisi",
"horizon.extras.wheel_cycles_settings": "Sürüşdürmə çarxı parametrləri dəyişir",
"horizon.extras.pack_browser": "Resurs paketi brauzeri",
"horizon.extras.fullbright.tooltip": "Blokları və obyektləri hətta qaranlıq yerlərdə də tam işıqda göstərin.",
"horizon.extras.dynamic_lights.tooltip": "Məşəllər və digər işıq mənbələri ətrafdakı blokları tutmuş, yerə atmış və ya odda yandırır.",
"horizon.extras.own_name_tag.tooltip": "Başqa oyunçuların gördüyü kimi adınızı başınızın üstündə üçüncü şəxsdə göstərir. Server komandanızın adlarını gizlətdikdə və ya siz görünməz olduğunuzda gizlidir.",
"horizon.extras.hide_block_particles.tooltip": "Sınıq blokdan qopan və mənimsədiyiniz blokdan parçalanan zibil hissəciklərini gizlədir. Digər hissəciklər qalır.",
"horizon.extras.auto_reconnect.tooltip": "Heç bir zərbə və ya qadağadan sonra itən əlaqə və ya yenidən başladıqdan sonra serverə yenidən qoşulur.",
"horizon.extras.ui_sound_reserve.tooltip": "Dünyadakı səslər 247 səs kanalından 16-nı pulsuz buraxır, buna görə də onların selinin (böyük partlayış) menyu kliklərini susdura bilməz.",
"horizon.extras.music_page.tooltip": "Musiqi və Səslər Musiqi... səhifəsi əldə edir: hər hansı daxili treki indi çalın və ya birini söndürün ki, oyun onu seçməyi dayandırsın. Siz treki söndürməyincə heç nə dəyişməyəcək.",
"horizon.extras.mouse_tweaks.tooltip": "Sağa sürükləmək hər yuvaya bir yerləşdirir, sola sürükləmək saxlanılan elementi toplayır, Shift + sola sürükləmək hər bir yuvaya köçür.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Aşağı yuvanın bir elementini digər tərəfə göndərir, yuxarı birini geri gətirir.",
"horizon.extras.sort.tooltip": "İnventar, sandıqlar, çəlləklər, ender sandıqlar və şulker qutularını yaradıcı nişan sırası ilə çeşidləyir, yığınları birləşdirin.",
"horizon.extras.sort_key.tooltip": "Kursorun altındakı hissəni çeşidləyir. Basın, sonra düyməni basın; Escape onu açır.",
"horizon.extras.no_ai.tooltip": "Hər bir AI funksiyasını gizlədir və söndürür: agent nəzarəti, horizon-ctl və MCP serverləri. Başlatıcının açarı da.",
"horizon.extras.agent_control.tooltip": "Öz AI agentiniz bu oyunu horizon-ctl və ya MCP müştəri vasitəsilə yalnız bu kompüterdə oynaya bilər.",
"horizon.extras.discord_presence.tooltip": "Discord işləyərkən Discord profilinizdə Horizon Playing göstərilir: menyular, tək oyunçu və ya multiplayer, ölçü və oynanılan vaxt. Heç vaxt dünyanın adı və ya server ünvanı.",
"Calibrate...": "Kalibrləmə...",
"Calibrate Anyway...": "İstənilən halda kalibrləyin...",
"Use Automatic Setup": "Avtomatik Quraşdırmadan istifadə edin",
"Shortcut: %s": "Qısayol: %s",
"Next": "Sonrakı",
"HDR Calibration %s/%s: %s": "HDR Kalibrləmə %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Aşağıdakı üç tam ekranı, hər dəfə bir sınaq şəklini təyin edir.",
"A key that opens this screen from the game. Not bound at first.": "Oyundan bu ekranı açan açar. Əvvəlcə bağlı deyil.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Ekranınız nə qədər parlaq olduğunu deməyib: onu qurmaq üçün kalibrləyin.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ekranınız üçün artıq qurulub. Yalnız HDR səhv görünürsə kalibrləyin.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Pik Parlaqlığı və Kağız Ağı ekranınızın quraşdırılmasına qaytarır.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Ekranınız üçün qurun. Səhv görünməsə, Auto-da buraxın.",
"Lower it until the logo just disappears into the black.": "Loqo qara rəngdə yox olana qədər onu endirin.",
"Raise it until the logo just disappears into the bright square.": "Loqo parlaq kvadratda yox olana qədər onu qaldırın.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Kartı oynadığınız işıqda uzun müddət baxmaq üçün rahat olan ağ rəngə çevirin.",
"horizon.splitscreen.profiles.forget": "Unut",
"horizon.splitscreen.profiles.create": "Yaradın",
"horizon.splitscreen.profiles.you": " - sən",
"horizon.splitscreen.profiles.forget_named": "Unut %s",
"horizon.splitscreen.profiles.previous": "Əvvəlki",
"horizon.splitscreen.profiles.next": "Sonrakı",
"horizon.splitscreen.profiles.new_button": "Yeni Profil...",
"horizon.splitscreen.profiles.forget_title": "Profili unutmusunuz?",
"horizon.splitscreen.profiles.forget_leaves": "Bu nümunənin siyahısını tərk edir.",
"horizon.splitscreen.profiles.forget_kept": "Dünya tərəqqisi və parametrlər saxlanılır.",
"horizon.splitscreen.profiles.new_title": "Yeni Profil",
"horizon.splitscreen.profiles.name_hint": "Ad (3-16 hərf, rəqəm, _)",
"horizon.splitscreen.profiles.switch_title": "Profilə keçin",
"horizon.splitscreen.profiles.offline_note": "Oflayn profillər bu instansiyaya aiddir",
"horizon.splitscreen.profiles.move": "Oynadığınız yerdə hərəkət edin",
"horizon.splitscreen.profiles.move_place": "Oynadığınız yerə köçürün: %s",
"horizon.friends.title": "Dostlarla oynayın",
"horizon.friends.hint": "Dostlar kodu başlıq ekranının Dostlar düyməsindən sonra, Birbaşa Bağlantıda və ya başlatma qurğusunun Play səhifəsində yerləşdirin.",
"horizon.friends.copied": "Kopyalandı",
"horizon.friends.copy_code": "Kodu kopyalayın",
"horizon.friends.stop_sharing": "Paylaşmağı dayandırın",
"horizon.friends.none_yet": "Burada hələ dost yoxdur.",
"horizon.friends.here_now": "İndi burada: %s",
"horizon.friends.share_intro": "Bu dünyanı dəvət kodunun arxasında paylaşın: o, LAN-a açılır və internetdəki dostlar kodla qoşulur. Heç bir serverimiz iştirak etmir.",
"horizon.friends.start_sharing": "Paylaşmağa başlayın",
"horizon.friends.troubleshooting": "Problem həll olunur...",
"horizon.friends.stopped": "Dəvət kodunu paylaşmağı dayandırdı.",
"horizon.friends.short_code_changed": "Kimsə səhv qısa kodu sınamağa davam etdi, ona görə də dəyişdi.",
"horizon.friends.wrong_code": "Kimsə səhv kodu sınadı.",
"horizon.friends.code_ready": "Dəvət kodunuz hazırdır: Oyun Menyu, Dostlarla Oyna. Kopyalayın və dostlarınıza göndərin.",
"horizon.friends.share_failed": "Dünyanı paylaşmaq mümkün olmadı: %s",
"horizon.friends.finding_route": "Dostların sizinlə necə əlaqə saxlaya biləcəyi tapılır...",
"horizon.osk.space": "Kosmos",
"horizon.osk.delete": "Sil",
"horizon.osk.type": "Növ",
"horizon.osk.symbols": "Simvollar",
"horizon.osk.close": "Bağlayın",
"horizon.extras.chat_animation.tooltip": "Yeni söhbət xətti bütöv görünmək əvəzinə soldan içəri sürüşür.",
"horizon.extras.chat_indicator.tooltip": "Söhbət xəttinin solunda olan rəngli bar.",
"horizon.extras.wheel_cycles_settings.tooltip": "Parametrlərdəki düymənin üzərindəki sürüşdürmə çarxı vanildə olduğu kimi dəyərini dəyişir. Söndürülmüş, təkər yalnız siyahını sürüşdürür.",
"horizon.extras.pack_browser.tooltip": "Resurs Paketlərini seçin Modrinth-də axtarış aparan və paketlər qovluğuna paket quraşdıran Əlavə et düyməsini alır. Off, oyun Modrinthlə heç vaxt əlaqə saxlamır.",
"horizon.extras.splitscreen.tooltip": "Dünyada başqa bir nəzarətçi yeni oyunçu kimi qoşula bilər: soruşduqda A düyməsini basın, sonra profil və harada oynayacağınızı seçin.",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
"horizon.splitscreen.setup.window": "Pəncərə %s",
"horizon.splitscreen.setup.new_window": "Yeni pəncərə",
"horizon.splitscreen.setup.fill_monitor": "%s doldurun",
"horizon.splitscreen.setup.done": "Bitdi",
"horizon.splitscreen.setup.on": "Aktiv",
"horizon.splitscreen.setup.off": "Söndür",
"horizon.splitscreen.setup.player": "Oyunçu %s",
"horizon.splitscreen.setup.revert": "Geri qaytarın",
"horizon.splitscreen.setup.apply": "Müraciət edin",
"horizon.splitscreen.setup.warning.empty_main": "Window 1-də heç kim oynamır: gizlənir",
"horizon.splitscreen.setup.warning.empty_close": "%s Pəncərəsində heç kim oynamır: Tətbiq edərkən bağlanır",
"horizon.splitscreen.setup.warning.empty_new": "%s pəncərəsində heç kim oynamır: açılmayacaq",
"horizon.splitscreen.setup.warning.on_main": "Pəncərə %s Pəncərə 1-in monitorunu doldurur: onun oyunçuları Pəncərə 1-də oynayır",
"horizon.splitscreen.setup.warning.unplugged": "Pəncərənin %s monitoru bağlı deyil: onun oyunçuları Window 1-də oynayır",
"horizon.splitscreen.setup.status.off": "Splitscreen deaktivdir.",
"horizon.splitscreen.setup.status.enable_hint": "Onu yandırın, sonra dünyanın başqa bir nəzarətçisində düyməni basın.",
"horizon.splitscreen.setup.status.join_hint": "Qoşulmaq və profil seçmək üçün A düyməsini basın.",
"horizon.splitscreen.setup.status.idle": "Splitscreen boş",
"horizon.splitscreen.setup.status.alone": "Başqa yerli oyunçu yoxdur.",
"horizon.splitscreen.setup.status.solo_controls": "Solo nəzarət və nəzarətçi rəyi dəyişməzdir.",
"horizon.splitscreen.setup.window_empty": "Pəncərə %s: boş",
"horizon.splitscreen.setup.note.select": "Oyunçuya klikləyin və ya onu pəncərəyə və ya monitora sürükləyin.",
"horizon.splitscreen.setup.note.apply": "Bu şəkildə oynamaq üçün müraciət edin, ləğv etmək üçün geri qaytarın.",
"horizon.splitscreen.setup.note.place": "%s harada oynamalıdır?",
"horizon.splitscreen.setup.this_player": "bu oyunçu",
"horizon.splitscreen.setup.switch_disabled": "Digərləri ayrıldıqdan sonra Splitscreen sönür (pauza menyusu).",
"horizon.splitscreen.setup.window_place": "Pəncərə %s (%s/%s)",
"horizon.splitscreen.setup.names": "Adlar",
"horizon.splitscreen.setup.identify": "Müəyyən etmək",
"horizon.splitscreen.setup.layout.two": "2 paylaşım: %s",
"horizon.splitscreen.setup.layout.three": "3 paylaşım: %s",
"horizon.splitscreen.setup.layout.two.0": "Avtomatik",
"horizon.splitscreen.setup.layout.two.1": "Yan-yana",
"horizon.splitscreen.setup.layout.two.2": "Üst və alt",
"horizon.splitscreen.setup.layout.three.0": "Üstündə biri geniş",
"horizon.splitscreen.setup.layout.three.1": "Solda biri hündür",
"horizon.splitscreen.setup.layout.three.2": "Yan-yana",
"horizon.splitscreen.setup.help.player": "Onları köçürmək üçün %s üzərinə klikləyin və ya dartın.",
"horizon.splitscreen.setup.help.layout_auto": "%s (pəncərə geniş olduqda yan-yana). Dəyişmək üçün basın.",
"horizon.splitscreen.setup.help.layout": "%s. Dəyişmək üçün basın.",
"horizon.splitscreen.setup.help.switch": "Bu kompüter üçün bölünmüş ekran, aktiv və ya söndürülür.",
"horizon.splitscreen.setup.help.identify": "Hər bir oyunçunun görünüşü onun hansı pəncərədə olduğunu göstərir.",
"horizon.splitscreen.setup.help.names": "Görünüşünün küncündə hər bir oyunçunun adı.",
"horizon.splitscreen.setup.help.revert": "Hələ tətbiq olunmamış dəyişiklikləri geri alın.",
"horizon.splitscreen.setup.help.apply": "Xəritənin göstərdiyi şəkildə oynayın.",
"horizon.splitscreen.setup.help.unchanged": "Hələ heç nə dəyişməyib.",
"horizon.splitscreen.setup.give_up_pad": "Pasxadan imtina et",
"horizon.splitscreen.prompt.controller_connected": "Nəzarətçi qoşulub",
"horizon.splitscreen.prompt.controller_disconnected": "Nəzarətçi ayrıldı",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Onu yenidən birləşdirin və ya digərində düyməni basın",
"horizon.splitscreen.prompt.join.one": "%s qoşulmaq istəyir",
"horizon.splitscreen.prompt.join.many": "%s nəzarətçilər qoşulmaq istəyirlər",
"horizon.splitscreen.prompt.join.yes": "Qoşulun",
"horizon.splitscreen.prompt.join.as_p1": "P1 kimi oynayın",
"horizon.splitscreen.prompt.join.no": "yox",
"horizon.splitscreen.prompt.lost": "%s: nəzarətçi ayrıldı",
"horizon.splitscreen.prompt.lost.use_another": "Başqasını istifadə edin",
"horizon.splitscreen.prompt.lost.leave": "tərk et",
"horizon.splitscreen.setup.help.give_up_pad": "Nəzarətçiniz sizi idarə etməyi dayandırır və ona qoşulmaq təklif olunur (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A onu bura düşür, B onu geri qoyur.",
"horizon.splitscreen.setup.note.held": "%s götürdü: sol/sağ yeri seçir, A düşür, B onu geri qoyur.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Async-dən kənar yoxlayır; qurumlar işarələyir.",
"Block Entities": "Blok obyektləri",
"Shulker Boxes": "Shulker qutuları",
"Reuses settled boxes; lid movement stays animated.": "Yerləşmiş qutuları təkrar istifadə edir; qapaq hərəkəti canlı qalır.",
"Signs": "İşarələr",
"Reuses sign text until text, light, or font changes.": "Mətn, işıq və ya şrift dəyişənə qədər işarə mətnindən təkrar istifadə edir.",
"Decorated Pots": "Bəzəkli qablar",
"Reuses still pots; break wobble and sherd changes redraw.": "Qazan qabları təkrar istifadə edir; yırğalanmaq və parçalanma dəyişiklikləri yenidən çəkmək.",
"Bells": "Zənglər",
"Reuses still bells; ringing stays animated.": "Hərəkətsiz zəngləri təkrar istifadə edir; Zəng animasiyalı qalır.",
"Chests": "Sinələr",
"Reuses closed chests; moving lids stay animated.": "Qapalı sandıqları təkrar istifadə edir; hərəkət edən qapaqlar canlı qalır.",
"Banners": "Bannerlər",
"Reuses poles; flags and pattern layers still update.": "Dirəkləri təkrar istifadə edir; bayraqlar və naxış qatları hələ də yenilənir.",
"Copper Golem Statues": "Mis Qolem heykəlləri",
"Reuses statue geometry until its block state changes.": "Blok vəziyyəti dəyişənə qədər heykəlin həndəsəsindən təkrar istifadə edir.",
"Leaf Culling": "Yarpaqların kəsilməsi",
"Every leaf face. Vanilla's fancy leaves.": "Hər yarpaq üzü. Vanilin gözəl yarpaqları.",
"Distant Plant Dither": "Uzaq Bitki Dither",
"Keeps fine plants in high-res packs from fading far away.": "Yüksək dəqiqlikli paketlərdəki incə bitkilərin uzaqdan solmasını qarşısını alır.",
"Screenshot Quality": "Ekran Şotunun Keyfiyyəti",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 ekran görüntüləri bu dəfələrlə oyunun həlli. Yüksək daha uzun çəkir.",
"Frame Generation": "Çərçivə Yaradılması",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Hər iki real arasında düzəldilmiş çərçivəni göstərir: daha hamar, bir az daha gecikmə.",
"Shadow Cache": "Kölgə önbelleği",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Günəş, görünüş və dünya uyğunlaşdıqda paket kölgə xəritəsindən təkrar istifadə edir. Defolt olaraq söndürülür.",
"Find BlueMap": "BlueMap tapın",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Serverlərdə uzaq ərazilər üçün BlueMap veb xəritəsini tapır. Növbəti qoşulun.",
"How far around you BlueMap tiles are downloaded. Next join.": "Ətrafınızda BlueMap plitələri nə qədər yüklənir. Növbəti qoşulun.",
"Synchronous": "Sinxron",
"Asynchronous": "Asinxron",
"Solid": "Möhkəm",
"Solid Aggressive": "Möhkəm Aqressiv",
"Hollow": "Boş",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Tam örtünün içindəki yarpaqlar möhkəm çəkilir: daha az keçirici təbəqə.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Bir tərəfi açıq olan yarpaqlar da bərk çəkir: daha sürətli, daha sıx kənarlar.",
"Drops faces between leaves: faster, but gaps look through.": "Yarpaqlar arasında üzləri düşür: daha sürətli, lakin boşluqlar içəri baxır.",
"Hotbar: Previous Slot": "İsti panel: Əvvəlki yuva",
"Hotbar: Next Slot": "İsti panel: Növbəti Slot",
"horizon.agent.connected.one": "Qoşuldu: 1 agent bağlantısı",
"horizon.agent.connected.many": "Qoşuldu: %s agent əlaqələri",
"horizon.agent.connected.one_ago": "Qoşuldu: 1 agent bağlantısı, son əmr %s s əvvəl",
"horizon.agent.connected.many_ago": "Qoşuldu: %s agent əlaqələri, son əmr %s s əvvəl",
"horizon.agent.waiting": "Agent gözləyir.",
"horizon.agent.off": "Off: agentlər bu oyuna daxil ola bilməz.",
"horizon.agent.session_opened": "Oyuna başlayan proqram tərəfindən bu sessiya üçün açıldı.",
"horizon.agent.socket": "Soket: %s (tam yolu üçün təfərrüatları kopyalayın)",
"horizon.agent.local_only": "Yalnız yerli: bu kompüterdəki agentlər, heç vaxt şəbəkə.",
"Auto (%s nits)": "Avtomatik (%s nits)",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Müəssisələrin yenilənməsi. Fərdi paketlərlə endirim.",
"horizon.extras.repro_keys": "Qısayolların qeyd edilməsi",
"horizon.extras.repro_keys.tooltip": "F3+R repro qeydini başlayır və ya dayandırır və F3+E öz paketini yazır. Defolt olaraq söndürülür: yazı dünyanı kopyalayır.",
"horizon.agent.record_session": "Səs yazmağa başlayın",
"horizon.agent.record_stop": "Səs yazmağı dayandırın",
"horizon.agent.export_repro": "Repro paketini ixrac edin",
"horizon.repro.started": "Qeydiyyat başladı; F3+R onu dayandırır və paketi yazır.",
"horizon.repro.stopped": "Qeydiyyat dayandırıldı: %s",
"horizon.repro.bundle": "Repro paketi: %s",
"horizon.repro.error": "Reproduksiya: %s",
"horizon.repro.no_recording": "İxrac etmək üçün hələ heç bir qeyd yoxdur.",
"horizon.repro.replay_started": "Təkrar başladı: %s",
"horizon.pack.addInfo": "Modrinth-dən resurs paketi əlavə edin",
"horizon.pack.addWorking": "Modrinthdən soruşur...",
"horizon.pack.addNone": "Heç bir resurs paketi tapılmadı",
"horizon.pack.addTitle": "Resurs Paketi əlavə edin",
"horizon.pack.install": "Quraşdırın",
"horizon.pack.installing": "Quraşdırılır",
"horizon.pack.installed": "Quraşdırılıb",
"horizon.pack.search": "Axtar",
"horizon.pack.searchHint": "Modrinth axtar...",
"horizon.pack.filters": "Filtrlər",
"horizon.pack.filter.realistic": "Realist",
"horizon.pack.filter.simplistic": "Sadə",
"horizon.pack.filter.vanillaLike": "Vanil kimi",
"horizon.pack.addFailed": "Paketi əlavə etmək mümkün olmadı: %s",
"horizon.extras.header.cosmetics": "Kosmetika",
"horizon.extras.cosmetics_mine": "Kosmetikam",
"horizon.extras.cosmetics_mine.tooltip": "Gizli: hər Horizon oyunçu sizi kosmetikanız olmadan görür. Başlatma şkafı ilə eyni açar.",
"horizon.extras.cosmetics_others": "Digər oyunçuların kosmetikası",
"horizon.extras.cosmetics_others.tooltip": "Digər Horizon oyunçular nə geyinirlər: hər şey, yalnız ad etiketi üslubları və ya heç nə. Gizli, oyun heç vaxt nə geyindiklərini soruşmur.",
"horizon.extras.cosmetics_auras": "Başqalarının auraları və izləri",
"horizon.extras.cosmetics_auras.tooltip": "Digər oyunçuların geydiyi auralar və izlər. Defolt olaraq söndürülür.",
"horizon.extras.cosmetics.shown": "Göstərildi",
"horizon.extras.cosmetics.hidden": "Gizli",
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri"
}
@@ -0,0 +1,499 @@
{
"horizon.options.controller": "Контролер",
"horizon.options.three_d_skin_layers": "3D скин ҡатламдары",
"horizon.options.vibration": "Вибрация",
"horizon.options.adaptive_triggers": "Адаптив триггерҙар",
"horizon.options.lightbar": "Лайтбар",
"horizon.options.look_sensitivity": "Ҡарау һиҙгерлеге",
"horizon.options.invert_x": "Ҡараш инверсияһы X",
"horizon.options.invert_y": "Ҡараш инверсияһы Y",
"horizon.options.sneak_button": "Боҫоу төймәһе",
"horizon.options.move_deadzone": "Хәрәкәт үле зонаһы",
"horizon.options.look_deadzone": "Ҡараш үле зонаһы",
"horizon.options.button_glyphs": "Төймә глифтары",
"horizon.options.menu_navigation": "Навигация меню",
"horizon.options.on_screen_keyboard": "Экрандағы клавиатура",
"horizon.options.chat_delay": "Чат тотҡарланыуы",
"horizon.options.lightbar.health": "Һаулыҡ",
"horizon.options.lightbar.dimension": "Үлсәм",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Күрһәткес",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.unavailable": "Был функция Horizon-ла әлегә юҡ.",
"horizon.options.search": "Эҙләү параметрҙары...",
"horizon.pages.mods": "Модтар",
"horizon.pages.shader_packs": "Шейдер пакеттары",
"horizon.pages.shader_options": "Шейдер параметрҙары",
"horizon.pages.controller": "Контроллер параметрҙары",
"horizon.pages.controller_binds": "Контроллер бәйләй",
"horizon.pages.extras": "Horizon Өҫтәмә",
"horizon.pages.settings": "Horizon Көйләүҙәр",
"horizon.pages.agent": "ЯИ агенты",
"horizon.split.leave": "Ҡалдырырға",
"horizon.pages.split_settings": "Сплит-экран параметрҙары",
"horizon.pages.split_profiles": "Кем уйнай?",
"horizon.pages.split_setup": "Сплит-экранды ҡуйыу",
"horizon.pages.music": "Музыка",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Өҫтөн донъя",
"horizon.pages.music.creative": "Ижади .",
"horizon.pages.music.underwater": "Һыу аҫтында .",
"horizon.pages.music.end": "Аҙағы",
"horizon.pages.music.boss": "Етәксе",
"horizon.pages.music.credits": "Кредиттар",
"horizon.pages.music.overworld_biomes": "Донъя өҫтөндәге биомдар",
"horizon.pages.music.nether_biomes": "Недер биомдары",
"horizon.pages.music.end_biomes": "Аҙаҡҡы биомдар",
"horizon.pages.music.other": "Башҡа музыка .",
"horizon.pages.music.empty": "Музыка тректары юҡ: уйындың тауыштары тейәлмәгән.",
"horizon.pages.music.tooltip": "Встроенный тректар, хәл буйынса: хәҙер бер уйнай, йәки теләһә ниндәй һүндерергә, шулай итеп, уйын уны йыйыу туҡтата.",
"horizon.pages.settings.tooltip": "Horizon үҙ параметрҙары: графика, контроллерҙар, өҫтәмәләр һәм модтар.",
"horizon.pages.graphics.tooltip": "Графика, ябай: һәр параметр нимәгә төшкәнен әйтә.",
"horizon.pages.graphics_advanced.tooltip": "Һәр график параметр, Horizon&#8217;s инә, һәр береһе нимә менән.",
"horizon.pages.controller.tooltip": "Контроллерҙар: уларҙың төймәләре, таяҡсалар, вибрация һәм меню навигацияһы.",
"horizon.pages.extras.tooltip": "Факультатив өҫтәүҙәр, бөтәһе лә өҫтөндә ғәҙәттәгесә: уйын ваниль ҡала, тик һеҙ бер өҫтөндә.",
"horizon.pages.agent.tooltip": "Һеҙҙең үҙ AI агент уйнай: horizon-ctl һәм MCP клиенттар был компьютерҙа.",
"horizon.pages.split_profiles.tooltip": "Кем уйнай: был уйын папкаһында һаҡланған профиль.",
"horizon.pages.split_setup.tooltip": "Тәҙрәләр һәм мониторҙар, ә кем ҡайҙа уйнай.",
"horizon.pages.shader_packs.tooltip": "Шейдер пакеттары шейдерпакеттар папкаһынан: Horizon үҙ һәм шейдерҙар/ форматындағылар. Берәм-берәм йүгерә.",
"horizon.pages.mods.tooltip": "Horizon өсөн эшләнгән модтар, горизонт-модтар папкаһынан. Һәр береһе ҡом йәшниктә йүгерә һәм һеҙ уны тоҡандырғансы һүндерелә.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Алдынғы графика...",
"horizon.pages.open.shader_packs": "Шейдер пакеттары...",
"horizon.pages.open.controller": "Контроллер параметрҙары...",
"horizon.pages.open.split_setup": "Бүленгән экран...",
"horizon.pages.open.extras": "Өҫтәмәләр...",
"horizon.pages.open.mods": "Модтар...",
"horizon.pages.open.agent": "ЯИ агенты...",
"horizon.pages.open.horizon_graphics": "Horizon Графика...",
"horizon.pages.open.controller_binds": "Контроллер бәйләй...",
"horizon.pages.open.horizon_settings": "Horizon Көйләүҙәр...",
"horizon.pages.open.music": "Музыка...",
"horizon.mods.empty": "Модтар юҡ: .hmod файлдарын горизонт-модтар папкаһына ҡуйығыҙ",
"horizon.mods.stopped": " (Туҡтатылған)",
"horizon.mods.not_built": " (Төҙөлмәгән)",
"horizon.mods.dev": " (Дев)",
"horizon.mods.settings": "Көйләүҙәр",
"horizon.mods.in_world": " (донъяны үҙгәртергә ҡалдырырға)",
"horizon.mods.open_folder": "Мод папкаһын асыу",
"horizon.shader_packs.empty": "Шейдер пакеттары юҡ: уларҙы шейдерпакеттар папкаһына һалығыҙ",
"horizon.shader_options.open": "Шейдер варианттары...",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Шейдер пакетының варианттарын уҡыу...",
"horizon.agent.copy_details": "Тоташыу тураһында мәғлүмәтте күсерергә",
"horizon.multiplayer.join": "Ҡушылыу",
"horizon.multiplayer.cancel_reconnecting": "Яңынан тоташтырыуҙы юҡҡа сығарыу",
"horizon.multiplayer.join_friend": "Дуҫҡа ҡушыл",
"horizon.multiplayer.invite_code": "Саҡырыу коды",
"horizon.multiplayer.relay": "Реле (теләк буйынса)",
"Preset": "Алдан ҡуйылған",
"LOD Distance": "LOD арауығы",
"Far Detail": "Алыҫ деталь",
"Cave Culling": "Мәмерйә ҡырҡыу",
"Entity Culling": "Субъектты ҡырҡыу",
"Chunk Updates": "Яңыртыу өлөштәре",
"Upload Budget": "Бюджет тейәү",
"Sky": "Күк",
"Fog": "Томан",
"Weather": "Һауа торошо",
"Animated Textures": "Йәнле текстуралар",
"Present Mode": "Хәҙерге режим",
"Frame Pacing": "Кадр темпы",
"Worker Threads": "Эшсе ептәр",
"Resolution Scale": "Асыҡлыҡ шкалаһы",
"Anti-aliasing": "Анти-псевдоним",
"Upscaling": "Масштаблаштырыу",
"Paper White": "Аҡ ҡағыҙ",
"Peak Brightness": "Иң юғары яҡтылыҡ",
"Black Level": "Ҡара кимәл",
"UI Brightness": "Яҡтылыҡ",
"Battery Mode": "Аккумулятор режимы",
"Graphics API": "Графика API",
"Translucent Batching": "Ярым үтә күренмәле партиялау",
"Performance Overlay": "Һөҙөмтәлелек өҫтөнлөктәре",
"Full Detail Distance": "Тулы ентекле арауыҡ",
"On": "Өҫтөндә",
"Off": "Һүндерелгән",
"Unlimited": "Сикләнмәгән",
"Auto": "Авто",
"Custom": "Ҡулланыусы",
"Performance": "Башҡарыу",
"Balanced": "Баланслы",
"Quality": "Сифат",
"Ultra": "Ультра",
"Ultra Performance": "Ультра етештереүсәнлеге",
"Native": "Натив",
"Full": "Тулы",
"Reduced": "Кәметелгән",
"Efficient": "Һөҙөмтәле",
"Precise": "Теүәл",
"Auto (VSync)": "Авто (ВСинхронизация)",
"FIFO": "ФИФО",
"Mailbox": "Почта йәшниге",
"Immediate": "Шунда уҡ",
"FPS and Graph": "FPS һәм график",
"TAA": "ТАА",
"FXAA": "ФХАА",
"No cost": "Хаҡы юҡ",
"Low cost": "Түбән хаҡ",
"Medium cost": "Уртаса хаҡ",
"High cost": "Юғары хаҡ",
"Saves time": "Ваҡытты экономиялай",
"Graphics": "Графика",
"Done": "Бөттө",
"This feature is not yet available in Horizon.": "Был функция Horizon-ла әлегә юҡ.",
"Your display or system doesn't offer HDR output.": "Һеҙҙең дисплей йәки система тәҡдим итмәй HDR сығыш.",
"Turn HDR on to use it.": "Уны ҡулланыу өсөн HDR ҡабыҙығыҙ.",
"Your GPU or graphics API doesn't support it.": "Һеҙҙең GPU йәки график API уны ҡабул итмәй.",
"%s chunks": "%s өлөштәр",
"%s per frame": "%s бер кадрға",
"%s MiB/frame": "%s МиБ/кадр",
"%s KiB/frame": "%s КиБ/кадр",
"Auto (%s)": "Авто (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Тулы деталь ер диапазоны. LOD Дистанция урындағы донъяларҙы алыҫҡараҡ һуҙырға мөмкин.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Урындағы донъяларҙа ҡырыҫ алыҫ ерҙәр. Интеграцияланған график процессорҙар 128 өлөштә туҡтай.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ҡыҫҡартылған тарта алыҫ ер ҡырыҫ: тиҙерәк был интеграцияланған GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Кәметелгән тарта алыҫ ер ҡырыҫыраҡ, GPUs өсөн ул яйлатып.",
"Field of view. Wider shows more terrain at once.": "Күренеш өлкәһе. Киңерәк бер юлы күберәк ерҙе күрһәтә.",
"Caps frames per second; a cap saves power and heat.": "Ҡапҡастар кадрҙар секундына; ҡапҡас энергияны һәм йылылыҡты һаҡлай.",
"Waits for the display's refresh: no tearing, a little latency.": "Дисплейҙы яңыртыуҙы көтә: йыртыу юҡ, бер аҙ тотҡарлыҡ.",
"Size of menus and the HUD.": "Размер меню һәм HUD.",
"Sets the options below together. Changing one makes it Custom.": "Түбәндәге варианттарҙы бергә ҡуя. Бер үҙгәртеү уны Ҡулланыусы итә.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, ут һәм ҡояш нурҙары аҡтан да сағыуыраҡ балҡый. HDR дисплей кәрәк.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Һеҙҙең дисплей&#8217;ы иң ҡара ҡара. 0 өсөн OLED; ЖК-дисплейҙар бер аҙ балҡый.",
"How bright highlights get. Auto: the peak your system reports.": "Нисек сағыу өҫтөнлөктәр ала. Авто: пик һеҙҙең система отчеттар.",
"How bright white paper and menus look. Auto: your desktop's white.": "Нисек сағыу аҡ ҡағыҙ, менюлар күренә. Авто: һеҙҙең өҫтәл&#8217;ы аҡ.",
"How bright the HUD and menus are, against paper white.": "Нисек сағыу HUD һәм менюлар, ҡағыҙ аҡ ҡаршы.",
"How far mobs, crops and machines keep working. CPU time.": "Күпме алыҫҡа мобтар, культуралар һәм машиналар эшләп тора. Процессор ваҡыты.",
"How far away mobs and items are drawn, as a share of normal.": "Нисек алыҫҡа мобтар һәм әйберҙәр тартыла, өлөшө булараҡ нормаль.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ябыҡ участкалар артында йәшеренгән ерҙәрҙе һикерә. Офф күп сығымдар талап итә.",
"Hides entities behind opaque blocks.": "Һүҙ асыҡ булмаған блоктар артында субъекттарҙы йәшерә.",
"No entity occlusion culling.": "Юҡ субъект окклюзия ҡырҡыу.",
"CPU occlusion test runs this frame.": "Процессор окклюзия һынау был кадрҙа эшләй.",
"Occlusion checks run on a worker thread.": "Окклюзия тикшерелеүҙәре эшсе ептә эшләй.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Бер кадрға тейәлгән селтәрле бүлектәр. Түбәнге тейәгәндә шымараҡ.",
"Soft shadows in corners. Costs chunk building time only.": "Мөйөштәрҙә йомшаҡ күләгәләр. Сығымдар өлөшө төҙөү ваҡыты ғына.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Алыҫ ерҙәр өсөн блок текстураларының бәләкәйерәк күсермәләре: ялтырап тороу аҙыраҡ.",
"Blends grass and water colours across biomes when building chunks.": "Үлән һәм һыу төҫтәрен биомдар буйынса ҡатнаштырып, өлөштәр төҙөгәндә.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Болот ҡатламы: өҙөлгән, яҫы (тиҙ) йәки 3D (фантазия).",
"How far clouds reach, in chunks.": "Болоттар ни тиклем алыҫҡа етә, өлөшләп.",
"Draws the sky, sun, moon and stars.": "Күкте, ҡояшты, айҙы, йондоҙҙарҙы һүрәтләй.",
"Distance fog. Off shows the edge of the loaded world.": "Дистанция томан. Off йөкләнгән донъяның ситен күрһәтә.",
"Draws rain and snow.": "Ямғыр һәм ҡар тарта.",
"How far rain and snow fall around you.": "Тирә-яғыңда ни тиклем алыҫҡа ямғыр, ҡар яуа.",
"How many particles effects spawn.": "Күпме киҫәксә эффекттары үрсетә.",
"Round shadows under mobs and items.": "Мобтар һәм әйберҙәр аҫтында түңәрәк күләгәләр.",
"Leaves with see-through gaps. Off draws them solid.": "Япраҡтары үтә күренмәле тишектәр менән. Офф уларҙы ныҡлы тарта.",
"Sorts water and glass more exactly behind each other.": "Һыу менән быяланы бер-береһе артында анығыраҡ сорттарға айыра.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Ниндәй өлөшө яңынан төҙөй көтә рамка: бер ниндәй ҙә, яҡында йәки һеҙҙең.",
"Animates water, lava, fire and other moving textures.": "Һыу, лава, ут һәм башҡа хәрәкәт итеүсе текстураларҙы йәнләндерә.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Донъя пикселдәре экран пикселе. 100% түбән тиҙерәк GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Тешле ситтәрен тигеҙләй. ФХАА: йомшағыраҡ. ТАА: иң шыма, хәрәкәт итеүсе әйберҙәр эҙҙәре булыуы мөмкин.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Донъяны бәләкәйерәк итә һәм уны ТАА менән яңынан төҙөй. Тиҙерәк, йомшағыраҡ.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR дисплейында HDR сығышы: лава, ут һәм ҡояш нурҙары сағыуыраҡ балҡый.",
"How frames reach the screen while VSync is off.": "Нисек кадрҙар экранға етә, ә VSync өҙөлгән.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Һөҙөмтәле йоҡлай сираттағы кадрға тиклем; Теүәл әйләнештәр, ваниль кеүек.",
"Fills the screen (borderless).": "Экранды тултыра (сикһеҙ).",
"Brightens dark places.": "Ҡараңғы урындарҙы яҡтырта.",
"Lowers the frame rate when minimised, or idle too.": "Кадрҙар йышлығын түбәнәйтә, ҡасан минималь, йәки буш ла.",
"Sways the view while walking.": "Йөрөгәндә ҡарашты һелкетә.",
"Fades newly loaded chunks in.": "Яңы тейәлгән өлөштәрҙе һүнә.",
"Darkens the screen's edges.": "Экрандың ситтәрен ҡарайта.",
"Shows when the world saves.": "Донъя ҡотҡарғанда күрһәтә.",
"Shows attack cooldown.": "Һөжүм итеү ваҡытын күрһәтә.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS ҡапҡас (30 менюларҙа йәки нефокусированный), яртыһы эшселәр: ҡөҙрәте аҙыраҡ.",
"Threads that generate, light and mesh chunks. Next start.": "Ептәр, генерациялау, яҡтылыҡ һәм селтәр өлөштәре. Киләһе старт.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, унан һуң DirectX 12 (Windows) йәки OpenGL (Linux) ҡайҙа уңышһыҙлыҡҡа осрай. Киләһе старт.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Һыу һәм быяла бер буферҙа: низағ шылтыратыуҙар аҙыраҡ, бер аҙ күберәк VRAM. Киләһе старт.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Селтәр мәғлүмәттәре ебәрелгән GPU бер кадр. Түбәнге тейәгәндә шымараҡ.",
"Frame rate and frame times over the game, with a graph.": "Кадрҙар тиҙлеге һәм кадрҙар ваҡыты өҫтөндә уйын, график менән.",
"horizon.multiplayer.finding_friend": "Дуҫыңдың донъяһын табыу...",
"horizon.multiplayer.not_signed_in": "Был сервер онлайн режимда. Уға ҡушылыу өсөн Microsoft иҫәп яҙмағыҙ менән Horizon стартерында инегеҙ.",
"horizon.multiplayer.invalid_address": "Дөрөҫ булмаған сервер адресы: %s",
"horizon.multiplayer.connected_friend_direct": "Һеҙҙең дуҫығыҙға туранан-тура тоташтырылған...",
"horizon.multiplayer.connected_friend_relay": "Һеҙҙең дуҫығыҙға реле аша тоташтырылған...",
"General": "Дөйөм",
"Advanced": "Алдынғы",
"Undo": "Кире ҡайтарыу",
"Search options": "Эҙләү варианттары",
"Shortcut": "Ярлыҡ",
"Distance": "Арауыҡ",
"Display": "Дисплей",
"Interface": "Интерфейс",
"Sky and weather": "Күк йөҙө һәм һауа торошо",
"Lighting and entities": "Яҡтыртыу һәм субъекттар",
"Textures": "Текстуралар",
"Image": "Һүрәт",
"Chunk loading": "Ҡыҫҡа тейәү",
"Culling": "Куллинг",
"Power": "Власть",
"Frames": "Рамкалар",
"Overlay": "Ҡаплау",
"Output": "Сығыш",
"Brightness": "Яҡтылыҡ",
"How far you see, the window and the HUD.": "Күпме алыҫ күрәһең, тәҙрә һәм HUD.",
"The sky and weather, lighting, textures and the image.": "Күк һәм һауа торошо, яҡтыртыу, текстуралар һәм образ.",
"Chunk loading, culling and power use.": "Ҡырҡылған тейәү, ҡырҡыу һәм ҡөҙрәт ҡулланыу.",
"How frames reach the screen, and the performance overlay.": "Нисек кадрҙар экранға етә, һәм башҡарыу өҫтәү.",
"Brighter-than-white output on an HDR display.": "HDR дисплейында аҡтан сағыуыраҡ сығыш.",
"Results for \"%s\"": "\"%s\" өсөн һөҙөмтәләр.",
"No settings match.": "Бер ниндәй ҙә параметрҙар тап килмәй.",
"horizon.title.not_affiliated": "Mojang йәки Microsoft менән бәйле түгел",
"horizon.extras.header.lighting": "Яҡтыртыу",
"horizon.extras.header.third_person": "Өсөнсө зат .",
"horizon.extras.header.particles": "Киҫәксәләр",
"horizon.extras.header.inventory": "Инвентаризация",
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.multiplayer": "Күп уйынсылар",
"horizon.extras.header.sound": "Тауыш .",
"horizon.extras.header.menus": "Менюлар",
"horizon.extras.fullbright": "Фуллбрайт",
"horizon.extras.dynamic_lights": "Динамик яҡтылыҡ",
"horizon.extras.own_name_tag": "Үҙ исем билдәһе",
"horizon.extras.hide_block_particles": "Блок-өҙөү киҫәксәләрен йәшерергә",
"horizon.extras.auto_reconnect": "Авто-яңынан тоташтырыу",
"horizon.extras.ui_sound_reserve": "Ҡулланыусылар интерфейсы тауыштары өсөн каналдар һаҡлау",
"horizon.extras.music_page": "Музыка бите .",
"horizon.extras.mouse_tweaks": "Сысҡандың идара итеү элементтарын үҙгәртә",
"horizon.extras.mouse_tweaks_wheel": "Прокрутка тәгәрмәсе әйберҙәрҙе күсерә",
"horizon.extras.sort": "Сортлау төймәләре",
"horizon.extras.sort_key": "Сортлау асҡысы",
"horizon.extras.no_ai": "ЯИ юҡ",
"horizon.extras.agent_control": "Агент контроле",
"horizon.extras.discord_presence": "Discord Бай булыу",
"horizon.extras.splitscreen": "Бүлгән экран",
"horizon.extras.chat_animation": "Тигеҙ һөйләшеү",
"horizon.extras.chat_indicator": "Хәбәр күрһәткесе",
"horizon.extras.wheel_cycles_settings": "Прокрутка тәгәрмәсе параметрҙарҙы үҙгәртә",
"horizon.extras.pack_browser": "Ресурс пакеты браузеры",
"horizon.extras.fullbright.tooltip": "Блоктарҙы һәм субъекттарҙы тулы яҡтылыҡта, хатта ҡараңғы урындарҙа ла күрһәтегеҙ.",
"horizon.extras.dynamic_lights.tooltip": "Факелдар һәм башҡа яҡтылыҡ сығанаҡтары тотолған, төшөрөлгән йәки ут өҫтөндә улар тирәһендәге блоктарҙы яҡтырта.",
"horizon.extras.own_name_tag.tooltip": "Һеҙҙең исемде баш өҫтөндә өсөнсө заттан күрһәтә, башҡа уйынсылар күргәнсә. Йәшерен ҡасан сервер һеҙҙең команданың исемдәрен йәшерә йәки һеҙ&#8217;күренмәй.",
"horizon.extras.hide_block_particles.tooltip": "Йәшерә сүп-сар киҫәксәләре, улар өҙөлгән блоктан ярыла һәм һеҙ шахта өҫтөндә емерек. Башҡа киҫәксәләр ҡала.",
"horizon.extras.auto_reconnect.tooltip": "Серверға тоташыу юғалғандан йәки ҡабаттан башлағандан һуң ҡабаттан ҡушыла, бер ҡасан да тибеүҙән йәки тыйыуҙан һуң.",
"horizon.extras.ui_sound_reserve.tooltip": "Донъялағы тауыштар 247 тауыш каналының 16-һын бушлай ҡалдыра, шуға күрә уларҙы ташҡын (ҙур шартлау) меню баҫыуҙарын тындыра алмай.",
"horizon.extras.music_page.tooltip": "Музыка & Тауыштар ала Музыка... бит: уйнау теләһә ниндәй встроенный трек хәҙер, йәки бер һүндереү, шулай итеп, уйын уны йыйыу туҡтата. Тректы һүндермәйенсә бер нәмә лә үҙгәрмәй.",
"horizon.extras.mouse_tweaks.tooltip": "Уң-һөйрәп урындар бер слот, һул-һөйрәп йыя үткәрелгән әйбер, Shift + һул-һөйрәп һәр слот күсә.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Аҫҡа слоттың бер әйберен икенсе яҡҡа ебәрә, өҫкә бер кире килтерә.",
"horizon.extras.sort.tooltip": "Сортировать инвентарь, һандыҡтар, бочкалар, эндер һандыҡтар һәм шулкер йәшниктәр ижади вкладка тәртибендә, стека берләштереү.",
"horizon.extras.sort_key.tooltip": "Курсор аҫтындағы өлөштө сорттарға бүлә. Баҫығыҙ, һуңынан бер төймәгә баҫығыҙ; Ҡасыу уны сисеп ебәрә.",
"horizon.extras.no_ai.tooltip": "Йәшерә һәм һүндерә һәр AI функцияһы: агент менән идара итеү, горизонт-ctl һәм MCP серверҙары. Пускатель&#8217;ы переключатель да.",
"horizon.extras.agent_control.tooltip": "Һеҙҙең үҙ AI агент был уйынды уйнай ала аша horizon-ctl йәки MCP клиент, был компьютерҙа ғына.",
"horizon.extras.discord_presence.tooltip": "Һеҙҙең Discord профилендә Horizon уйнауҙы күрһәтә, ә Discord йүгерә: менюлар, бер уйынсы йәки күп уйынсы, үлсәм һәм уйнаған ваҡыт. Бер ҡасан да донъяның исеме йәки сервер адресы.",
"Calibrate...": "Калибрлау...",
"Calibrate Anyway...": "Калибрлау барыбер...",
"Use Automatic Setup": "Автоматик ҡуйыуҙы ҡулланыу",
"Shortcut: %s": "Ҡыҫҡа юл: %s",
"Next": "Артабан",
"HDR Calibration %s/%s: %s": "HDR калибровкаһы %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Өс аҫтында тулы экран ҡуя, бер һынау һүрәте бер юлы.",
"A key that opens this screen from the game. Not bound at first.": "Уйындан был экранды асҡан асҡыс. Тәүҙә бәйләнмәгән.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Һеҙҙең дисплей нисек яҡты ала, тип әйтмәне: калибровка, уны ҡуйырға.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Һеҙҙең дисплей өсөн инде ҡуйылған. Тик калибровка, әгәр HDR дөрөҫ түгел күренә.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Ҡуя Пик Яркость һәм Ҡағыҙ Аҡ кире һеҙҙең дисплей&#8217;ы ҡороу.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Һеҙҙең дисплей өсөн ҡуйырға. Авто өҫтөндә ҡалдырырға, әгәр ҙә ул дөрөҫ күренмәһә.",
"Lower it until the logo just disappears into the black.": "Түбән уны тиклем логотип ҡара ғына юғала.",
"Raise it until the logo just disappears into the bright square.": "Логотип яҡты квадратҡа ғына юғалғансы уны күтәрегеҙ.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Картаны аҡ төҫтә эшләгеҙ, оҙаҡ ҡарарға уңайлы, һеҙ уйнаған яҡтылыҡта.",
"horizon.splitscreen.profiles.forget": "Онот .",
"horizon.splitscreen.profiles.create": "Ижад итеү",
"horizon.splitscreen.profiles.you": " - һин",
"horizon.splitscreen.profiles.forget_named": "Онот %s .",
"horizon.splitscreen.profiles.previous": "Алдағы",
"horizon.splitscreen.profiles.next": "Артабан",
"horizon.splitscreen.profiles.new_button": "Яңы профиль...",
"horizon.splitscreen.profiles.forget_title": "Профильде оноттоғоҙмо?",
"horizon.splitscreen.profiles.forget_leaves": "Ул был инстанцияның исемлегенән сыға.",
"horizon.splitscreen.profiles.forget_kept": "Донъя прогресс һәм параметрҙар һаҡлана.",
"horizon.splitscreen.profiles.new_title": "Яңы профиль",
"horizon.splitscreen.profiles.name_hint": "Исем (3-тән 16-ға тиклем хәреф, һан, _)",
"horizon.splitscreen.profiles.switch_title": "Профиль алмаштырыу",
"horizon.splitscreen.profiles.offline_note": "Офлайн профилдәр был данаға ҡарай",
"horizon.splitscreen.profiles.move": "Уйнаған ергә күсерегеҙ .",
"horizon.splitscreen.profiles.move_place": "Уйнаған урынға күсегеҙ: %s .",
"horizon.friends.title": "Дуҫтар менән уйнау .",
"horizon.friends.hint": "Дуҫтар кодты титул экранының «Дуҫтар» төймәһенән һуң, туранан-тура бәйләнештә йәки стартердың «Уйнау» битендә йәбештерә.",
"horizon.friends.copied": "Күсерелгән",
"horizon.friends.copy_code": "Код күсермәһе",
"horizon.friends.stop_sharing": "Бүлешеүҙе туҡтатығыҙ",
"horizon.friends.none_yet": "Бында әлегә дуҫтар юҡ.",
"horizon.friends.here_now": "Бында хәҙер: %s",
"horizon.friends.share_intro": "Был донъя менән бүлешергә артында саҡырыу коды: ул LAN асыла, һәм дуҫтар интернетта код менән ҡушыла. Беҙҙең бер сервер ҙа ҡатнашмай.",
"horizon.friends.start_sharing": "Бүлешергә башлағыҙ",
"horizon.friends.troubleshooting": "Неисправность...",
"horizon.friends.stopped": "Саҡырыу коды менән бүлешеүҙе туҡтатты.",
"horizon.friends.short_code_changed": "Кемдер дөрөҫ булмаған ҡыҫҡа кодты һынап ҡараны, шуға күрә ул үҙгәрҙе.",
"horizon.friends.wrong_code": "Кемдер дөрөҫ булмаған кодты ҡулланып ҡараған.",
"horizon.friends.code_ready": "Һеҙҙең саҡырыу коды әҙер: Уйын Менюһы, Дуҫтар менән уйнау. Күсереп яҙып, дуҫтарығыҙға ебәрегеҙ.",
"horizon.friends.share_failed": "Донъяны бүлешә алманым: %s",
"horizon.friends.finding_route": "Һеҙгә дуҫтар нисек барып етә алыуын табыу...",
"horizon.osk.shift": "Смена",
"horizon.osk.space": "Йыһан",
"horizon.osk.delete": "Юйырға",
"horizon.osk.type": "Тип .",
"horizon.osk.symbols": "Символдар",
"horizon.osk.close": "Ябыҡ",
"horizon.extras.chat_animation.tooltip": "Яңы чат һыҙығы һулдан шыуып инә, ә ул һүнә, урынына бөтөн күренә.",
"horizon.extras.chat_indicator.tooltip": "Чат һыҙығының һул яғында төҫлө һыҙыҡ.",
"horizon.extras.wheel_cycles_settings.tooltip": "Көйләүҙәрҙәге төймә өҫтөндәге прокрутка тәгәрмәсе үҙенең ҡиммәтен үҙгәртә, ванильдәге кеүек. Һүндерелгән, тәгәрмәс исемлекте генә йөрөтә.",
"horizon.extras.pack_browser.tooltip": "Һайлау ресурс пакеттары ала өҫтәү төймәһе, эҙләү Modrinth һәм һеҙҙең пакеттар папкаһына пакет ҡуя. Һүндерелгән, уйын бер ҡасан да бәйләнешкә инә Modrinth.",
"horizon.extras.splitscreen.tooltip": "Донъяла, икенсе контроллер яңы уйынсы булараҡ ҡушыла ала: баҫығыҙ А, ҡасан ул һорай, һуңынан профиль һайлап һәм ҡайҙа уйнау.",
"horizon.splitscreen.setup.monitor": "Монитор %s",
"horizon.splitscreen.setup.monitor_unknown": "монитор",
"horizon.splitscreen.setup.window": "Тәҙрә %s",
"horizon.splitscreen.setup.new_window": "Яңы тәҙрә",
"horizon.splitscreen.setup.fill_monitor": "Тултырырға %s",
"horizon.splitscreen.setup.done": "Бөттө",
"horizon.splitscreen.setup.on": "Өҫтөндә",
"horizon.splitscreen.setup.off": "Һүндерелгән",
"horizon.splitscreen.setup.player": "Уйынсы %s",
"horizon.splitscreen.setup.revert": "Кире ҡайтарыу",
"horizon.splitscreen.setup.apply": "Ҡулланыу",
"horizon.splitscreen.setup.warning.empty_main": "1-се тәҙрәлә бер кем дә уйнамай: ул йәшенә",
"horizon.splitscreen.setup.warning.empty_close": "Тәҙрәлә бер кем дә уйнамай %s: ул Ҡулланыуҙа ябыла",
"horizon.splitscreen.setup.warning.empty_new": "Тәҙрәлә бер кем дә уйнамай %s: ул асылмай",
"horizon.splitscreen.setup.warning.on_main": "%s тәҙрә 1-се тәҙрә мониторын тултыра: уның уйынсылары 1-се тәҙрәлә уйнай",
"horizon.splitscreen.setup.warning.unplugged": "Тәҙрә %s мониторы тоташтырылмаған: уның уйынсылары 1-се тәҙрәлә уйнай",
"horizon.splitscreen.setup.status.off": "Сплитскрин һүндерелгән.",
"horizon.splitscreen.setup.status.enable_hint": "Уны тоҡандырып, һуңынан бер донъяла икенсе контроллерҙағы төймәгә баҫығыҙ.",
"horizon.splitscreen.setup.status.join_hint": "А баҫып, ҡушылырға һәм профиль һайларға.",
"horizon.splitscreen.setup.status.idle": "Сплит-экран буш тора",
"horizon.splitscreen.setup.status.alone": "Башҡа урындағы уйынсылар юҡ.",
"horizon.splitscreen.setup.status.solo_controls": "Яңғыҙ идара итеү һәм контроллер кире бәйләнеш үҙгәрешһеҙ.",
"horizon.splitscreen.setup.window_empty": "Тәҙрә %s: буш",
"horizon.splitscreen.setup.note.select": "Плеерға баҫығыҙ, йәки уны тәҙрәгә йәки мониторға һөйрәгеҙ.",
"horizon.splitscreen.setup.note.apply": "Ҡулланыу өсөн уйнау өсөн был ысул, Ҡайтарыу өсөн кире ҡайтарыу.",
"horizon.splitscreen.setup.note.place": "%s ҡайҙа уйнарға тейеш?",
"horizon.splitscreen.setup.this_player": "был уйынсы",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen һүнә бер тапҡыр башҡа ҡалдырҙы (уларҙы пауза менюһы).",
"horizon.splitscreen.setup.window_place": "Тәҙрә %s (%s %s-нан)",
"horizon.splitscreen.setup.names": "Исемдәр .",
"horizon.splitscreen.setup.identify": "Билдәләү",
"horizon.splitscreen.setup.layout.two": "2 бүлешеү: %s",
"horizon.splitscreen.setup.layout.three": "3 бүлешеү: %s",
"horizon.splitscreen.setup.layout.two.0": "Авто",
"horizon.splitscreen.setup.layout.two.1": "Ян-яҡтан .",
"horizon.splitscreen.setup.layout.two.2": "Өҫкө һәм аҫҡы .",
"horizon.splitscreen.setup.layout.three.0": "Өҫтөндә бер киң",
"horizon.splitscreen.setup.layout.three.1": "Һул яҡта бер бейек",
"horizon.splitscreen.setup.layout.three.2": "Ян-яҡтан .",
"horizon.splitscreen.setup.help.player": "Уларҙы күсерер өсөн %s баҫығыҙ йәки һөйрәгеҙ.",
"horizon.splitscreen.setup.help.layout_auto": "%s (тәҙрә киң булғанда йәнәш). Үҙгәртергә баҫығыҙ.",
"horizon.splitscreen.setup.help.layout": "%s. Үҙгәртергә баҫығыҙ.",
"horizon.splitscreen.setup.help.switch": "Был компьютер өсөн экран бүленгән, өҫтөндә йәки өҫтөндә.",
"horizon.splitscreen.setup.help.identify": "Һәр уйынсының ҡарашы ниндәй тәҙрәлә икәнен күрһәтә.",
"horizon.splitscreen.setup.help.names": "Һәр уйынсының исеме мөйөшөндә уларҙы ҡарау.",
"horizon.splitscreen.setup.help.revert": "Әле ҡулланылмаған үҙгәрештәрҙе кире ҡайтарығыҙ.",
"horizon.splitscreen.setup.help.apply": "Картала күрһәтелгәнсә уйнағыҙ.",
"horizon.splitscreen.setup.help.unchanged": "Әлегә бер нәмә лә үҙгәрмәне.",
"horizon.splitscreen.setup.give_up_pad": "Падтан баш тартығыҙ",
"horizon.splitscreen.prompt.controller_connected": "Контроллер тоташтырылған",
"horizon.splitscreen.prompt.controller_disconnected": "Контроллер өҙөлгән",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Яңынан уны тоташтырырға, йәки икенсе төймәгә баҫығыҙ",
"horizon.splitscreen.prompt.join.one": "%s ҡушылырға теләй",
"horizon.splitscreen.prompt.join.many": "%s контроллерҙар ҡушылырға теләй",
"horizon.splitscreen.prompt.join.yes": "Ҡушылыу",
"horizon.splitscreen.prompt.join.as_p1": "Р1 булып уйнай.",
"horizon.splitscreen.prompt.join.no": "Юҡ",
"horizon.splitscreen.prompt.lost": "%s: контроллер өҙөлгән",
"horizon.splitscreen.prompt.lost.use_another": "Икенсе ҡулланыу .",
"horizon.splitscreen.prompt.lost.leave": "Ҡалдырырға",
"horizon.splitscreen.setup.help.give_up_pad": "Һеҙҙең контролер һеҙҙе водитель туҡтата һәм ҡушылырға тәҡдим ителә (А).",
"horizon.splitscreen.setup.note.held_at": "%s %s тиклем: А уны бында төшөрә, В кире һала.",
"horizon.splitscreen.setup.note.held": "%s йыйып алынған: һул/уң ҡайҙа йыйып ала, А төшә, В кире һала.",
"horizon.splitscreen.online_only": "Был сервер онлайн режимда: офлайн профилдәр уға ҡушыла алмай, һәр уйынсыға үҙ Minecraft иҫәбе кәрәк.",
"horizon.splitscreen.own_account": "Был серверға һәр уйынсының үҙ Minecraft иҫәбе кәрәк",
"horizon.splitscreen.local_only": "Башҡа уйынсылар был серверға үҙҙәренең Minecraft иҫәбенә генә ҡушыла ала: офлайн профилдәр һеҙҙең донъяла, LAN йәки һеҙҙең урындағы селтәрҙәге серверҙа уйнай.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Моделдәрҙе асыҡ булмаған блоктар артында йәшерә. Ассинхрон тикшерә тышҡы еп; субъекттар галочкой.",
"Block Entities": "Блок субъекттары",
"Shulker Boxes": "Шулкер йәшниктәре",
"Reuses settled boxes; lid movement stays animated.": "Ҡабаттан ҡуллана урынлаштырылған йәшниктәр; ҡапҡас хәрәкәте йәнле ҡала.",
"Signs": "Билдәләре",
"Reuses sign text until text, light, or font changes.": "Текст, яҡтылыҡ йәки шрифт үҙгәргәнсе билдә тексын ҡабаттан ҡуллана.",
"Decorated Pots": "Биҙәкле һауыттар .",
"Reuses still pots; break wobble and sherd changes redraw.": "Һаман да ҡаҙандарҙы ҡабаттан ҡуллана; өҙөү качаться һәм шерд үҙгәрештәр перерисовать.",
"Bells": "Ҡыңғырауҙар",
"Reuses still bells; ringing stays animated.": "Ҡабаттан ҡуллана әле ҡыңғырауҙар; шылтыратыу йәнле ҡала.",
"Chests": "Һандыҡтар",
"Reuses closed chests; moving lids stay animated.": "Ябыҡ һандыҡтарҙы ҡабаттан ҡуллана; хәрәкәт итеүсе ҡапҡастар йәнле булып ҡала.",
"Banners": "Баннерҙар",
"Reuses poles; flags and pattern layers still update.": "Бағаналарҙы ҡабаттан ҡуллана; флагтар һәм өлгө ҡатламдары һаман да яңыртыла.",
"Copper Golem Statues": "Баҡыр Голем статуялары",
"Reuses statue geometry until its block state changes.": "Блок хәле үҙгәргәнсе статуя геометрияһын ҡабаттан ҡуллана.",
"Leaf Culling": "Япраҡ ҡырҡыу",
"Every leaf face. Vanilla's fancy leaves.": "Һәр япраҡ йөҙө. Ванильдың фантазия япраҡтары.",
"Distant Plant Dither": "Алыҫ үҫемлек ҡалтыраныуы",
"Keeps fine plants in high-res packs from fading far away.": "Юғары асыҡлыҡтағы пакеттарҙағы ваҡ үҫемлектәрҙе алыҫҡа һүнеүҙән һаҡлай.",
"Screenshot Quality": "Скриншот сифаты",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 скриншоттар был күп тапҡыр уйын&#8217;ы ҡарар. Юғары оҙағыраҡ ваҡыт ала.",
"Frame Generation": "Кадр быуын",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Һәр ике ысын араһында ҡоролған кадр күрһәтә: шымараҡ, бер аҙ күберәк лаг.",
"Shadow Cache": "Күләгә кэш",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Ҡояш, ҡараш һәм донъя тап килгәндә пакет күләгә картаһын ҡабаттан ҡуллана. Һүндерелгән по умолчанию.",
"Find BlueMap": "Зәңгәр картаны табығыҙ",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Серверҙарҙа, алыҫ ерҙәр өсөн уларҙы BlueMap веб-картаһын таба. Киләһе ҡушылыу.",
"BlueMap Radius": "Зәңгәр карта радиусы",
"How far around you BlueMap tiles are downloaded. Next join.": "Һеҙҙең тирәләй ни тиклем алыҫ BlueMap плиталар скачать. Киләһе ҡушылыу.",
"Synchronous": "Синхрон",
"Asynchronous": "Ассинхрон",
"Solid": "Ҡаты",
"Solid Aggressive": "Ҡаты агрессив",
"Hollow": "Ҡыуыш",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Япраҡтар тулыһынса навес эсендә ҡаты тарта: үтә күренмәле ҡатламдар аҙыраҡ.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Япраҡтар менән бер асыҡ яғы ҡаты тарта ла: тиҙерәк, chunkier ситтәре.",
"Drops faces between leaves: faster, but gaps look through.": "Япраҡтар араһында йөҙҙәрен тамсылай: тиҙерәк, әммә тишектәр аша ҡарай.",
"Hotbar: Previous Slot": "Ҡыҙыу панелдә: Алдағы слот",
"Hotbar: Next Slot": "Ҡыҙыу панелдә: Киләһе слот",
"horizon.agent.connected.one": "Тоташтырылған: 1 агент тоташыуы",
"horizon.agent.connected.many": "Тоташтырылған: %s агент тоташыуҙары",
"horizon.agent.connected.one_ago": "Тоташтырылған: 1 агент тоташыу, һуңғы команда %s с элек",
"horizon.agent.connected.many_ago": "Тоташтырылған: %s агент тоташыуҙары, һуңғы команда %s с элек",
"horizon.agent.waiting": "Агент көтөп.",
"horizon.agent.off": "Һүндерелгән: агенттар был уйынға етә алмай.",
"horizon.agent.session_opened": "Был сессия өсөн уйынды башлаған программа асты.",
"horizon.agent.socket": "Розетка: %s (уның тулы юлы өсөн реквизиттарҙы күсерергә)",
"horizon.agent.local_only": "Урындағы ғына: был компьютерҙа агенттар, бер ҡасан да селтәр.",
"%s nits": "%s нит",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нит",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Моделдәрҙе асыҡ булмаған блоктар артында йәшерә. Яңыртыу субъекттары. Ҡулланыусылар пакеттары менән өҙөлгән.",
"horizon.extras.repro_keys": "Яҙма ярлыҡтар",
"horizon.extras.repro_keys.tooltip": "F3+R репро яҙманы башлай йәки туҡтата һәм F3+E уның өйөмөн яҙа. Ғәҙәттәгесә һүндерелгән: яҙма донъяны күсерә.",
"horizon.agent.record_session": "Яҙҙырыу сеансын башлау",
"horizon.agent.record_stop": "Яҙҙырыуҙы туҡтатыу",
"horizon.agent.export_repro": "Экспортлау Repro йыйылмаһы",
"horizon.repro.started": "Яҙҙырыу башланды; F3+R уны туҡтата һәм өйөмдө яҙа.",
"horizon.repro.stopped": "Яҙма туҡтатылды: %s",
"horizon.repro.bundle": "Репро пакеты: %s",
"horizon.repro.error": "Ҡабатлау: %s",
"horizon.repro.no_recording": "Әлегә экспортҡа сығарырлыҡ яҙма юҡ.",
"horizon.repro.replay_started": "Ҡабатлау башланды: %s",
"horizon.pack.addInfo": "Modrinth ресурстар пакетын өҫтәү",
"horizon.pack.addWorking": "Һорау Modrinth...",
"horizon.pack.addNone": "Ресурс пакеттары табылмаған",
"horizon.pack.addTitle": "Ресурстар пакетын өҫтәү",
"horizon.pack.install": "Ҡуйыу",
"horizon.pack.installing": "Ҡуйыу",
"horizon.pack.installed": "Ҡуйылған",
"horizon.pack.search": "Эҙләү",
"horizon.pack.searchHint": "Эҙләү Modrinth...",
"horizon.pack.filters": "Фильтрҙар",
"horizon.pack.filter.realistic": "Ысынбарлыҡҡа ярашлы",
"horizon.pack.filter.simplistic": "Ябайлаштырылған",
"horizon.pack.filter.vanillaLike": "Ваниль һымаҡ",
"horizon.pack.filter.x16": "16х",
"horizon.pack.filter.x32": "32х",
"horizon.pack.filter.x64": "64х",
"horizon.pack.addFailed": "Пакетты өҫтәп булманы: %s",
"horizon.extras.header.cosmetics": "Косметика",
"horizon.extras.cosmetics_mine": "Минең косметика",
"horizon.extras.cosmetics_mine.tooltip": "Йәшерен: һәр Horizon уйынсы һеҙҙең косметикаһыҙ күрә, һеҙ ҙә индерелгән. Шул уҡ переключатель стартер&#8217;s Гардероб.",
"horizon.extras.cosmetics_others": "Башҡа уйынсыларҙың косметикаһы",
"horizon.extras.cosmetics_others.tooltip": "Башҡа ниндәй Horizon уйынсылар кейә: барыһы ла, тик уларҙы исем биркаһы стилдәре, йәки бер нәмә лә. Йәшерен, уйын бер ҡасан да һорамай, улар нимә кейә.",
"horizon.extras.cosmetics_auras": "Башҡаларҙың ауралары һәм эҙҙәре",
"horizon.extras.cosmetics_auras.tooltip": "Аура һәм эҙҙәр башҡа уйынсылар кейә. Һүндерелгән по умолчанию.",
"horizon.extras.cosmetics.shown": "Күрһәтелгән",
"horizon.extras.cosmetics.hidden": "Йәшерен",
"horizon.extras.cosmetics.name_tags": "Исем билдәләре генә"
}
@@ -0,0 +1,485 @@
{
"horizon.options.controller": "Кантралёр",
"horizon.options.vibration": "Вібрацыя",
"horizon.options.adaptive_triggers": "Адаптыўныя трыгеры",
"horizon.options.lightbar": "Светлавая панэль",
"horizon.options.look_sensitivity": "Адчувальнасць агляду",
"horizon.options.invert_x": "Інверсія агляду па X",
"horizon.options.invert_y": "Інверсія агляду па Y",
"horizon.options.sneak_button": "Кнопка красціся",
"horizon.options.move_deadzone": "Мёртвая зона руху",
"horizon.options.look_deadzone": "Мёртвая зона агляду",
"horizon.options.button_glyphs": "Гліфы кнопак",
"horizon.options.menu_navigation": "Навігацыя па меню",
"horizon.options.on_screen_keyboard": "Экранная клавіятура",
"horizon.options.chat_delay": "Затрымка чата",
"horizon.options.lightbar.health": "здароўе",
"horizon.options.lightbar.dimension": "Вымярэнне",
"horizon.options.glyphs.auto": "Аўто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Паказальнік",
"horizon.options.keyboard.auto": "Аўто",
"horizon.options.unavailable": "Гэтая функцыя яшчэ недаступная ў Horizon.",
"horizon.options.search": "Налады пошуку...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Пакеты шэйдараў",
"horizon.pages.shader_options": "Параметры шэйдара",
"horizon.pages.controller": "Налады кантролера",
"horizon.pages.controller_binds": "Прывязкі кантролера",
"horizon.pages.extras": "Horizon Дадаткова",
"horizon.pages.settings": "Horizon Налады",
"horizon.pages.agent": "Агент штучнага інтэлекту",
"horizon.split.leave": "Сыходзь",
"horizon.pages.split_settings": "Налады падзеленага экрана",
"horizon.pages.split_profiles": "Хто гуляе?",
"horizon.pages.split_setup": "Налада падзеленага экрана",
"horizon.pages.music": "Музыка",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Надсвет",
"horizon.pages.music.creative": "Творчы",
"horizon.pages.music.underwater": "Пад вадой",
"horizon.pages.music.end": "Канец",
"horizon.pages.music.boss": "Бос",
"horizon.pages.music.credits": "Крэдыты",
"horizon.pages.music.overworld_biomes": "Сусветныя біёмы",
"horizon.pages.music.nether_biomes": "Ніжнія біёмы",
"horizon.pages.music.end_biomes": "Канчатковыя біёмы",
"horizon.pages.music.other": "Іншая музыка",
"horizon.pages.music.empty": "Няма музычных трэкаў: не загружаюцца гукі гульні.",
"horizon.pages.music.tooltip": "Убудаваныя трэкі ў залежнасці ад сітуацыі: прайграйце адзін зараз або адключыце любы, каб гульня перастала яго выбіраць.",
"horizon.pages.settings.tooltip": "Уласныя налады Horizon: графіка, кантролеры, дадатковыя функцыі і моды.",
"horizon.pages.graphics.tooltip": "Простая графіка: кожная налада паказвае, колькі яна каштуе.",
"horizon.pages.graphics_advanced.tooltip": "Кожная налада графікі, у тым ліку Horizon, з коштам кожнай.",
"horizon.pages.controller.tooltip": "Кантролеры: іх кнопкі, стыкі, вібрацыя і навігацыя па меню.",
"horizon.pages.extras.tooltip": "Неабавязковыя дадаткі, усе выключаны па змаўчанні: гульня застаецца яркай, пакуль вы не ўключыце адну.",
"horizon.pages.agent.tooltip": "Дазвольце вашаму ўласнаму агенту штучнага інтэлекту гуляць: кліенты horizon-ctl і MCP на гэтым кампутары.",
"horizon.pages.split_profiles.tooltip": "Хто гуляе: профіль захоўваецца ў гэтай тэчцы гульні.",
"horizon.pages.split_setup.tooltip": "Вокны і маніторы, і хто дзе гуляе.",
"horizon.pages.shader_packs.tooltip": "Пакеты шэйдараў з папкі shaderpacks: уласныя Horizon і тыя, якія знаходзяцца ў фармаце shaders/. Бяжыць адзін за адным.",
"horizon.pages.mods.tooltip": "Моды, зробленыя для Horizon, з папкі horizon-mods. Кожны працуе ў пясочніцы і застаецца выключаным, пакуль вы яго не ўключыце.",
"horizon.pages.open.graphics": "Графіка...",
"horizon.pages.open.graphics_advanced": "Пашыраная графіка...",
"horizon.pages.open.shader_packs": "Пакеты шэйдэраў...",
"horizon.pages.open.controller": "Налады кантролера...",
"horizon.pages.open.split_setup": "Раздзелены экран...",
"horizon.pages.open.extras": "Дадаткова...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "Агент штучнага інтэлекту...",
"horizon.pages.open.horizon_graphics": "Horizon Графіка...",
"horizon.pages.open.controller_binds": "Кантролер прывязвае...",
"horizon.pages.open.horizon_settings": "Horizon Налады...",
"horizon.pages.open.music": "музыка...",
"horizon.mods.empty": "Няма модаў: змясціце файлы .hmod у тэчку horizon-mods",
"horizon.mods.stopped": " (Спынена)",
"horizon.mods.not_built": " (Не пабудаваны)",
"horizon.mods.settings": "Налады",
"horizon.mods.in_world": " (пакінуць свет змяніцца)",
"horizon.mods.open_folder": "Адкрыць тэчку Mod",
"horizon.shader_packs.empty": "Пакетаў шэйдэраў няма: змесціце іх у тэчку шэйдэраў",
"horizon.shader_options.open": "Параметры шэйдара...",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Чытанне параметраў пакета шэйдараў...",
"horizon.agent.copy_details": "Скапіруйце звесткі аб падключэнні",
"horizon.multiplayer.join": "Далучайцеся",
"horizon.multiplayer.cancel_reconnecting": "Адмяніць паўторнае падключэнне",
"horizon.multiplayer.join_friend": "Далучайцеся да сяброў",
"horizon.multiplayer.invite_code": "Код запрашэння",
"horizon.multiplayer.relay": "Рэле (неабавязкова)",
"Preset": "Прадусталяваная",
"LOD Distance": "LOD Адлегласць",
"Far Detail": "Далёкая дэталь",
"Cave Culling": "Выбракоўка пячор",
"Entity Culling": "Выбракоўванне сутнасці",
"Chunk Updates": "Абнаўленні чанка",
"Upload Budget": "Бюджэт загрузкі",
"Sky": "неба",
"Fog": "Туман",
"Weather": "Надвор'е",
"Animated Textures": "Аніміраваныя тэкстуры",
"Present Mode": "Рэжым сучаснасці",
"Frame Pacing": "Тэмп кадраў",
"Worker Threads": "Працоўныя тэмы",
"Resolution Scale": "Шкала дазволу",
"Anti-aliasing": "Згладжванне",
"Upscaling": "Апскейлінг",
"Paper White": "Папера белая",
"Peak Brightness": "Пікавая яркасць",
"Black Level": "Узровень чорнага",
"UI Brightness": "Яркасць інтэрфейсу",
"Battery Mode": "Рэжым батарэі",
"Graphics API": "Графічны API",
"Translucent Batching": "Напаўпразрысты дазаванне",
"Performance Overlay": "Прадукцыйнасць Overlay",
"Full Detail Distance": "Поўная адлегласць",
"On": "Укл",
"Off": "Выкл",
"Unlimited": "Неабмежаваны",
"Auto": "Аўто",
"Custom": "Прыстасаваныя",
"Performance": "Прадукцыйнасць",
"Balanced": "Ураўнаважаны",
"Quality": "Якасць",
"Ultra": "Ультра",
"Ultra Performance": "Ультрапрадукцыйнасць",
"Native": "Натыўны",
"Full": "Поўны",
"Reduced": "Паменшаны",
"Efficient": "Эфектыўная",
"Precise": "Дакладны",
"Auto (VSync)": "Аўта (VSync)",
"Mailbox": "Паштовая скрыня",
"Immediate": "Неадкладны",
"FPS and Graph": "FPS і графік",
"No cost": "Без выдаткаў",
"Low cost": "Нізкі кошт",
"Medium cost": "Сярэдні кошт",
"High cost": "Высокі кошт",
"Saves time": "Эканоміць час",
"Graphics": "Графіка",
"Done": "Гатова",
"This feature is not yet available in Horizon.": "Гэтая функцыя яшчэ недаступная ў Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дысплэй або сістэма не забяспечвае выхад HDR.",
"Turn HDR on to use it.": "Уключыце HDR, каб выкарыстоўваць яго.",
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічны API не падтрымлівае гэта.",
"%s chunks": "%s кавалкаў",
"%s per frame": "%s за кадр",
"%s MiB/frame": "%s МіБ/кадр",
"%s KiB/frame": "%s КіБ/кадр",
"Auto (%s)": "Аўто (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Поўны дэталёвы дыяпазон мясцовасці. LOD Адлегласць можа пашырыць мясцовыя светы далей.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубая аддаленая мясцовасць у лакальных мірах. Убудаваныя графічныя працэсары спыняюцца на 128 кавалках.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Паменшанае малюнак больш грубай мясцовасці: хутчэй на гэтым убудаваным графічным працэсары.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Зніжэнне малюе больш грубую мясцовасць, для графічных працэсараў гэта запавольваецца.",
"Field of view. Wider shows more terrain at once.": "Поле зроку. Шырэй паказвае адразу больш мясцовасці.",
"Caps frames per second; a cap saves power and heat.": "Колькасць кадраў у секунду; шапка эканоміць энергію і цяпло.",
"Waits for the display's refresh: no tearing, a little latency.": "Чакае абнаўлення дысплея: без разрыву, з невялікай затрымкай.",
"Size of menus and the HUD.": "Памер меню і HUD.",
"Sets the options below together. Changing one makes it Custom.": "Разам усталёўвае параметры ніжэй. Змена аднаго робіць яго карыстальніцкім.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, агонь і сонечнае святло ззяюць ярчэй белага. Патрэбен дысплей HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самы цёмны чорны на вашым дысплеі. 0 для OLED; ВК-дысплеі трохі свецяцца.",
"How bright highlights get. Auto: the peak your system reports.": "Якія яркія моманты становяцца. Аўта: пік, пра які паведамляе ваша сістэма.",
"How bright white paper and menus look. Auto: your desktop's white.": "Як ярка выглядаюць белая папера і меню. Аўтаматычна: ваш працоўны стол белы.",
"How bright the HUD and menus are, against paper white.": "Наколькі яркія HUD і меню на фоне белай паперы.",
"How far mobs, crops and machines keep working. CPU time.": "Як далёка працягваюць працаваць мобы, ураджай і машыны. працэсарны час.",
"How far away mobs and items are drawn, as a share of normal.": "Наколькі далёка намаляваны мобы і прадметы ў долі звычайнага.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Прапускае мясцовасць, схаваную за закрытымі ўчасткамі. Off каштуе шмат.",
"Hides entities behind opaque blocks.": "Хавае аб'екты за непразрыстымі блокамі.",
"No entity occlusion culling.": "Няма выбракоўвання аклюзіі сутнасці.",
"CPU occlusion test runs this frame.": "Тэст аклюзіі ЦП запускае гэты кадр.",
"Occlusion checks run on a worker thread.": "Праверкі аклюзіі выконваюцца ў працоўным патоку.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Сеткавыя раздзелы, загружаныя на кадр. Ніжняя больш плыўная падчас загрузкі.",
"Soft shadows in corners. Costs chunk building time only.": "Мяккія цені ў кутах. Каштуе толькі час зборкі кавалкаў.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Меншыя копіі блочных тэкстур для далёкай мясцовасці: менш мігацення.",
"Blends grass and water colours across biomes when building chunks.": "Змешвае колеру травы і вады ў біёмах пры стварэнні кавалкаў.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Воблачны пласт: выключаны, плоскі (хуткі) або 3D (фантастычны).",
"How far clouds reach, in chunks.": "Наколькі дасягаюць воблакі, кавалкамі.",
"Draws the sky, sun, moon and stars.": "Малюе неба, сонца, месяц і зоркі.",
"Distance fog. Off shows the edge of the loaded world.": "Далёкі туман. Off паказвае край загружанага свету.",
"Draws rain and snow.": "Малюе дождж і снег.",
"How far rain and snow fall around you.": "Як далёка вакол цябе дождж і снег.",
"How many particles effects spawn.": "Колькі эфектаў часціц спараджае.",
"Round shadows under mobs and items.": "Круглыя цені пад мобамі і прадметамі.",
"Leaves with see-through gaps. Off draws them solid.": "Лісце з празрыстымі прасветамі. Off малюе іх цвёрда.",
"Sorts water and glass more exactly behind each other.": "Сартуе ваду і шкло больш дакладна адзін за адным.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які кавалак перабудоўваецца ў чаканні кадра: ніякі, побач ці ваш.",
"Animates water, lava, fire and other moving textures.": "Ажыўляе ваду, лаву, агонь і іншыя рухомыя тэкстуры.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Сусветныя пікселі на піксель экрана. Ніжэй за 100% хутчэй на графічным працэсары.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжвае няроўныя краю. FXAA: мякчэй. TAA: самыя гладкія рэчы, якія рухаюцца, могуць цягнуцца.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Памяншае свет і перабудоўвае яго з TAA. Хутчэй, мякчэй.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вывад HDR на дысплеі HDR: лава, агонь і сонечнае святло ззяюць ярчэй.",
"How frames reach the screen while VSync is off.": "Як кадры трапляюць на экран, калі VSync выключаны.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Эфектыўнае спіць да наступнага кадра; Дакладныя спіны, як ваніль.",
"Fills the screen (borderless).": "Запаўняе экран (без палёў).",
"Brightens dark places.": "Асвятляе цёмныя месцы.",
"Lowers the frame rate when minimised, or idle too.": "Зніжае частату кадраў пры згортванні або ў рэжыме чакання.",
"Sways the view while walking.": "Качае погляд падчас хады.",
"Fades newly loaded chunks in.": "Змяншае толькі што загружаныя кавалкі.",
"Darkens the screen's edges.": "Зацямняе краю экрана.",
"Shows when the world saves.": "Паказвае, калі свет ратуе.",
"Shows attack cooldown.": "Паказвае час аднаўлення атакі.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Абмежаванне 60 кадраў у секунду (30 у меню або без увагі), палова рабочых: меншая магутнасць.",
"Threads that generate, light and mesh chunks. Next start.": "Ніткі, якія ствараюць, лёгкія і сеткаватыя кавалкі. Наступны старт.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, затым DirectX 12 (Windows) або OpenGL (Linux), дзе не атрымліваецца. Наступны старт.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вада і шкло ў адным буферы: менш выклікаў на розыгрыш, крыху больш VRAM. Наступны старт.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Даныя сеткі, якія адпраўляюцца ў графічны працэсар за кадр. Ніжняя больш плыўная падчас загрузкі.",
"Frame rate and frame times over the game, with a graph.": "Частата кадраў і час кадраў у гульні з графікам.",
"horizon.multiplayer.finding_friend": "Пошук свету вашага сябра...",
"horizon.multiplayer.not_signed_in": "Гэты сервер знаходзіцца ў онлайн-рэжыме. Увайдзіце ў свой уліковы запіс Microsoft у праграме запуску Horizon, каб далучыцца да яе.",
"horizon.multiplayer.invalid_address": "Няправільны адрас сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Падключаны непасрэдна да вашага сябра...",
"horizon.multiplayer.connected_friend_relay": "Падключаны да вашага сябра праз рэле...",
"General": "Генерал",
"Advanced": "Пашыраны",
"Undo": "Адмяніць",
"Search options": "Параметры пошуку",
"Shortcut": "Ярлык",
"Distance": "Дыстанцыя",
"Display": "Дысплей",
"Interface": "Інтэрфейс",
"Sky and weather": "Неба і надвор'е",
"Lighting and entities": "Асвятленне і сутнасці",
"Textures": "Тэкстуры",
"Image": "Малюнак",
"Chunk loading": "Загрузка чанка",
"Culling": "Выбракоўка",
"Power": "Магутнасць",
"Frames": "Рамкі",
"Overlay": "Накладка",
"Output": "Выхад",
"Brightness": "Яркасць",
"How far you see, the window and the HUD.": "Як далёка вы бачыце, акно і HUD.",
"The sky and weather, lighting, textures and the image.": "Неба і надвор'е, асвятленне, тэкстуры і малюнак.",
"Chunk loading, culling and power use.": "Загрузка кавалкаў, выбракоўванне і выкарыстанне энергіі.",
"How frames reach the screen, and the performance overlay.": "Як кадры трапляюць на экран і накладанне прадукцыйнасці.",
"Brighter-than-white output on an HDR display.": "Ярчэйшы за белы выхад на HDR-дысплеі.",
"Results for \"%s\"": "Вынікі для \"%s\"",
"No settings match.": "Налады не супадаюць.",
"horizon.title.not_affiliated": "Не звязаны з Mojang або Microsoft",
"horizon.extras.header.lighting": "Асвятленне",
"horizon.extras.header.third_person": "Трэцяя асоба",
"horizon.extras.header.particles": "Часціцы",
"horizon.extras.header.inventory": "Інвентар",
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.multiplayer": "Мультыплэер",
"horizon.extras.header.sound": "Гук",
"horizon.extras.header.menus": "Меню",
"horizon.extras.fullbright": "Фулбрайт",
"horizon.extras.dynamic_lights": "Дынамічныя фары",
"horizon.extras.own_name_tag": "Уласная бірка з імем",
"horizon.extras.hide_block_particles": "Схаваць часціцы, якія разбіваюць блок",
"horizon.extras.auto_reconnect": "Аўтаматычнае падключэнне",
"horizon.extras.ui_sound_reserve": "Захоўвайце каналы для гукаў інтэрфейсу",
"horizon.extras.music_page": "Музычная старонка",
"horizon.extras.mouse_tweaks": "Элементы кіравання Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Кола пракруткі перамяшчае элементы",
"horizon.extras.sort": "Кнопкі сартавання",
"horizon.extras.sort_key": "Ключ сартавання",
"horizon.extras.agent_control": "Агентскі кантроль",
"horizon.extras.splitscreen": "Раздзелены экран",
"horizon.extras.chat_animation": "Плаўны чат",
"horizon.extras.chat_indicator": "Індыкатар паведамлення",
"horizon.extras.wheel_cycles_settings": "Кола пракруткі змяняе налады",
"horizon.extras.pack_browser": "Браўзэр Resource Pack",
"horizon.extras.fullbright.tooltip": "Паказвайце блокі і аб'екты пры поўным асвятленні, нават у цёмных месцах.",
"horizon.extras.dynamic_lights.tooltip": "Факелы і іншыя крыніцы святла, якія трымаюць, упускаюць або гараць, асвятляюць блокі вакол іх.",
"horizon.extras.own_name_tag.tooltip": "Паказвае ваша імя над галавой ад трэцяй асобы, як яго бачаць іншыя гульцы. Схаваны, калі сервер хавае назвы вашай каманды або вы нябачныя.",
"horizon.extras.hide_block_particles.tooltip": "Хавае часціцы смецця, якія вырываюцца з разбітага блока і рассыпаюцца на той, які вы здабываеце. Іншыя часціцы застаюцца.",
"horizon.extras.auto_reconnect.tooltip": "Паўторна далучаецца да сервера пасля страты злучэння або перазагрузкі, ніколі пасля кіку або забароны.",
"horizon.extras.ui_sound_reserve.tooltip": "Гукі ў свеце пакідаюць 16 з 247 гукавых каналаў свабоднымі, таму іх паток (вялікі выбух) не можа заглушыць націсканне меню.",
"horizon.extras.music_page.tooltip": "«Музыка і гукі» атрымлівае старонку «Музыка...»: прайграйце любы ўбудаваны трэк зараз або адключыце адзін, каб гульня перастала яго выбіраць. Нічога не зменіцца, пакуль вы не адключыце трэк.",
"horizon.extras.mouse_tweaks.tooltip": "Перацягванне правай кнопкай мышы змяшчае па адным у слот, перацягванне левай кнопкай збірае ўтрымоўваны прадмет, Shift + перацягванне левай кнопкай мышы перамяшчае кожны слот.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Уніз адпраўляе адзін элемент слота на другі бок, уверх вяртае адзін назад.",
"horizon.extras.sort.tooltip": "Сартуе інвентар, куфры, бочкі, эндэрскія куфры і шулкерныя скрыні ў творчым парадку ўкладак, аб'ядноўваючы стосы.",
"horizon.extras.sort_key.tooltip": "Сартуе частку пад курсорам. Націсніце, затым націсніце клавішу; Уцёкі развязвае яго.",
"horizon.extras.no_ai.tooltip": "Хавае і адключае ўсе функцыі штучнага інтэлекту: кіраванне агентамі, horizon-ctl і серверы MCP. Пераключальнік пускавы ўстаноўкі таксама.",
"horizon.extras.agent_control.tooltip": "Ваш уласны агент штучнага інтэлекту можа гуляць у гэтую гульню праз horizon-ctl або кліент MCP толькі на гэтым камп'ютары.",
"horizon.extras.discord_presence.tooltip": "Паказвае Playing Horizon у вашым профілі Discord падчас запуску Discord: меню, адзіночны або шматкарыстальніцкі рэжым, памер і час гульні. Ніколі не назва свету або адрас сервера.",
"Calibrate...": "Адкалібраваць...",
"Calibrate Anyway...": "Усё роўна адкалібраваць...",
"Use Automatic Setup": "Выкарыстоўвайце аўтаматычную наладу",
"Shortcut: %s": "Ярлык: %s",
"Next": "Далей",
"HDR Calibration %s/%s: %s": "Каліброўка HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Устанаўлівае тры ніжэй на ўвесь экран, па адным тэставым малюнку.",
"A key that opens this screen from the game. Not bound at first.": "Ключ, які адкрывае гэты экран з гульні. Спачатку не звязаны.",
"Your display didn't say how bright it gets: calibrate to set it up.": "На вашым дысплеі не пазначана, наколькі ён яркім: адкалібруйце, каб наладзіць яго.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ужо наладжана для вашага дысплея. Калібруйце, толькі калі HDR выглядае няправільна.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Вяртае пікавую яркасць і папяровы белы ў налады вашага дысплея.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Наладзьце для вашага дысплея. Пакіньце на Auto, калі гэта не выглядае няправільна.",
"Lower it until the logo just disappears into the black.": "Апускайце яго, пакуль лагатып проста не знікне ў чорным колеры.",
"Raise it until the logo just disappears into the bright square.": "Падымайце яго, пакуль лагатып не знікне ў яркім квадраце.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Зрабіце карту белай, каб на яе было зручна глядзець на працягу доўгага часу пры святле, пры якім вы гуляеце.",
"horizon.splitscreen.profiles.forget": "Забыць",
"horizon.splitscreen.profiles.create": "Ствараць",
"horizon.splitscreen.profiles.you": " - вы",
"horizon.splitscreen.profiles.forget_named": "Забыць %s",
"horizon.splitscreen.profiles.previous": "Папярэдні",
"horizon.splitscreen.profiles.next": "Далей",
"horizon.splitscreen.profiles.new_button": "Новы профіль...",
"horizon.splitscreen.profiles.forget_title": "Забыць профіль?",
"horizon.splitscreen.profiles.forget_leaves": "Ён пакідае спіс гэтага экземпляра.",
"horizon.splitscreen.profiles.forget_kept": "Сусветны прагрэс і налады захоўваюцца.",
"horizon.splitscreen.profiles.new_title": "Новы профіль",
"horizon.splitscreen.profiles.name_hint": "Імя (ад 3 да 16 літар, лічбаў, _)",
"horizon.splitscreen.profiles.switch_title": "Пераключыць профіль",
"horizon.splitscreen.profiles.offline_note": "Афлайн-профілі належаць да гэтага асобніка",
"horizon.splitscreen.profiles.move": "Рухайцеся туды, дзе гуляеце",
"horizon.splitscreen.profiles.move_place": "Рухайцеся туды, дзе вы гуляеце: %s",
"horizon.friends.title": "Гуляць з сябрамі",
"horizon.friends.hint": "Сябры ўстаўце код пасля кнопкі \"Сябры\" на тытульным экране ў раздзеле \"Прамое злучэнне\" або на старонцы \"Прайграванне\" праграмы запуску.",
"horizon.friends.copied": "Скапіравана",
"horizon.friends.copy_code": "Скапіруйце код",
"horizon.friends.stop_sharing": "Спыніць абагульванне",
"horizon.friends.none_yet": "Тут пакуль няма сяброў.",
"horizon.friends.here_now": "Вось зараз: %s",
"horizon.friends.share_intro": "Падзяліцеся гэтым светам за кодам запрашэння: ён адкрываецца ў лакальнай сеткі, і сябры ў Інтэрнэце далучаюцца з кодам. Наш сервер не задзейнічаны.",
"horizon.friends.start_sharing": "Пачніце абагульваць",
"horizon.friends.troubleshooting": "Ліквідацыя непаладак...",
"horizon.friends.stopped": "Абагульванне кода запрашэння спынена.",
"horizon.friends.short_code_changed": "Хтосьці спрабаваў увесці няправільны кароткі код, таму ён змяніўся.",
"horizon.friends.wrong_code": "Хтосьці паспрабаваў увесці няправільны код.",
"horizon.friends.code_ready": "Ваш код запрашэння гатовы: меню гульні, гульня з сябрамі. Скапіруйце яго і адпраўце сваім сябрам.",
"horizon.friends.share_failed": "Не атрымалася падзяліцца светам: %s",
"horizon.friends.finding_route": "Пошук, як сябры могуць звязацца з вамі...",
"horizon.osk.shift": "зрух",
"horizon.osk.space": "космас",
"horizon.osk.delete": "Выдаліць",
"horizon.osk.type": "Тып",
"horizon.osk.symbols": "Сімвалы",
"horizon.osk.close": "Блізка",
"horizon.extras.chat_animation.tooltip": "Новы радок чата слізгае злева, пакуль ён знікае, замест таго, каб выглядаць цалкам.",
"horizon.extras.chat_indicator.tooltip": "Каляровая паласа злева ад радка чата.",
"horizon.extras.wheel_cycles_settings.tooltip": "Кола пракруткі над кнопкай у наладах змяняе яе значэнне, як у vanilla. Выключана, кола толькі пракручвае спіс.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs атрымлівае кнопку «Дадаць», якая выконвае пошук у Modrinth і ўсталёўвае пакет у вашу тэчку пакетаў. Выключана, гульня ніколі не звязваецца з Modrinth.",
"horizon.extras.splitscreen.tooltip": "У свеце іншы кантролер можа далучыцца як новы гулец: націсніце A, калі ён запытае, затым выберыце профіль і дзе гуляць.",
"horizon.splitscreen.setup.monitor": "Манітор %s",
"horizon.splitscreen.setup.monitor_unknown": "манітор",
"horizon.splitscreen.setup.window": "Акно %s",
"horizon.splitscreen.setup.new_window": "Новае акно",
"horizon.splitscreen.setup.fill_monitor": "Запоўніце %s",
"horizon.splitscreen.setup.done": "Гатова",
"horizon.splitscreen.setup.on": "Укл",
"horizon.splitscreen.setup.off": "Выкл",
"horizon.splitscreen.setup.player": "Гулец %s",
"horizon.splitscreen.setup.revert": "Вярнуць",
"horizon.splitscreen.setup.apply": "Ужыць",
"horizon.splitscreen.setup.warning.empty_main": "Ніхто не гуляе ў Window 1: яна хаваецца",
"horizon.splitscreen.setup.warning.empty_close": "Ніхто не гуляе ў Window %s: яно зачыняецца пры ўжыванні",
"horizon.splitscreen.setup.warning.empty_new": "Ніхто не гуляе ў Window %s: яно не адчыняецца",
"horizon.splitscreen.setup.warning.on_main": "Window %s запаўняе манітор Window 1: яго гульцы гуляюць у Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Манітор Window %s не падключаны: яго гульцы гуляюць у Window 1",
"horizon.splitscreen.setup.status.off": "Splitscreen выключаны.",
"horizon.splitscreen.setup.status.enable_hint": "Уключыце яго, затым націсніце кнопку на іншым кантролеры ў свеце.",
"horizon.splitscreen.setup.status.join_hint": "Націсніце A, каб далучыцца і выбраць профіль.",
"horizon.splitscreen.setup.status.idle": "Раздзелены экран у рэжыме чакання",
"horizon.splitscreen.setup.status.alone": "Няма іншых мясцовых гульцоў.",
"horizon.splitscreen.setup.status.solo_controls": "Індывідуальныя элементы кіравання і зваротная сувязь кантролера не змяніліся.",
"horizon.splitscreen.setup.window_empty": "Акно %s: пустое",
"horizon.splitscreen.setup.note.select": "Пстрыкніце прайгравальнік або перацягніце яго ў акно або на манітор.",
"horizon.splitscreen.setup.note.apply": "Падайце заяўку, каб гуляць такім чынам, вярніце, каб адмяніць.",
"horizon.splitscreen.setup.note.place": "Дзе гуляць %s?",
"horizon.splitscreen.setup.this_player": "гэты гулец",
"horizon.splitscreen.setup.switch_disabled": "Раздзелены экран выключаецца, як толькі іншыя сыдуць (іх меню паўзы).",
"horizon.splitscreen.setup.window_place": "Акно %s (%s з %s)",
"horizon.splitscreen.setup.names": "Імёны",
"horizon.splitscreen.setup.identify": "Ідэнтыфікаваць",
"horizon.splitscreen.setup.layout.two": "2 абмен: %s",
"horizon.splitscreen.setup.layout.three": "3 абмен: %s",
"horizon.splitscreen.setup.layout.two.0": "Аўто",
"horizon.splitscreen.setup.layout.two.1": "Бок аб бок",
"horizon.splitscreen.setup.layout.two.2": "Зверху і знізу",
"horizon.splitscreen.setup.layout.three.0": "Адзін шырокі зверху",
"horizon.splitscreen.setup.layout.three.1": "Адзін высокі злева",
"horizon.splitscreen.setup.layout.three.2": "Бок аб бок",
"horizon.splitscreen.setup.help.player": "Націсніце або перацягніце %s, каб перамясціць іх.",
"horizon.splitscreen.setup.help.layout_auto": "%s (побач, калі акно шырокае). Націсніце, каб змяніць.",
"horizon.splitscreen.setup.help.layout": "%s. Націсніце, каб змяніць.",
"horizon.splitscreen.setup.help.switch": "Splitscreen для гэтага кампутара, уключаны або выключаны.",
"horizon.splitscreen.setup.help.identify": "Погляд кожнага гульца паказвае, у якім акне ён знаходзіцца.",
"horizon.splitscreen.setup.help.names": "Імя кожнага гульца ў куце іх агляду.",
"horizon.splitscreen.setup.help.revert": "Адмяніць яшчэ не прымененыя змены.",
"horizon.splitscreen.setup.help.apply": "Гуляйце так, як паказвае карта.",
"horizon.splitscreen.setup.help.unchanged": "Пакуль нічога не змянілася.",
"horizon.splitscreen.setup.give_up_pad": "Адмоўцеся ад Pad",
"horizon.splitscreen.prompt.controller_connected": "Кантролер падключаны",
"horizon.splitscreen.prompt.controller_disconnected": "Кантролер адключаны",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Зноў падключыце яго або націсніце кнопку на іншым",
"horizon.splitscreen.prompt.join.one": "%s хоча далучыцца",
"horizon.splitscreen.prompt.join.many": "%s кантралёраў хочуць далучыцца",
"horizon.splitscreen.prompt.join.yes": "Далучайцеся",
"horizon.splitscreen.prompt.join.as_p1": "Гуляць як P1",
"horizon.splitscreen.prompt.join.no": "няма",
"horizon.splitscreen.prompt.lost": "%s: кантролер адключаны",
"horizon.splitscreen.prompt.lost.use_another": "Выкарыстоўвайце іншы",
"horizon.splitscreen.prompt.lost.leave": "Сыходзь",
"horizon.splitscreen.setup.help.give_up_pad": "Ваш кантролер перастае кіраваць вамі і прапануе далучыцца (A).",
"horizon.splitscreen.setup.note.held_at": "%s да %s: А кідае яго сюды, Б ставіць назад.",
"horizon.splitscreen.setup.note.held": "%s падняў: левы/правы выбіраюць, дзе, А кідае, Б ставіць назад.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Хавае мадэлі за непразрыстымі блокамі. Async правярае па-за патокам; суб'ектаў цік.",
"Block Entities": "Блокавыя аб'екты",
"Shulker Boxes": "Скрынкі Шулкера",
"Reuses settled boxes; lid movement stays animated.": "Паўторна выкарыстоўвае адстойнікі; рух вечка застаецца ажыўленым.",
"Signs": "Знакі",
"Reuses sign text until text, light, or font changes.": "Паўторна выкарыстоўвае тэкст знака, пакуль не зменіцца тэкст, падсвятленне або шрыфт.",
"Decorated Pots": "Упрыгожаныя гаршкі",
"Reuses still pots; break wobble and sherd changes redraw.": "Паўторна выкарыстоўвае гаршкі; перапыніць хістанне і асколак змены перамаляваць.",
"Bells": "Званы",
"Reuses still bells; ringing stays animated.": "Паўторна выкарыстоўвае яшчэ званы; званок застаецца аніміраваным.",
"Chests": "Сундукі",
"Reuses closed chests; moving lids stay animated.": "Паўторна выкарыстоўвае закрытыя куфры; рухомыя павекі застаюцца ажыўленымі.",
"Banners": "Банеры",
"Reuses poles; flags and pattern layers still update.": "Паўторна выкарыстоўвае слупы; сцягі і ўзоры слаёў па-ранейшаму абнаўляюцца.",
"Copper Golem Statues": "Медныя статуі голема",
"Reuses statue geometry until its block state changes.": "Паўторна выкарыстоўвае геаметрыю статуі, пакуль не зменіцца яе стан блока.",
"Leaf Culling": "Выбракоўванне лісця",
"Every leaf face. Vanilla's fancy leaves.": "Кожны ліст твар. Вострыя лісце ванілі.",
"Distant Plant Dither": "Дыстантны завод",
"Keeps fine plants in high-res packs from fading far away.": "Захоўвае выдатныя расліны ў пакетах з высокім дазволам ад выцвітання.",
"Screenshot Quality": "Якасць скрыншота",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Скрыншоты F2 з дазволам, які шмат разоў перавышае дазвол гульні. Вышэйшы займае больш часу.",
"Frame Generation": "Генерацыя фрэймаў",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Паказвае прыдуманую рамку паміж двума сапраўднымі: больш гладкая, з крыху большым адставаннем.",
"Shadow Cache": "Ценявы кэш",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Паўторна выкарыстоўвае карту ценяў зграі, калі сонца, выгляд і свет супадаюць. Выключана па змаўчанні.",
"Find BlueMap": "Знайдзіце BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "На серверах знаходзіць вэб-карту BlueMap для далёкай мясцовасці. Далей далучыцца.",
"BlueMap Radius": "Радыус BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Як далёка вакол вас загружаюцца пліткі BlueMap. Далей далучыцца.",
"Synchronous": "Сінхронныя",
"Asynchronous": "Асінхронны",
"Solid": "Цвёрды",
"Solid Aggressive": "Суцэльны агрэсіўны",
"Hollow": "Полая",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Лісце цалкам унутры полага малююць суцэльна: менш празрыстых слаёў.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Лісце з адным адкрытым бокам таксама атрымліваюцца цвёрдымі: больш хуткія, каржакаватыя краю.",
"Drops faces between leaves: faster, but gaps look through.": "Апускае грані паміж лісцікамі: хутчэй, але шчыліны праглядваюць.",
"Hotbar: Previous Slot": "Гарачая панэль: папярэдні слот",
"Hotbar: Next Slot": "Гарачая панэль: наступны слот",
"horizon.agent.connected.one": "Падключана: 1 падключэнне агента",
"horizon.agent.connected.many": "Падключана: %s агентскіх злучэнняў",
"horizon.agent.connected.one_ago": "Падключана: 1 падключэнне агента, апошняя каманда %s с таму",
"horizon.agent.connected.many_ago": "Падключана: %s злучэнняў агента, апошняя каманда %s с таму",
"horizon.agent.waiting": "Чакаю агента.",
"horizon.agent.off": "Выключана: агенты не могуць атрымаць доступ да гэтай гульні.",
"horizon.agent.session_opened": "Адкрыты для гэтай сесіі праграмай, якая запусціла гульню.",
"horizon.agent.socket": "Сокет: %s (скапіруйце дэталі для поўнага шляху)",
"horizon.agent.local_only": "Толькі лакальна: агенты на гэтым кампутары, ніколі ў сетцы.",
"%s nits": "%s ніт",
"Auto (%s nits)": "Аўта (%s ніт)",
"%.2f nits": "%.2f ніт",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Хавае мадэлі за непразрыстымі блокамі. Абнаўленне суб'ектаў. Выключыце спецыяльныя пакеты.",
"horizon.extras.repro_keys": "Ярлыкі для запісу",
"horizon.extras.repro_keys.tooltip": "F3+R запускае або спыняе прайгравальны запіс, а F3+E запісвае пакет. Выключана па змаўчанні: запіс капіруе свет.",
"horizon.agent.record_session": "Пачаць сеанс запісу",
"horizon.agent.record_stop": "Спыніць сеанс запісу",
"horizon.agent.export_repro": "Экспарт Repro Bundle",
"horizon.repro.started": "Запіс пачаўся; F3+R спыняе яго і запісвае пакет.",
"horizon.repro.stopped": "Запіс спынены: %s",
"horizon.repro.bundle": "Пакет рэпрадукцый: %s",
"horizon.repro.error": "Паўтор: %s",
"horizon.repro.no_recording": "Пакуль няма запісу для экспарту.",
"horizon.repro.replay_started": "Паўтор пачаўся: %s",
"horizon.pack.addInfo": "Дадайце пакет рэсурсаў з Modrinth",
"horizon.pack.addWorking": "Запыт да Modrinth...",
"horizon.pack.addNone": "Пакеты рэсурсаў не знойдзены",
"horizon.pack.addTitle": "Дадайце пакет рэсурсаў",
"horizon.pack.install": "Усталяваць",
"horizon.pack.installing": "Ўстаноўка",
"horizon.pack.installed": "Усталяваны",
"horizon.pack.search": "Пошук",
"horizon.pack.searchHint": "Пошук у Modrinth...",
"horizon.pack.filters": "Фільтры",
"horizon.pack.filter.realistic": "Рэалістычны",
"horizon.pack.filter.simplistic": "Спрошчаны",
"horizon.pack.filter.vanillaLike": "Ванільны",
"horizon.extras.header.cosmetics": "Касметыка",
"horizon.extras.cosmetics_mine": "Мая касметыка",
"horizon.extras.cosmetics_mine.tooltip": "Схаваны: кожны гулец Horizon бачыць вас без вашай касметыкі, у тым ліку і вас. The same switch as the launcher's Wardrobe.",
"horizon.extras.cosmetics_others": "Касметыка іншых гульцоў",
"horizon.extras.cosmetics_others.tooltip": "Што носяць іншыя гульцы Horizon: усё, толькі стылі тэгаў з імёнамі або нічога. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_auras": "Чужыя аўры і шлейфы",
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Выключана па змаўчанні.",
"horizon.extras.cosmetics.shown": "Паказана",
"horizon.extras.cosmetics.hidden": "Схаваны",
"horizon.extras.cosmetics.name_tags": "Толькі біркі з імёнамі"
}
@@ -0,0 +1,481 @@
{
"horizon.options.controller": "Контролер",
"horizon.options.vibration": "Вибрация",
"horizon.options.adaptive_triggers": "Адаптивни тригери",
"horizon.options.lightbar": "Светлинна лента",
"horizon.options.look_sensitivity": "Чувствителност на погледа",
"horizon.options.invert_x": "Обръщане на оста X на погледа",
"horizon.options.invert_y": "Обръщане на оста Y на погледа",
"horizon.options.sneak_button": "Бутон за промъкване",
"horizon.options.move_deadzone": "Мъртва зона на движението",
"horizon.options.look_deadzone": "Мъртва зона на погледа",
"horizon.options.button_glyphs": "Глифове на бутони",
"horizon.options.menu_navigation": "Навигация в менюто",
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
"horizon.options.chat_delay": "Забавяне на чата",
"horizon.options.lightbar.health": "здраве",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "показалец",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.steam": "Пара",
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
"horizon.options.search": "Настройки за търсене...",
"horizon.pages.mods": "Модификации",
"horizon.pages.shader_packs": "Шейдърни пакети",
"horizon.pages.shader_options": "Опции на шейдъра",
"horizon.pages.controller": "Настройки на контролера",
"horizon.pages.controller_binds": "Контролерът се свързва",
"horizon.pages.agent": "AI агент",
"horizon.split.leave": "тръгвай",
"horizon.pages.split_settings": "Настройки за разделен екран",
"horizon.pages.split_profiles": "Кой играе?",
"horizon.pages.split_setup": "Настройка на разделен екран",
"horizon.pages.music": "Музика",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Надземният свят",
"horizon.pages.music.creative": "Творчески",
"horizon.pages.music.underwater": "Под вода",
"horizon.pages.music.end": "Краят",
"horizon.pages.music.boss": "Шефе",
"horizon.pages.music.credits": "Кредити",
"horizon.pages.music.overworld_biomes": "Биоми от надземния свят",
"horizon.pages.music.nether_biomes": "Долни биоми",
"horizon.pages.music.end_biomes": "Крайни биоми",
"horizon.pages.music.other": "Друга музика",
"horizon.pages.music.empty": "Няма музикални записи: звуците на играта не се зареждат.",
"horizon.pages.music.tooltip": "Вградените песни според ситуацията: пуснете една сега или изключете някоя, така че играта да спре да я избира.",
"horizon.pages.settings.tooltip": "Собствени настройки на Horizon: графики, контролери, екстри и модове.",
"horizon.pages.graphics.tooltip": "Графика просто: всяка настройка казва колко струва.",
"horizon.pages.graphics_advanced.tooltip": "Всяка графична настройка, включително Horizon, с цената на всяка.",
"horizon.pages.controller.tooltip": "Контролери: техните бутони, стикове, вибрация и навигация в менюто.",
"horizon.pages.extras.tooltip": "Незадължителни добавки, всички изключени по подразбиране: играта остава ванилия, докато не включите някоя.",
"horizon.pages.agent.tooltip": "Нека вашият собствен AI агент играе: horizon-ctl и MCP клиенти на този компютър.",
"horizon.pages.split_profiles.tooltip": "Кой играе: профил, съхраняван в тази папка на играта.",
"horizon.pages.split_setup.tooltip": "Прозорците и мониторите и кой къде играе.",
"horizon.pages.shader_packs.tooltip": "Шейдърни пакети от папката shaderpacks: собствените на Horizon и тези във формата shaders/. Един тича наведнъж.",
"horizon.pages.mods.tooltip": "Модификации, направени за Horizon, от папката horizon-mods. Всеки работи в пясъчна кутия и остава изключен, докато не го включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Разширена графика...",
"horizon.pages.open.shader_packs": "Шейдър пакети...",
"horizon.pages.open.controller": "Настройки на контролера...",
"horizon.pages.open.split_setup": "Разделен екран...",
"horizon.pages.open.extras": "Екстри...",
"horizon.pages.open.mods": "Модове...",
"horizon.pages.open.agent": "AI агент...",
"horizon.pages.open.controller_binds": "Контролерът се свързва...",
"horizon.pages.open.horizon_settings": "Настройки на Horizon...",
"horizon.pages.open.music": "музика...",
"horizon.mods.empty": "Без модификации: поставете .hmod файлове в папката horizon-mods",
"horizon.mods.stopped": " (спряно)",
"horizon.mods.not_built": " (Не е построен)",
"horizon.mods.settings": "Настройки",
"horizon.mods.in_world": " (оставете света да се промени)",
"horizon.mods.open_folder": "Отворете папката на Mod",
"horizon.shader_packs.empty": "Без шейдър пакети: поставете ги в папката shaderpacks",
"horizon.shader_options.open": "Опции за шейдър...",
"horizon.shader_options.profile": "Профил",
"horizon.shader_options.loading": "Четене на опциите на шейдър пакета...",
"horizon.agent.copy_details": "Копиране на подробности за връзката",
"horizon.multiplayer.join": "Присъединете се",
"horizon.multiplayer.cancel_reconnecting": "Отказ Повторно свързване",
"horizon.multiplayer.join_friend": "Присъединете се към приятел",
"horizon.multiplayer.invite_code": "Код за покана",
"horizon.multiplayer.relay": "Реле (по избор)",
"Preset": "Предварителна настройка",
"LOD Distance": "LOD разстояние",
"Far Detail": "Далечен детайл",
"Cave Culling": "Пещерно избиване",
"Entity Culling": "Отстраняване на субекти",
"Chunk Updates": "Актуализации на парчета",
"Upload Budget": "Бюджет за качване",
"Sky": "небе",
"Fog": "мъгла",
"Weather": "Времето",
"Animated Textures": "Анимирани текстури",
"Present Mode": "Настоящ режим",
"Frame Pacing": "Темпо на рамката",
"Worker Threads": "Работни нишки",
"Resolution Scale": "Скала на резолюцията",
"Upscaling": "Премащабиране",
"Paper White": "Бяла хартия",
"Peak Brightness": "Пикова яркост",
"Black Level": "Черно ниво",
"UI Brightness": "Яркост на потребителския интерфейс",
"Battery Mode": "Режим на батерия",
"Graphics API": "Графичен API",
"Translucent Batching": "Полупрозрачно пакетиране",
"Performance Overlay": "Наслагване на ефективността",
"Full Detail Distance": "Пълно подробно разстояние",
"On": "включено",
"Off": "Изкл",
"Unlimited": "Неограничен",
"Auto": "Авто",
"Custom": "По поръчка",
"Performance": "Изпълнение",
"Balanced": "Балансиран",
"Quality": "качество",
"Ultra": "Ултра",
"Ultra Performance": "Ултра производителност",
"Native": "Роден",
"Full": "Пълна",
"Reduced": "Намалена",
"Efficient": "Ефективно",
"Precise": "Прецизен",
"Auto (VSync)": "Автоматично (VSync)",
"Mailbox": "Пощенска кутия",
"Immediate": "Незабавно",
"FPS and Graph": "FPS и графика",
"No cost": "Без разходи",
"Low cost": "Ниска цена",
"Medium cost": "Средна цена",
"High cost": "Висока цена",
"Saves time": "Спестява време",
"Graphics": "Графика",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Тази функция все още не е налична в Horizon.",
"Your display or system doesn't offer HDR output.": "Вашият дисплей или система не предлага HDR изход.",
"Turn HDR on to use it.": "Включете HDR, за да го използвате.",
"Your GPU or graphics API doesn't support it.": "Вашият GPU или графичен API не го поддържат.",
"%s chunks": "%s парчета",
"%s per frame": "%s на кадър",
"%s MiB/frame": "%s MiB/кадър",
"%s KiB/frame": "%s KiB/кадър",
"Auto (%s)": "Авто (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Пълен детайлен обхват на терена. LOD Разстоянието може да разшири локалните светове по-далеч.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Груб далечен терен в местните светове. Интегрираните графични процесори спират на 128 парчета.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Намалено чертане на по-груб терен: по-бързо с този интегриран GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Намалено рисува далеч по-груб терен, за GPU се забавя.",
"Field of view. Wider shows more terrain at once.": "Зрително поле. По-широко показва повече терен наведнъж.",
"Caps frames per second; a cap saves power and heat.": "Максимални кадри в секунда; капачката спестява енергия и топлина.",
"Waits for the display's refresh: no tearing, a little latency.": "Изчаква опресняване на дисплея: без разкъсване, малко забавяне.",
"Size of menus and the HUD.": "Размер на менютата и HUD.",
"Sets the options below together. Changing one makes it Custom.": "Задава заедно опциите по-долу. Промяната на един го прави персонализиран.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лавата, огънят и слънчевата светлина блестят по-ярко от бялото. Има нужда от HDR дисплей.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Вашият дисплей е най-тъмно черен. 0 за OLED; LCD дисплеите светят малко.",
"How bright highlights get. Auto: the peak your system reports.": "Колко ярки стават акцентите. Автоматично: пикът, който вашата система отчита.",
"How bright white paper and menus look. Auto: your desktop's white.": "Колко ярки изглеждат бялата хартия и менютата. Автоматично: вашият работен плот е бял.",
"How bright the HUD and menus are, against paper white.": "Колко ярки са HUD и менютата на бяла хартия.",
"How far mobs, crops and machines keep working. CPU time.": "Докъде продължават да работят тълпите, културите и машините. процесорно време.",
"How far away mobs and items are drawn, as a share of normal.": "Колко далеч са изтеглени тълпите и предметите, като дял от нормалното.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропуска терен, скрит зад затворени участъци. Off струва много.",
"Hides entities behind opaque blocks.": "Скрива обекти зад непрозрачни блокове.",
"No entity occlusion culling.": "Без отстраняване на оклузия на обект.",
"CPU occlusion test runs this frame.": "Тестът за запушване на процесора изпълнява този кадър.",
"Occlusion checks run on a worker thread.": "Проверките за затваряне се изпълняват на работна нишка.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Мрежести секции, качени на кадър. Долната е по-плавна при зареждане.",
"Soft shadows in corners. Costs chunk building time only.": "Меки сенки в ъглите. Разходи само време за изграждане на парче.",
"Smaller copies of block textures for distant terrain: less shimmer.": "По-малки копия на блокови текстури за отдалечени терени: по-малко блясък.",
"Blends grass and water colours across biomes when building chunks.": "Смесва тревни и водни цветове в биоми при изграждане на парчета.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Облачен слой: изключен, плосък (бърз) или 3D (фантастичен).",
"How far clouds reach, in chunks.": "Докъде достигат облаците, на парчета.",
"Draws the sky, sun, moon and stars.": "Рисува небето, слънцето, луната и звездите.",
"Distance fog. Off shows the edge of the loaded world.": "Дистанционна мъгла. Изключено показва ръба на заредения свят.",
"Draws rain and snow.": "Рисува дъжд и сняг.",
"How far rain and snow fall around you.": "Докъде валят дъжд и сняг около вас.",
"How many particles effects spawn.": "Колко ефекти на частици хвърлят хайвера си.",
"Round shadows under mobs and items.": "Кръгли сенки под тълпи и предмети.",
"Leaves with see-through gaps. Off draws them solid.": "Листа с прозрачни празнини. Off ги рисува солидно.",
"Sorts water and glass more exactly behind each other.": "Сортира водата и стъклото по-точно едно зад друго.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Коя част се възстановява, чакайки рамката: нито една, наблизо или вашата.",
"Animates water, lava, fire and other moving textures.": "Анимира вода, лава, огън и други движещи се текстури.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Световни пиксели на пиксел на екрана. Под 100% е по-бързо на GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Изглажда назъбените ръбове. FXAA: по-мек. TAA: най-плавните, движещи се неща могат да се проточат.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Прави света по-малък и го възстановява с TAA. По-бързо, по-меко.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR изход на HDR дисплей: лава, огън и слънчева светлина блестят по-ярко.",
"How frames reach the screen while VSync is off.": "Как кадрите достигат до екрана, докато VSync е изключен.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Ефективно заспиване до следващия кадър; Прецизни завъртания, като ванилия.",
"Fills the screen (borderless).": "Запълва екрана (без граници).",
"Brightens dark places.": "Осветява тъмните места.",
"Lowers the frame rate when minimised, or idle too.": "Намалява честотата на кадрите, когато е минимизирана или неактивна.",
"Sways the view while walking.": "Люлее гледката по време на ходене.",
"Fades newly loaded chunks in.": "Избледнява новозаредените парчета.",
"Darkens the screen's edges.": "Затъмнява ръбовете на екрана.",
"Shows when the world saves.": "Показва кога светът спасява.",
"Shows attack cooldown.": "Показва изчакване на атаката.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS капачка (30 в менюта или нефокусирани), половината работници: по-малко мощност.",
"Threads that generate, light and mesh chunks. Next start.": "Нишки, които генерират, леки и мрежести парчета. Следващ старт.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, след това DirectX 12 (Windows) или OpenGL (Linux), където не успее. Следващ старт.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода и стъкло в един буфер: по-малко извиквания за теглене, малко повече VRAM. Следващ старт.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Мрежови данни, изпратени до GPU на кадър. Долната е по-плавна при зареждане.",
"Frame rate and frame times over the game, with a graph.": "Скорост на кадрите и времена на кадрите в играта, с графика.",
"horizon.multiplayer.finding_friend": "Намиране на света на вашия приятел...",
"horizon.multiplayer.not_signed_in": "Този сървър е в онлайн режим. Влезте с вашия акаунт в Microsoft в стартовия панел на Horizon, за да се присъедините към него.",
"horizon.multiplayer.invalid_address": "Невалиден адрес на сървъра: %s",
"horizon.multiplayer.connected_friend_direct": "Свързан директно с вашия приятел...",
"horizon.multiplayer.connected_friend_relay": "Свързан с вашия приятел чрез релето...",
"General": "генерал",
"Advanced": "Разширено",
"Undo": "Отмяна",
"Search options": "Опции за търсене",
"Shortcut": "Пряк път",
"Distance": "Разстояние",
"Display": "Дисплей",
"Interface": "Интерфейс",
"Sky and weather": "Небе и време",
"Lighting and entities": "Осветление и обекти",
"Textures": "Текстури",
"Image": "Изображение",
"Chunk loading": "Зареждане на парчета",
"Culling": "Бране",
"Power": "Мощност",
"Frames": "Рамки",
"Overlay": "Наслагване",
"Output": "Изход",
"Brightness": "Яркост",
"How far you see, the window and the HUD.": "Колко далеч виждате, прозореца и HUD.",
"The sky and weather, lighting, textures and the image.": "Небето и времето, осветлението, текстурите и изображението.",
"Chunk loading, culling and power use.": "Зареждане на парчета, отстраняване и използване на енергия.",
"How frames reach the screen, and the performance overlay.": "Как рамките достигат до екрана и наслагването на ефективността.",
"Brighter-than-white output on an HDR display.": "По-ярък от бялото изход на HDR дисплей.",
"Results for \"%s\"": "Резултати за \"%s\"",
"No settings match.": "Няма съответстващи настройки.",
"horizon.title.not_affiliated": "Не е свързан с Mojang или Microsoft",
"horizon.extras.header.lighting": "Осветление",
"horizon.extras.header.third_person": "Трето лице",
"horizon.extras.header.particles": "частици",
"horizon.extras.header.inventory": "Инвентаризация",
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.multiplayer": "Мултиплейър",
"horizon.extras.header.sound": "Звук",
"horizon.extras.header.menus": "Менюта",
"horizon.extras.fullbright": "Фулбрайт",
"horizon.extras.dynamic_lights": "Динамични светлини",
"horizon.extras.own_name_tag": "Собствен етикет с име",
"horizon.extras.hide_block_particles": "Скриване на частиците за разбиване на блокове",
"horizon.extras.auto_reconnect": "Автоматично повторно свързване",
"horizon.extras.ui_sound_reserve": "Запазете канали за звуци от потребителския интерфейс",
"horizon.extras.music_page": "Музикална страница",
"horizon.extras.mouse_tweaks": "Mouse Tweaks контроли",
"horizon.extras.mouse_tweaks_wheel": "Колелото за превъртане премества елементи",
"horizon.extras.sort": "Бутони за сортиране",
"horizon.extras.sort_key": "Ключ за сортиране",
"horizon.extras.no_ai": "Без AI",
"horizon.extras.agent_control": "Агентски контрол",
"horizon.extras.discord_presence": "Богато присъствие на Discord",
"horizon.extras.splitscreen": "Разделен екран",
"horizon.extras.chat_animation": "Плавен чат",
"horizon.extras.chat_indicator": "Индикатор за съобщение",
"horizon.extras.wheel_cycles_settings": "Колелото за превъртане променя настройките",
"horizon.extras.pack_browser": "Браузър за пакети с ресурси",
"horizon.extras.fullbright.tooltip": "Показвайте блокове и обекти при пълна светлина, дори в тъмни зони.",
"horizon.extras.dynamic_lights.tooltip": "Факли и други източници на светлина, държани, изпуснати или запалени, осветяват блоковете около тях.",
"horizon.extras.own_name_tag.tooltip": "Показва името ви над главата ви в трето лице, както го виждат другите играчи. Скрит, когато сървърът скрива имената на вашия отбор или сте невидими.",
"horizon.extras.hide_block_particles.tooltip": "Скрива частиците отломки, които избухват от счупен блок и се разпадат от този, който копаете. Други частици остават.",
"horizon.extras.auto_reconnect.tooltip": "Присъединява се отново към сървър след загубена връзка или рестартиране, никога след ритване или бан.",
"horizon.extras.ui_sound_reserve.tooltip": "Звуците в света оставят 16 от 247 звукови канала свободни, така че потоп от тях (голяма експлозия) не може да заглуши щракванията в менюто.",
"horizon.extras.music_page.tooltip": "Music & Sounds получава страница Music...: пуснете която и да е вградена песен сега или изключете някоя, така че играта да спре да я избира. Нищо не се променя, докато не изключите запис.",
"horizon.extras.mouse_tweaks.tooltip": "Плъзгане с десен бутон поставя по един на слот, плъзгане с ляво събира задържания елемент, Shift + плъзгане наляво премества всеки слот.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Надолу изпраща един елемент от слота от другата страна, нагоре връща един обратно.",
"horizon.extras.sort.tooltip": "Сортира инвентара, сандъците, бъчвите, сандъците за ендер и кутиите за шулкери в творчески ред на раздели, сливайки стекове.",
"horizon.extras.sort_key.tooltip": "Сортира частта под курсора. Щракнете, след това натиснете клавиш; Escape го развързва.",
"horizon.extras.no_ai.tooltip": "Скрива и изключва всяка функция на AI: контрол на агента, horizon-ctl и MCP сървърите. Превключвателят на стартера също.",
"horizon.extras.agent_control.tooltip": "Вашият собствен AI агент може да играе тази игра чрез horizon-ctl или MCP клиент само на този компютър.",
"horizon.extras.discord_presence.tooltip": "Показва Playing Horizon във вашия Discord профил, докато Discord работи: менюта, един играч или мултиплейър, измерението и времето на игра. Никога име на света или адрес на сървър.",
"Calibrate...": "Калибриране...",
"Calibrate Anyway...": "Калибриране все пак...",
"Use Automatic Setup": "Използвайте автоматична настройка",
"Shortcut: %s": "Пряк път: %s",
"Next": "Следваща",
"HDR Calibration %s/%s: %s": "HDR калибриране %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Задава трите под цял екран, една тестова картина наведнъж.",
"A key that opens this screen from the game. Not bound at first.": "Ключ, който отваря този екран от играта. В началото не е обвързан.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Вашият дисплей не каза колко ярък става: калибрирайте, за да го настроите.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Вече е настроен за вашия дисплей. Калибрирайте само ако HDR изглежда грешно.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Връща Peak Brightness и Paper White обратно към настройката на вашия дисплей.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Настройте за вашия дисплей. Оставете на Auto, освен ако не изглежда грешно.",
"Lower it until the logo just disappears into the black.": "Спуснете го, докато логото изчезне в черното.",
"Raise it until the logo just disappears into the bright square.": "Повдигнете го, докато логото изчезне в яркия квадрат.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Направете картата бяла, удобна за гледане дълго време, на светлината, на която играете.",
"horizon.splitscreen.profiles.forget": "Забравете",
"horizon.splitscreen.profiles.create": "Създавайте",
"horizon.splitscreen.profiles.you": " - ти",
"horizon.splitscreen.profiles.forget_named": "Забравете %s",
"horizon.splitscreen.profiles.previous": "Предишен",
"horizon.splitscreen.profiles.next": "Следваща",
"horizon.splitscreen.profiles.new_button": "Нов профил...",
"horizon.splitscreen.profiles.forget_title": "Забравете профила?",
"horizon.splitscreen.profiles.forget_leaves": "Напуска списъка на този екземпляр.",
"horizon.splitscreen.profiles.forget_kept": "Световният напредък и настройки се запазват.",
"horizon.splitscreen.profiles.new_title": "Нов профил",
"horizon.splitscreen.profiles.name_hint": "Име (3 до 16 букви, цифри, _)",
"horizon.splitscreen.profiles.switch_title": "Превключване на профил",
"horizon.splitscreen.profiles.offline_note": "Офлайн профилите принадлежат към този екземпляр",
"horizon.splitscreen.profiles.move": "Движете се там, където играете",
"horizon.splitscreen.profiles.move_place": "Преместете се там, където играете: %s",
"horizon.friends.title": "Играйте с приятели",
"horizon.friends.hint": "Приятели поставете кода след бутона Приятели на заглавния екран, в Direct Connection или на страницата за възпроизвеждане на стартовия панел.",
"horizon.friends.copied": "Копирано",
"horizon.friends.copy_code": "Копиране на код",
"horizon.friends.stop_sharing": "Спиране на споделянето",
"horizon.friends.none_yet": "Тук все още няма приятели.",
"horizon.friends.here_now": "Тук сега: %s",
"horizon.friends.share_intro": "Споделете този свят зад код за покана: той се отваря към LAN и приятели в интернет се присъединяват с кода. Не е включен наш сървър.",
"horizon.friends.start_sharing": "Започнете споделяне",
"horizon.friends.troubleshooting": "Отстраняване на неизправности...",
"horizon.friends.stopped": "Спря споделянето на кода за покана.",
"horizon.friends.short_code_changed": "Някой продължаваше да опитва грешен кратък код, така че той се промени.",
"horizon.friends.wrong_code": "Някой е опитал грешен код.",
"horizon.friends.code_ready": "Вашият код за покана е готов: Меню за игри, Играйте с приятели. Копирайте го и го изпратете на приятелите си.",
"horizon.friends.share_failed": "Не можах да споделя света: %s",
"horizon.friends.finding_route": "Търся как приятелите могат да се свържат с вас...",
"horizon.osk.space": "пространство",
"horizon.osk.delete": "Изтриване",
"horizon.osk.type": "Тип",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "затвори",
"horizon.extras.chat_animation.tooltip": "Нова линия за чат се плъзга отляво, докато избледнява, вместо да се показва цяла.",
"horizon.extras.chat_indicator.tooltip": "Цветната лента отляво на линия за чат.",
"horizon.extras.wheel_cycles_settings.tooltip": "Колелцето за превъртане над бутон в настройките променя стойността му, както при vanilla. Изключено, колелото само превърта списъка.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs получава бутон за добавяне, който търси в Modrinth и инсталира пакет във вашата папка с пакети. Off, играта никога не се свързва с Modrinth.",
"horizon.extras.splitscreen.tooltip": "В един свят друг контролер може да се присъедини като нов играч: натиснете A, когато поиска, след това изберете профил и къде да играете.",
"horizon.splitscreen.setup.monitor": "Монитор %s",
"horizon.splitscreen.setup.monitor_unknown": "монитор",
"horizon.splitscreen.setup.window": "Прозорец %s",
"horizon.splitscreen.setup.new_window": "Нов прозорец",
"horizon.splitscreen.setup.fill_monitor": "Попълнете %s",
"horizon.splitscreen.setup.done": "Готово",
"horizon.splitscreen.setup.on": "включено",
"horizon.splitscreen.setup.off": "Изкл",
"horizon.splitscreen.setup.player": "Играч %s",
"horizon.splitscreen.setup.revert": "Връщане",
"horizon.splitscreen.setup.apply": "Кандидатствайте",
"horizon.splitscreen.setup.warning.empty_main": "Никой не играе в Window 1: той се скрива",
"horizon.splitscreen.setup.warning.empty_close": "Никой не играе в Window %s: затваря се при Apply",
"horizon.splitscreen.setup.warning.empty_new": "Никой не играе в Window %s: няма да се отвори",
"horizon.splitscreen.setup.warning.on_main": "Window %s изпълва монитора на Window 1: неговите играчи играят в Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Мониторът на Window %s не е свързан: неговите играчи играят в Window 1",
"horizon.splitscreen.setup.status.off": "Разделеният екран е изключен.",
"horizon.splitscreen.setup.status.enable_hint": "Включете го, след това натиснете бутон на друг контролер в свят.",
"horizon.splitscreen.setup.status.join_hint": "Натиснете A, за да се присъедините и изберете профил.",
"horizon.splitscreen.setup.status.idle": "Разделен екран неактивен",
"horizon.splitscreen.setup.status.alone": "Няма други местни играчи.",
"horizon.splitscreen.setup.status.solo_controls": "Самостоятелните контроли и обратната връзка на контролера са непроменени.",
"horizon.splitscreen.setup.window_empty": "Прозорец %s: празен",
"horizon.splitscreen.setup.note.select": "Щракнете върху плейър или го плъзнете към прозорец или монитор.",
"horizon.splitscreen.setup.note.apply": "Приложете, за да играете по този начин, Върнете, за да отмените.",
"horizon.splitscreen.setup.note.place": "Къде трябва да играе %s?",
"horizon.splitscreen.setup.this_player": "този играч",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen се изключва, след като другите напуснат (тяхното меню за пауза).",
"horizon.splitscreen.setup.window_place": "Прозорец %s (%s от %s)",
"horizon.splitscreen.setup.names": "имена",
"horizon.splitscreen.setup.identify": "Идентифицирайте",
"horizon.splitscreen.setup.layout.two": "2 споделяне: %s",
"horizon.splitscreen.setup.layout.three": "3 споделяне: %s",
"horizon.splitscreen.setup.layout.two.0": "Авто",
"horizon.splitscreen.setup.layout.two.1": "Един до друг",
"horizon.splitscreen.setup.layout.two.2": "Отгоре и отдолу",
"horizon.splitscreen.setup.layout.three.0": "Един широк отгоре",
"horizon.splitscreen.setup.layout.three.1": "Един висок отляво",
"horizon.splitscreen.setup.layout.three.2": "Един до друг",
"horizon.splitscreen.setup.help.player": "Кликнете или плъзнете %s, за да ги преместите.",
"horizon.splitscreen.setup.help.layout_auto": "%s (един до друг, когато прозорецът е широк). Натиснете за промяна.",
"horizon.splitscreen.setup.help.layout": "%s. Натиснете за промяна.",
"horizon.splitscreen.setup.help.switch": "Разделен екран за този компютър, включен или изключен.",
"horizon.splitscreen.setup.help.identify": "Изгледът на всеки играч показва в кой прозорец се намира.",
"horizon.splitscreen.setup.help.names": "Името на всеки играч в ъгъла на неговия изглед.",
"horizon.splitscreen.setup.help.revert": "Отменете промените, които все още не са приложени.",
"horizon.splitscreen.setup.help.apply": "Играйте по начина, по който картата показва.",
"horizon.splitscreen.setup.help.unchanged": "Все още нищо не се е променило.",
"horizon.splitscreen.setup.give_up_pad": "Подложка за отказване",
"horizon.splitscreen.prompt.controller_connected": "Контролерът е свързан",
"horizon.splitscreen.prompt.controller_disconnected": "Контролерът е изключен",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Свържете го отново или натиснете бутон на друг",
"horizon.splitscreen.prompt.join.one": "%s иска да се присъедини",
"horizon.splitscreen.prompt.join.many": "%s контролери искат да се присъединят",
"horizon.splitscreen.prompt.join.yes": "Присъединете се",
"horizon.splitscreen.prompt.join.as_p1": "Играйте като P1",
"horizon.splitscreen.prompt.join.no": "не",
"horizon.splitscreen.prompt.lost": "%s: контролерът е изключен",
"horizon.splitscreen.prompt.lost.use_another": "Използвайте друг",
"horizon.splitscreen.prompt.lost.leave": "тръгвай",
"horizon.splitscreen.setup.help.give_up_pad": "Вашият контролер спира да ви кара и получава предложение да се присъедини (A).",
"horizon.splitscreen.setup.note.held_at": "%s до %s: A го пуска тук, B го връща обратно.",
"horizon.splitscreen.setup.note.held": "%s взето: ляв/десен избира къде, A пуска, B го връща обратно.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Скрива модели зад непрозрачни блокове. Async проверява извън нишката; субекти отметка.",
"Block Entities": "Блокиране на обекти",
"Shulker Boxes": "Кутии Шулкер",
"Reuses settled boxes; lid movement stays animated.": "Повторно използване на утаени кутии; движението на капака остава анимирано.",
"Signs": "Знаци",
"Reuses sign text until text, light, or font changes.": "Използва повторно текста на знака, докато текстът, светлината или шрифтът се променят.",
"Decorated Pots": "Декорирани саксии",
"Reuses still pots; break wobble and sherd changes redraw.": "Повторно използване на саксии; прекъсване на колебанията и отделяне на промени, преначертаване.",
"Bells": "Камбани",
"Reuses still bells; ringing stays animated.": "Повторно използване на неподвижни камбани; звъненето остава анимирано.",
"Chests": "Сандъци",
"Reuses closed chests; moving lids stay animated.": "Повторно използване на затворени сандъци; подвижните клепачи остават анимирани.",
"Banners": "Банери",
"Reuses poles; flags and pattern layers still update.": "Повторно използване на стълбове; флаговете и слоевете с шаблони все още се актуализират.",
"Copper Golem Statues": "Медни статуи на голем",
"Reuses statue geometry until its block state changes.": "Използва повторно геометрията на статуята, докато блоковото й състояние се промени.",
"Leaf Culling": "Изрязване на листа",
"Every leaf face. Vanilla's fancy leaves.": "Всеки лист лице. Елегантните листа на ванилия.",
"Keeps fine plants in high-res packs from fading far away.": "Предпазва фините растения в пакети с висока разделителна способност от избледняване.",
"Screenshot Quality": "Качество на екранната снимка",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Екранни снимки на F2 при толкова многократна разделителна способност на играта. По-високото отнема повече време.",
"Frame Generation": "Генериране на рамка",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показва измислен кадър между всеки два реални: по-гладък, с малко повече забавяне.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Използва повторно картата на сенките на глутницата, когато слънцето, гледката и света съвпадат. Изключено по подразбиране.",
"Find BlueMap": "Намерете BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "На сървъри намира тяхната уеб карта BlueMap за далечен терен. Следващо присъединяване.",
"BlueMap Radius": "Радиус на BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "На какво разстояние около вас се изтеглят плочките BlueMap. Следващо присъединяване.",
"Synchronous": "Синхронен",
"Asynchronous": "Асинхронен",
"Solid": "Твърди",
"Solid Aggressive": "Твърди агресивни",
"Hollow": "Кух",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листата изцяло вътре в сенник рисуват плътно: по-малко прозрачни слоеве.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Листата с една отворена страна също рисуват плътно: по-бързи, по-едри ръбове.",
"Drops faces between leaves: faster, but gaps look through.": "Капки лица между листата: по-бързо, но празнини прозират.",
"Hotbar: Previous Slot": "Гореща лента: Предишен слот",
"Hotbar: Next Slot": "Гореща лента: Следващ слот",
"horizon.agent.connected.one": "Свързан: 1 връзка с агент",
"horizon.agent.connected.many": "Свързани: %s агентни връзки",
"horizon.agent.connected.one_ago": "Свързан: 1 връзка с агент, последна команда преди %s сек",
"horizon.agent.connected.many_ago": "Свързани: %s агентни връзки, последна команда преди %s сек",
"horizon.agent.waiting": "В очакване на агент.",
"horizon.agent.off": "Изключено: агентите не могат да достигнат до тази игра.",
"horizon.agent.session_opened": "Отворено за тази сесия от програмата, която стартира играта.",
"horizon.agent.socket": "Сокет: %s (копирайте подробностите за пълния му път)",
"horizon.agent.local_only": "Само локално: агенти на този компютър, никога в мрежата.",
"%s nits": "%s нита",
"Auto (%s nits)": "Автоматично (%s нита)",
"%.2f nits": "%.2f нита",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Скрива модели зад непрозрачни блокове. Актуализация на обекти. Изключване с персонализирани пакети.",
"horizon.extras.repro_keys": "Преки пътища за запис",
"horizon.extras.repro_keys.tooltip": "F3+R стартира или спира възпроизвеждане на запис и F3+E записва неговия пакет. Изключено по подразбиране: записът копира света.",
"horizon.agent.record_session": "Започнете запис на сесия",
"horizon.agent.record_stop": "Спрете сесията за запис",
"horizon.agent.export_repro": "Експортиране на Repro Bundle",
"horizon.repro.started": "Записът започна; F3+R го спира и записва пакета.",
"horizon.repro.stopped": "Записът е спрян: %s",
"horizon.repro.bundle": "Repro пакет: %s",
"horizon.repro.error": "Репро: %s",
"horizon.repro.no_recording": "Все още няма запис за експортиране.",
"horizon.repro.replay_started": "Стартирало повторение: %s",
"horizon.pack.addInfo": "Добавете ресурсен пакет от Modrinth",
"horizon.pack.addNone": "Няма намерени пакети с ресурси",
"horizon.pack.addTitle": "Добавете пакет с ресурси",
"horizon.pack.install": "Инсталирайте",
"horizon.pack.installing": "Инсталиране",
"horizon.pack.installed": "Инсталиран",
"horizon.pack.search": "Търсене",
"horizon.pack.searchHint": "Търсете Modrinth...",
"horizon.pack.filters": "Филтри",
"horizon.pack.filter.realistic": "Реалистичен",
"horizon.pack.filter.simplistic": "Опростено",
"horizon.pack.filter.vanillaLike": "Подобно на ванилия",
"horizon.pack.addFailed": "Пакетът не можа да бъде добавен: %s",
"horizon.extras.header.cosmetics": "Козметика",
"horizon.extras.cosmetics_mine": "Моята козметика",
"horizon.extras.cosmetics_mine.tooltip": "Скрит: всеки играч на Horizon ви вижда без вашата козметика, включително и вие. Същият превключвател като гардероба на стартера.",
"horizon.extras.cosmetics_others": "Козметика за други играчи",
"horizon.extras.cosmetics_others.tooltip": "Какво носят другите играчи на Horizon: всичко, само техните стилове на имената или нищо. Скрито, играта никога не пита какво носят.",
"horizon.extras.cosmetics_auras": "Аури и следи на други",
"horizon.extras.cosmetics_auras.tooltip": "Аури и пътеки, които другите играчи носят. Изключено по подразбиране.",
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Скрити",
"horizon.extras.cosmetics.name_tags": "Само етикети с имена"
}
@@ -0,0 +1,484 @@
{
"horizon.options.controller": "Reoler",
"horizon.options.three_d_skin_layers": "Gwiskadoù kroc'hen 3D",
"horizon.options.vibration": "Krenadenn",
"horizon.options.adaptive_triggers": "Gwaskerioù azasaus",
"horizon.options.lightbar": "Barrenn gouloù",
"horizon.options.look_sensitivity": "Sensibilitez ar sell",
"horizon.options.invert_x": "Distreiñ ar sell X",
"horizon.options.invert_y": "Distreiñ ar sell Y",
"horizon.options.sneak_button": "Bouton mont kuzh",
"horizon.options.move_deadzone": "Takad marv ar fiñval",
"horizon.options.look_deadzone": "Takad marv ar sell",
"horizon.options.button_glyphs": "Glifoù bouton",
"horizon.options.menu_navigation": "Merdeiñ dre ar meni",
"horizon.options.on_screen_keyboard": "Klavier war ar skramm",
"horizon.options.chat_delay": "Dale flapañ",
"horizon.options.lightbar.health": "Yec'hed",
"horizon.options.lightbar.dimension": "Ment",
"horizon.options.glyphs.auto": "Emgefreek",
"horizon.options.menu_focus": "Lakaat ar gaoz",
"horizon.options.menu_pointer": "Poent",
"horizon.options.keyboard.auto": "Emgefreek",
"horizon.options.unavailable": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
"horizon.options.search": "Arventennoù klask...",
"horizon.pages.mods": "Modoù",
"horizon.pages.shader_packs": "Pakadoù skeudennoù",
"horizon.pages.shader_options": "Dibarzhioù skeudenniñ",
"horizon.pages.controller": "Arventennoù ar merour",
"horizon.pages.controller_binds": "Liammañ ar c'hontroller",
"horizon.pages.extras": "Horizon Ouzhpennoù",
"horizon.pages.settings": "Horizon Arventennoù",
"horizon.pages.agent": "Ajañs IA",
"horizon.split.leave": "Kuitaat",
"horizon.pages.split_settings": "Arventennoù ar skramm rannet",
"horizon.pages.split_profiles": "Piv a zo o c'hoari ?",
"horizon.pages.split_setup": "Arventennoù ar skramm rannet",
"horizon.pages.music": "Sonerezh",
"horizon.pages.music.overworld": "Bed dreist",
"horizon.pages.music.creative": "Krouiñ",
"horizon.pages.music.underwater": "Dindan an dour",
"horizon.pages.music.end": "Ar fin",
"horizon.pages.music.boss": "Patron",
"horizon.pages.music.credits": "Kredoù",
"horizon.pages.music.overworld_biomes": "Biomoù ar bed dreist",
"horizon.pages.music.nether_biomes": "Biomoù an izelvroioù",
"horizon.pages.music.end_biomes": "Biomoù dibenn",
"horizon.pages.music.other": "Sonerezh all",
"horizon.pages.music.empty": "Roudenn sonerezh ebet : n'eo ket karget sonioù ar c'hoari.",
"horizon.pages.music.tooltip": "An hentoù enkorfet, dre stad : c'hoari unan bremañ, pe diweredekaat unan anezho evit ma paouezfe ar c'hoari da zibab anezhañ.",
"horizon.pages.settings.tooltip": "arventennoù Horizon e-unan : grafikoù, kontrollerioù, ouzhpennadennoù ha kemmoù.",
"horizon.pages.graphics.tooltip": "Grafikoù, simpl : pep arventenn a lavar petra a goust.",
"horizon.pages.graphics_advanced.tooltip": "Pep arventenn grafek, Horizon en o zouez, gant ar pezh a goust pep hini.",
"horizon.pages.controller.tooltip": "Kontrollerien : o boutonoù, o bizhier, o c'hrennlavar hag o merdeiñ war al lañserioù.",
"horizon.pages.extras.tooltip": "Ouzhpennadennoù dibar, holl diweredekaet dre ziouer : chom a ra ar c'hoari vanilha betek ma vo lakaet unan war-sav.",
"horizon.pages.agent.tooltip": "Lakait hoc'h ajañs AI da c'hoari : ar c'hliented horizon-ctl ha MCP war an urzhiataer-mañ.",
"horizon.pages.split_profiles.tooltip": "Piv a c'hoari : ur profil miret er renkell c'hoari-mañ.",
"horizon.pages.split_setup.tooltip": "Ar prenestroù hag ar skrammoù, ha piv a c'hoari pelec'h.",
"horizon.pages.shader_packs.tooltip": "Pakadoù skeudennoù eus an teuliad pakadoù skeudennoù : Horizon o-unan hag ar re er stumm skeudennoù/. Unan a red d'an dro.",
"horizon.pages.mods.tooltip": "Modoù savet evit Horizon, eus an teuliad horizon-mods. Pep hini a ya en-dro en ur voest-traezh ha chom a ra diweredekaet betek ma vo lakaet war-sav.",
"horizon.pages.open.graphics": "Grafikoù...",
"horizon.pages.open.graphics_advanced": "Grafikoù araokaet...",
"horizon.pages.open.shader_packs": "Pakadoù skeudennoù...",
"horizon.pages.open.controller": "Arventennoù ar merour...",
"horizon.pages.open.split_setup": "Skramm rannet...",
"horizon.pages.open.extras": "Ouzhpenn...",
"horizon.pages.open.mods": "Modoù...",
"horizon.pages.open.agent": "Ajañs IA...",
"horizon.pages.open.horizon_graphics": "Horizon Grafikoù...",
"horizon.pages.open.controller_binds": "Stagañ a ra ar reoler...",
"horizon.pages.open.horizon_settings": "Horizon Arventennoù...",
"horizon.pages.open.music": "Sonerezh...",
"horizon.mods.empty": "Mod ebet : lakait restroù .hmod e teuliad horizon-mods",
"horizon.mods.stopped": " (Paouezet)",
"horizon.mods.not_built": " (N'eo ket bet savet)",
"horizon.mods.settings": "Arventennoù",
"horizon.mods.in_world": " (lezel ar bed da cheñch)",
"horizon.mods.open_folder": "Digeriñ an teuliad mod",
"horizon.shader_packs.empty": "Pakad skeudennoù ebet : lakait anezho e teuliad ar pakadoù skeudennoù",
"horizon.shader_options.open": "Dibarzhioù skeudenniñ...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "O lenn dibaboù ar pakad skeudennoù...",
"horizon.agent.copy_details": "Eilañ munudoù ar c'hennask",
"horizon.multiplayer.join": "Kemer perzh",
"horizon.multiplayer.cancel_reconnecting": "Nullañ an adkennaskañ",
"horizon.multiplayer.join_friend": "Kemer perzh en ur mignon",
"horizon.multiplayer.invite_code": "Kod pediñ",
"horizon.multiplayer.relay": "Treuzkas (dibar)",
"Preset": "Raktreset",
"LOD Distance": "Pellder LOD",
"Far Detail": "Munudoù pell",
"Cave Culling": "Lazhañ ar c'hevoù",
"Entity Culling": "Distruj an entite",
"Chunk Updates": "Hizivadennoù tammoù",
"Upload Budget": "Budjed pellgargañ",
"Sky": "Oabl",
"Fog": "Brumenn",
"Weather": "Amzer",
"Animated Textures": "Teñzorioù bevaat",
"Present Mode": "Mod bremañ",
"Frame Pacing": "Pazadur ar framm",
"Worker Threads": "Neudennoù labourerien",
"Resolution Scale": "Skeul an diarunusted",
"Anti-aliasing": "Enep-aliañ",
"Upscaling": "Kreskiñ",
"Paper White": "Gwenn paper",
"Peak Brightness": "Sklêrijenn uhelañ",
"Black Level": "Live du",
"UI Brightness": "Sklêrijenn UI",
"Battery Mode": "Mod pilenn",
"Graphics API": "API grafikoù",
"Translucent Batching": "Lodennadur treuzwelus",
"Performance Overlay": "Dreistlakaat ar perzhded",
"Full Detail Distance": "Pellder munudoù klok",
"On": "War",
"Off": "Diweredekaet",
"Unlimited": "Didalvoud",
"Auto": "Emgefreek",
"Custom": "Personelaet",
"Performance": "Arvest",
"Balanced": "Kempouez",
"Quality": "Kalite",
"Native": "Natif",
"Full": "Leun",
"Reduced": "Digresket",
"Efficient": "Efedus",
"Precise": "Resis",
"Auto (VSync)": "Emgefre (VSync)",
"Mailbox": "Boest-postel",
"Immediate": "Diouzhtu",
"FPS and Graph": "FPS ha grafik",
"No cost": "Koust ebet",
"Low cost": "Koust izel",
"Medium cost": "Koust etre",
"High cost": "Koust uhel",
"Saves time": "Gounit a ra amzer",
"Graphics": "Grafikoù",
"Done": "Echu",
"This feature is not yet available in Horizon.": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
"Your display or system doesn't offer HDR output.": "Ho skramm pe ho reizhiad ne ginnig ket un disoc'h HDR.",
"Turn HDR on to use it.": "Lakait HDR war-sav evit e implijout.",
"Your GPU or graphics API doesn't support it.": "N'eo ket skoret gant ho GPU pe ho API grafikoù.",
"%s chunks": "%s tammoù",
"%s per frame": "%s dre framm",
"%s MiB/frame": "%s MiB/framm",
"%s KiB/frame": "%s KiB/framm",
"Auto (%s)": "Emgefre (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Rannvro an douaroù leun a vunudoù. LOD Ar pellder a c'hell astenn ar bedoù lec'hel pelloc'h.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Douaroù pell gros e bedoù lec'hel. Paouez a ra ar GPUoù kevredet da 128 tamm.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Digreskiñ a ra an douaroù kalz grosoc'h : buanoc'h war ar GPU kevredet-mañ.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ar reduset a denn an douaroù kalz grosoc'h, evit ar GPUoù e vez gorrekaet.",
"Field of view. Wider shows more terrain at once.": "Tachenn gwel. Ledanoc'h a ziskouez muioc'h a zouaroù war un dro.",
"Caps frames per second; a cap saves power and heat.": "Bep frammoù dre eilenn; ur c'hasketenn a espern energiezh ha tommder.",
"Waits for the display's refresh: no tearing, a little latency.": "Gortoz a ra freskaat ar skramm : tamm torr ebet, un tamm dale.",
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
"Sets the options below together. Changing one makes it Custom.": "Lakaat a ra an dibarzhioù amañ dindan asambles. Cheñch unan a ra anezhañ Personelaet.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, tan ha gouloù an heol a lugern muioc'h eget ar gwenn. Ezhomm zo eus ur skramm HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du teñvalañ eo ho skramm. 0 evit OLED; LCDoù a lugern un tamm.",
"How bright highlights get. Auto: the peak your system reports.": "Pegen skedus e teu ar sklêrijennoù. Emgefre : ar pik a vez embannet gant ho reizhiad.",
"How bright white paper and menus look. Auto: your desktop's white.": "Pegen skedus e seblant ar paper gwenn hag ar mennozhioù. Emgefre : gwenn eo ho burev.",
"How bright the HUD and menus are, against paper white.": "Pegen skedus eo an HUD hag ar mennozhioù, a-enep gwenn ar paper.",
"How far mobs, crops and machines keep working. CPU time.": "Pegen pell e kendalc'h ar mobs, ar c'hoadoù hag ar mekanikoù da labourat. Amzer ar CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Pegen pell e vez tennet ar mobs hag an traoù, evel ul lodenn eus ar pezh a vez graet normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Tremen a ra an douaroù kuzhet a-dreñv rannoù serr. Koustañ a ra kalz.",
"Hides entities behind opaque blocks.": "Kuzhat a ra an elfennoù a-dreñv bloc'hoù dister.",
"No entity occlusion culling.": "N'eus ket a zistruj entite.",
"CPU occlusion test runs this frame.": "Test stankañ ar CPU a gas ar framm-mañ da benn.",
"Occlusion checks run on a worker thread.": "Ar gwirioù stankañ a vez kaset da benn war un neudenn labourer.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Rannoù rouedek pellgarget dre framm. Eeunoc'h eo an izelañ e-pad ar c'hargañ.",
"Soft shadows in corners. Costs chunk building time only.": "Skeudoù dous er c'hornioù. Koustañ a ra amzer sevel a-stroll hepken.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Skouerennoù bihanoc'h eus teñzorioù bloc'hoù evit an tachennoù pell : nebeutoc'h a sked.",
"Blends grass and water colours across biomes when building chunks.": "Meskañ a ra livioù ar geot hag an dour dre ar biomoù pa vez savet tammoù.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Treuzfollenn ar c'hoad : diweredekaet, plaen (buan) pe 3D (fentus).",
"How far clouds reach, in chunks.": "Pegen pell e tizh ar c'houmoul, e tammoù.",
"Draws the sky, sun, moon and stars.": "Tresañ a ra an oabl, an heol, al loar hag ar stered.",
"Distance fog. Off shows the edge of the loaded world.": "Brumenn pellder. Diweredekaat a ziskouez ribl ar bed karget.",
"Draws rain and snow.": "Tennañ a ra glav hag erc'h.",
"How far rain and snow fall around you.": "Pegen pell e kouezh ar glav hag an erc'h en-dro deoc'h.",
"How many particles effects spawn.": "Peseurt efedoù rannigoù a vez ganet.",
"Round shadows under mobs and items.": "Skeudennoù ront dindan mobs hag elfennoù.",
"Leaves with see-through gaps. Off draws them solid.": "Delioù gant toulloù gwelus. Off a denn anezho start.",
"Sorts water and glass more exactly behind each other.": "Renkañ a ra an dour hag ar gwer an eil a-dreñv egile.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Peseurt rann a adsevel a c'hortoz ar framm : hini ebet, e-kichen pe ho hini.",
"Animates water, lava, fire and other moving textures.": "Bevaat a ra an dour, al lava, an tan hag an danvezioù fiñvus all.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pikseloù bed dre piksel skramm. Buanoc'h eo dindan 100% war ar GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Eeunaat a ra ar c'hornioù ruz. FXAA: dousoc'h. TAA: an traoù eeunañ, o fiñval a c'hell heuliañ.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Bihanaat a ra ar bed hag adsevel anezhañ gant TAA. Buanoc'h, dousoc'h.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Disoc'h HDR war ur skramm HDR : lava, tan ha gouloù an heol a lugern muioc'h.",
"How frames reach the screen while VSync is off.": "Penaos e tistro ar frammoù war ar skramm pa vez diweredekaet VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Kousket a ra efedus betek ar framm da heul; Troioù resis, evel ar vanilha.",
"Fills the screen (borderless).": "Leuniañ ar skramm (hep bevennoù).",
"Brightens dark places.": "Sklaeraat a ra al lec'hioù teñval.",
"Lowers the frame rate when minimised, or idle too.": "Digreskiñ a ra ar feur framm pa vez bihanaet, pe pa vez didalvoud ivez.",
"Sways the view while walking.": "Heñchañ a ra ar gwel e-pad ma kerzher.",
"Fades newly loaded chunks in.": "Diverkañ a ra an tammoù nevez karget.",
"Darkens the screen's edges.": "Teñvalaat a ra ribloù ar skramm.",
"Shows when the world saves.": "Diskouez a ra pa savete ar bed.",
"Shows attack cooldown.": "Diskouez a ra an amzer yenaat evit an taolioù.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 er mennozhioù pe diskoulmet), an hanter eus al labourerien : nebeutoc'h a c'halloud.",
"Threads that generate, light and mesh chunks. Next start.": "Neudennoù a grou, a luc'h hag a ro troc'hadoù. Kregiñ war-lerc'h.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, neuze DirectX 12 (Windows) pe OpenGL (Linux) ma c'hwit. Kregiñ war-lerc'h.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Dour ha gwer en ur buffer : nebeutoc'h a c'halvoù tennañ, un tamm muioc'h a VRAM. Kregiñ war-lerc'h.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Roadennoù rouedad kaset d'ar GPU dre framm. Eeunoc'h eo an izelañ e-pad ar c'hargañ.",
"Frame rate and frame times over the game, with a graph.": "Feur ar framm hag an amzer framm e-pad ar c'hoari, gant ur grafik.",
"horizon.multiplayer.finding_friend": "O kavout bed ho mignon...",
"horizon.multiplayer.not_signed_in": "E mod enlinenn emañ an dafariad-mañ. En em lugañ gant ho kont Microsoft el lañser Horizon evit mont e-barzh.",
"horizon.multiplayer.invalid_address": "Chomlec'h servijer direizh : %s",
"horizon.multiplayer.connected_friend_direct": "Kevreet war-eeun ouzh ho mignon...",
"horizon.multiplayer.connected_friend_relay": "Kevreet ouzh ho mignon dre ar c'heflusker...",
"General": "Hollek",
"Advanced": "Araokaet",
"Undo": "Dizober",
"Search options": "Dibarzhioù klask",
"Shortcut": "Berradennoù",
"Distance": "Pellder",
"Display": "Diskouez",
"Interface": "Etrefas",
"Sky and weather": "Oabl hag amzer",
"Lighting and entities": "Gouloù hag ensavadurioù",
"Textures": "Teñzorioù",
"Image": "Skeudenn",
"Chunk loading": "Kargañ tammoù",
"Culling": "Lazhañ",
"Power": "Galloud",
"Frames": "Frammoù",
"Overlay": "Dreistlakaat",
"Output": "Disoc'h",
"Brightness": "Sklêrijenn",
"How far you see, the window and the HUD.": "Pegen pell e weler, ar prenestr hag an HUD.",
"The sky and weather, lighting, textures and the image.": "An oabl hag an amzer, ar gouloù, an teñzorioù hag ar skeudenn.",
"Chunk loading, culling and power use.": "Kargañ troc'hadoù, distrujañ hag implij ar galloud.",
"How frames reach the screen, and the performance overlay.": "Penaos e tistro ar frammoù d'ar skramm, hag an dreuzfollenn perzhded.",
"Brighter-than-white output on an HDR display.": "Disoc'h sklaeroc'h eget gwenn war ur skramm HDR.",
"Results for \"%s\"": "Disoc'hoù evit \"%s\"",
"No settings match.": "Arventennoù ebet a glot.",
"horizon.title.not_affiliated": "N'eo ket stag ouzh Mojang pe Microsoft",
"horizon.extras.header.lighting": "Gouloù",
"horizon.extras.header.third_person": "Trede gour",
"horizon.extras.header.particles": "Rannigoù",
"horizon.extras.header.inventory": "Enrolladenn",
"horizon.extras.header.chat": "Flapañ",
"horizon.extras.header.multiplayer": "Liesc'hoarier",
"horizon.extras.header.sound": "Son",
"horizon.extras.header.menus": "Mennozhioù",
"horizon.extras.dynamic_lights": "Gouloù dinamek",
"horizon.extras.own_name_tag": "Tikedenn anv e-unan",
"horizon.extras.hide_block_particles": "Kuzhat rannigoù bloc'h-torr",
"horizon.extras.auto_reconnect": "Adkevreañ emgefre",
"horizon.extras.ui_sound_reserve": "Mirout ar chadennoù evit sonioù an UI",
"horizon.extras.music_page": "Pajenn sonerezh",
"horizon.extras.mouse_tweaks": "Reolennoù kemmañ al logodenn",
"horizon.extras.mouse_tweaks_wheel": "Ar rod riklañ a zilec'h elfennoù",
"horizon.extras.sort": "Boutonoù renkañ",
"horizon.extras.sort_key": "Alc'hwez renkañ",
"horizon.extras.no_ai": "AI ebet",
"horizon.extras.agent_control": "Kontroll an ajañs",
"horizon.extras.discord_presence": "Discord Bezañs pinvidik",
"horizon.extras.splitscreen": "Skramm rannet",
"horizon.extras.chat_animation": "Flapañ eeun",
"horizon.extras.chat_indicator": "Merk kemennadenn",
"horizon.extras.wheel_cycles_settings": "Kemmañ a ra arventennoù ar rouedad riklañ",
"horizon.extras.pack_browser": "Merdeer pakadoù arc'hwelioù",
"horizon.extras.fullbright.tooltip": "Diskouez bloc'hoù hag elfennoù e-pad ar gouloù leun, zoken e lec'hioù teñval.",
"horizon.extras.dynamic_lights.tooltip": "Torchoù ha fontoù gouloù all dalc'het, taolet pe en tan a sklêrijenn ar bloc'hoù tro-dro dezho.",
"horizon.extras.own_name_tag.tooltip": "Diskouez a ra hoc'h anv a-us d'ho penn en trede gour, evel ma vez gwelet gant ar c'hoarierien all. Kuzhet pa vez kuzhet anvioù ho skipailh gant an dafariad pe pa vezit diwelus.",
"horizon.extras.hide_block_particles.tooltip": "Kuzhat a ra ar rannigoù lastez a darzh diwar ur bloc'h torret hag a zismantr diwar an hini a vez tennet ganeoc'h. Chom a ra rannigoù all.",
"horizon.extras.auto_reconnect.tooltip": "Adkemer perzh en ur servijer goude ur c'hennask kollet pe un adloc'hañ, biskoazh goude ur c'hrogad pe ur bann.",
"horizon.extras.ui_sound_reserve.tooltip": "Ar sonioù er bed a lez 16 eus ar 247 chadenn son digoust, setu ne c'hell ket ur bern anezho (ur splujadenn vras) sioulaat ar c'hlikoù war al lañser.",
"horizon.extras.music_page.tooltip": "Music & Sounds a gav ur bajenn Sonerezh... : seniñ un ton enkorfet bremañ, pe diweredekaat unan evit ma paouezfe ar c'hoari da zibab anezhañ. Ne cheñch netra betek ma vo lazhet un hent.",
"horizon.extras.mouse_tweaks.tooltip": "An riklañ a-zehou a laka unan dre lec'h, an riklañ a-gleiz a zastum an elfenn dalc'het, an riklañ Shift + an riklañ a-gleiz a zilec'h pep rann.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down a gas un elfenn eus ar rann d'an tu all, up a zegas unan en-dro.",
"horizon.extras.sort.tooltip": "Urzhiañ a ra an enrolladennoù, ar c'hofoù, ar barilhoù, ar c'hofoù ender hag ar boestoù shulker en urzh ivinelloù krouiñ, o kendeuziñ ar bernoù.",
"horizon.extras.sort_key.tooltip": "Urzhiañ a ra al lodenn dindan ar merdeer. Klikañ, ha pouezañ war un alc'hwez ; Tec'hout a zistag anezhañ.",
"horizon.extras.no_ai.tooltip": "Kuzhat ha diweredekaat pep elfenn AI : kontroll an ajañs, horizon-ctl hag ar servijerioù MCP. Alc'hwez al lañser ivez.",
"horizon.extras.agent_control.tooltip": "Hoc'h ajañs IA a c'hall c'hoari ar c'hoari-mañ dre horizon-ctl pe dre ur c'hlient MCP, war an urzhiataer-mañ hepken.",
"horizon.extras.discord_presence.tooltip": "Diskouez a ra C'hoari Horizon war ho profil Discord e-keit ma vez Discord o vont en-dro : mennozhioù, c'hoarier unvan pe liesc'hoarier, ar vent hag an amzer c'hoariet. Biskoazh anv ur bed pe chomlec'h ur servijer.",
"Calibrate...": "Kalibrañ...",
"Calibrate Anyway...": "Kalibrañ koulskoude...",
"Use Automatic Setup": "Implijout arventennoù emgefre",
"Shortcut: %s": "Berradennoù : %s",
"Next": "Da heul",
"HDR Calibration %s/%s: %s": "Kalibradur HDR %s/%s : %s",
"Sets the three below full screen, one test picture at a time.": "Lakaat a ra an tri skramm leun a-is, ur skeudenn amprouiñ bep tro.",
"A key that opens this screen from the game. Not bound at first.": "Un alc'hwez a zigor ar skramm-mañ eus ar c'hoari. N'eo ket stag da gentañ.",
"Your display didn't say how bright it gets: calibrate to set it up.": "N'eo ket bet embannet gant ho skramm pegen sklaer e teu da vezañ : kalibret evit e lakaat war-sav.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Kefluniet dija evit ho skramm. Kalibret nemet ma seblant HDR bezañ fazius.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Lakaat a ra ar sked uhelañ hag ar gwenn paper en-dro e kefluniadur ho skramm.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Kefluniañ evit ho skramm. Lezel war Auto nemet pa seblant fall.",
"Lower it until the logo just disappears into the black.": "Izelaat anezhañ betek ma vo kollet al logo en du.",
"Raise it until the logo just disappears into the bright square.": "Sevel anezhañ betek ma vo kollet al logo er c'harrez skedus.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Lakaat ar gartenn da vezañ gwenn a vo aes da sellet outi e-pad pell, er gouloù ma c'hoarier.",
"horizon.splitscreen.profiles.forget": "Ankounac'haat",
"horizon.splitscreen.profiles.create": "Krouiñ",
"horizon.splitscreen.profiles.you": " - c'hwi",
"horizon.splitscreen.profiles.forget_named": "Ankouaat %s",
"horizon.splitscreen.profiles.previous": "Kent",
"horizon.splitscreen.profiles.next": "Da heul",
"horizon.splitscreen.profiles.new_button": "Profil nevez...",
"horizon.splitscreen.profiles.forget_title": "Ankounac'haat ho profil ?",
"horizon.splitscreen.profiles.forget_leaves": "Kuitaat a ra roll an ensavadur-mañ.",
"horizon.splitscreen.profiles.forget_kept": "Miret e vez an araokadennoù hag an arventennoù er bed.",
"horizon.splitscreen.profiles.new_title": "Profil nevez",
"horizon.splitscreen.profiles.name_hint": "Anv (3 da 16 lizherenn, sifroù, _)",
"horizon.splitscreen.profiles.switch_title": "Cheñch profil",
"horizon.splitscreen.profiles.offline_note": "Ar profiloù ezlinenn a ya d'ober an ensavadur-mañ",
"horizon.splitscreen.profiles.move": "Dilec'hiañ el lec'h ma c'hoarier",
"horizon.splitscreen.profiles.move_place": "Dilec'hiañ el lec'h ma c'hoarier : %s",
"horizon.friends.title": "C'hoari gant mignoned",
"horizon.friends.hint": "Pegañ a ra ar vignoned ar c'hod goude bouton Mignoned ar skramm titl, e Kevreadur war-eeun, pe war bajenn C'hoari al lañser.",
"horizon.friends.copied": "Eilet",
"horizon.friends.copy_code": "Eilañ ar c'hod",
"horizon.friends.stop_sharing": "Paouez da rannañ",
"horizon.friends.none_yet": "Mignon ebet amañ c'hoazh.",
"horizon.friends.here_now": "Setu bremañ : %s",
"horizon.friends.share_intro": "Rannañ ar bed-mañ a-dreñv ur c'hod pediñ : digeriñ a ra d'ar LAN, ha mignoned war internet a gemer perzh gant ar c'hod. N'eus servijer ebet ac'hanomp e-barzh.",
"horizon.friends.start_sharing": "Kregiñ da rannañ",
"horizon.friends.troubleshooting": "Diskoulmañ kudennoù...",
"horizon.friends.stopped": "Paouezet da rannañ ar c'hod pediñ.",
"horizon.friends.short_code_changed": "Unan bennak a gendalc'has da glask ur c'hod berr fazius, setu m'eo cheñchet.",
"horizon.friends.wrong_code": "Unan bennak en deus klasket ur c'hod fall.",
"horizon.friends.code_ready": "Prest eo ho kod pediñ : Menü ar c'hoari, C'hoari gant mignoned. Eilañ anezhañ ha kas anezhañ d'ho mignoned.",
"horizon.friends.share_failed": "N'hall ket rannañ ar bed : %s",
"horizon.friends.finding_route": "Kavout penaos e c'hall mignoned tizhout ac'hanoc'h...",
"horizon.osk.shift": "Dilec'hiañ",
"horizon.osk.space": "Egor",
"horizon.osk.delete": "Dilemel",
"horizon.osk.type": "Seurt",
"horizon.osk.symbols": "Arouezioù",
"horizon.osk.close": "Serriñ",
"horizon.extras.chat_animation.tooltip": "Ur linenn flapañ nevez a zeu eus an tu kleiz e-keit ma vez o vont da get, e-lec'h dont war wel a-bezh.",
"horizon.extras.chat_indicator.tooltip": "Ar varrenn livet a-gleiz d'ul linenn flapañ.",
"horizon.extras.wheel_cycles_settings.tooltip": "Ar rod riklañ war ur bouton en arventennoù a gemm e dalvoudegezh, evel e vanilha. Diweredekaet, ne ra ar rod nemet riklañ ar roll.",
"horizon.extras.pack_browser.tooltip": "Dibab pakadoù arc'hwelioù a resev ur bouton Ouzhpennañ a glask Modrinth hag a staliañ ur pakad en ho teuliad pakadoù. Diweredekaet, ne vez ket darempredet ar c'hoari gant Modrinth.",
"horizon.extras.splitscreen.tooltip": "En ur bed e c'hall ur c'hontroller all mont e-barzh evel c'hoarier nevez : pouezit war A pa c'houlenn, ha dibabit ur profil ha pelec'h c'hoari.",
"horizon.splitscreen.setup.monitor": "Heuliañ %s",
"horizon.splitscreen.setup.monitor_unknown": "ur skramm",
"horizon.splitscreen.setup.window": "Prenestr %s",
"horizon.splitscreen.setup.new_window": "Prenestr nevez",
"horizon.splitscreen.setup.fill_monitor": "Leuniañ %s",
"horizon.splitscreen.setup.done": "Echu",
"horizon.splitscreen.setup.on": "War",
"horizon.splitscreen.setup.off": "Diweredekaet",
"horizon.splitscreen.setup.player": "C'hoarier %s",
"horizon.splitscreen.setup.revert": "Distreiñ",
"horizon.splitscreen.setup.apply": "Goulenn",
"horizon.splitscreen.setup.warning.empty_main": "Den ne c'hoari e Prenestr 1 : kuzhat a ra",
"horizon.splitscreen.setup.warning.empty_close": "Den ne c'hoari e Prenestr %s : serriñ a ra war Apply",
"horizon.splitscreen.setup.warning.empty_new": "Den ne c'hoari e Prenestr %s : ne vo ket digoret",
"horizon.splitscreen.setup.warning.on_main": "Prenestr %s a leun skramm ar prenestr 1 : e c'hoarierien a c'hoari e prenestr 1",
"horizon.splitscreen.setup.warning.unplugged": "N'eo ket kevreet skramm ar Prenestr %s : c'hoari a ra e c'hoarierien e Prenestr 1",
"horizon.splitscreen.setup.status.off": "Diweredekaet eo ar skramm rannet.",
"horizon.splitscreen.setup.status.enable_hint": "Lakait anezhañ war enaou, ha pouezit war ur bouton war ur c'hontroller all en ur bed.",
"horizon.splitscreen.setup.status.join_hint": "Pouezit war A evit mont e-barzh ha dibab ur profil.",
"horizon.splitscreen.setup.status.idle": "Skramm rannet didalvoud",
"horizon.splitscreen.setup.status.alone": "C'hoarier lec'hel all ebet.",
"horizon.splitscreen.setup.status.solo_controls": "Ar c'hontrolloù solo hag an evezhiadennoù kontroller n'int ket cheñchet.",
"horizon.splitscreen.setup.window_empty": "Prenestr %s: goullo",
"horizon.splitscreen.setup.note.select": "Klikit war ur c'hoarier, pe riklañ anezhañ betek ur prenestr pe ur skramm.",
"horizon.splitscreen.setup.note.apply": "Arverañ evit c'hoari evel-se, Distreiñ evit distreiñ.",
"horizon.splitscreen.setup.note.place": "Pelec'h e tlefe %s c'hoari ?",
"horizon.splitscreen.setup.this_player": "ar c'hoarier-mañ",
"horizon.splitscreen.setup.switch_disabled": "Lazhañ a ra ar skramm rannet ur wech ma'z a ar re all kuit (o lañser paouez).",
"horizon.splitscreen.setup.window_place": "Prenestr %s (%s eus %s)",
"horizon.splitscreen.setup.names": "Anvioù",
"horizon.splitscreen.setup.identify": "Anavezout",
"horizon.splitscreen.setup.layout.two": "2 rannañ : %s",
"horizon.splitscreen.setup.layout.three": "3 rannañ : %s",
"horizon.splitscreen.setup.layout.two.0": "Emgefreek",
"horizon.splitscreen.setup.layout.two.1": "Kichen-ha-kichen",
"horizon.splitscreen.setup.layout.two.2": "Krec'h ha traoñ",
"horizon.splitscreen.setup.layout.three.0": "Unan ledan war-c'horre",
"horizon.splitscreen.setup.layout.three.1": "Unan uhel a-gleiz",
"horizon.splitscreen.setup.layout.three.2": "Kichen-ha-kichen",
"horizon.splitscreen.setup.help.player": "Klikañ pe riklañ %s evit o dilec'hiañ.",
"horizon.splitscreen.setup.help.layout_auto": "%s (kichen-ha-kichen pa vez ledan ar prenestr). Pouezit evit cheñch.",
"horizon.splitscreen.setup.help.layout": "%s. Pouezit evit cheñch.",
"horizon.splitscreen.setup.help.switch": "Skramm rannet evit an urzhiataer-mañ, war pe get.",
"horizon.splitscreen.setup.help.identify": "Gwel pep c'hoarier a ziskouez e peseurt prenestr emañ.",
"horizon.splitscreen.setup.help.names": "Anv pep c'hoarier e korn o gwel.",
"horizon.splitscreen.setup.help.revert": "Dilemel ar c'hemmoù n'int ket bet arveret c'hoazh.",
"horizon.splitscreen.setup.help.apply": "C'hoari evel ma tiskouez ar gartenn.",
"horizon.splitscreen.setup.help.unchanged": "N'eus netra cheñchet c'hoazh.",
"horizon.splitscreen.setup.give_up_pad": "Pad dilezel",
"horizon.splitscreen.prompt.controller_connected": "Reoler kevreet",
"horizon.splitscreen.prompt.controller_disconnected": "Diskennet eo ar c'hontroller",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Adkevreit anezhañ, pe pouezit war ur bouton war unan all",
"horizon.splitscreen.prompt.join.one": "%s a fell dezhañ mont e-barzh",
"horizon.splitscreen.prompt.join.many": "%s kontrollerien a fell dezho kemer perzh",
"horizon.splitscreen.prompt.join.yes": "Kemer perzh",
"horizon.splitscreen.prompt.join.as_p1": "C'hoari evel P1",
"horizon.splitscreen.prompt.join.no": "Nann",
"horizon.splitscreen.prompt.lost": "%s: distaget eo ar c'hontroller",
"horizon.splitscreen.prompt.lost.use_another": "Implijout unan all",
"horizon.splitscreen.prompt.lost.leave": "Kuitaat",
"horizon.splitscreen.setup.help.give_up_pad": "Paouez a ra ho kontroller da gas ac'hanoc'h ha kinniget e vez deoc'h mont e-barzh (A).",
"horizon.splitscreen.setup.note.held_at": "%s da %s: A a laka anezhañ da gouezhañ amañ, B a laka anezhañ en-dro.",
"horizon.splitscreen.setup.note.held": "%s dastumet : dibaboù kleiz/dehou e-lec'h ma kouezh A, B a laka anezhañ en-dro.",
"horizon.splitscreen.online_only": "E mod enlinenn emañ ar servijer-mañ : n'hall ket ar profiloù ezlinenn mont e-barzh, pep c'hoarier en deus ezhomm eus e gont Minecraft.",
"horizon.splitscreen.own_account": "Ezhomm en deus ar servijer-mañ eus ur gont Minecraft evit pep c'hoarier",
"horizon.splitscreen.local_only": "Ar c'hoarierien all ne c'hallont mont e-barzh ar servijer-mañ nemet gant o c'hont Minecraft o-unan : ar profiloù ezlinenn a c'hoari en ho bed, war LAN pe war ur servijer war ho rouedad lec'hel.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Kuzhat a ra ar patromoù a-dreñv bloc'hoù dister. Gwiriañ a ra an async e-maez an neudennad; tik an entiteoù.",
"Block Entities": "Entiteoù stankañ",
"Shulker Boxes": "Boestoù Shulker",
"Reuses settled boxes; lid movement stays animated.": "Adimplij a ra ar voestoù staliet; fiñv ar golo a chom bev.",
"Signs": "Sinoù",
"Reuses sign text until text, light, or font changes.": "Adlakaat a ra testenn ar sin betek ma cheñcho an destenn, ar gouloù pe ar font.",
"Decorated Pots": "Podoù kinklet",
"Reuses still pots; break wobble and sherd changes redraw.": "Adimplij a ra podoù c'hoazh; terriñ ar c'hrennlazh hag ar c'hemmoù strinkañ adtresañ.",
"Bells": "Klochennoù",
"Reuses still bells; ringing stays animated.": "Adimplij a ra kloc'hioù c'hoazh; chom a ra bevaat ar sonerezh.",
"Chests": "Kofoù",
"Reuses closed chests; moving lids stay animated.": "Adimplij ar c'houfr serr; ar goloioù o fiñval a chom bev.",
"Banners": "Bannieloù",
"Reuses poles; flags and pattern layers still update.": "Adimplij ar palioù; hizivaet c'hoazh ar bannieloù hag ar gwiskadoù patromoù.",
"Copper Golem Statues": "Delwennoù golem kouevr",
"Reuses statue geometry until its block state changes.": "Adlakaat a ra geometriezh an delwenn betek ma cheñcho he stad bloc'h.",
"Leaf Culling": "Lazhañ delioù",
"Every leaf face. Vanilla's fancy leaves.": "Pep delienn dremm. Delioù fentus ar vanilha.",
"Distant Plant Dither": "Tremen a ra ar plant pell",
"Keeps fine plants in high-res packs from fading far away.": "Mirout a ra ar plant fin e pakadoù uhel o resolution da vont da get pell.",
"Screenshot Quality": "Kalite ar skramm",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Skeudennoù skramm F2 gant kement-se meur a wech diarunusted ar c'hoari. Uheloc'h a bad hiroc'h.",
"Frame Generation": "Krouiñ frammoù",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Diskouez a ra ur framm savet etre pep daou hini gwirion : eeunoc'h, un tamm muioc'h a zilez.",
"Shadow Cache": "Grubuilh skeud",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Adimplij ar gartenn skeud pakad pa glot an heol, ar gwel hag ar bed. Diweredekaet dre ziouer.",
"Find BlueMap": "Kavout BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "War ar servijerioù e kaver o gartenn web BlueMap evit an tachennoù pell. Kemer perzh da heul.",
"BlueMap Radius": "Radius ar gartenn glas",
"How far around you BlueMap tiles are downloaded. Next join.": "Pegen pell tro-dro deoc'h e vez pellgarget teulioù BlueMap. Kemer perzh da heul.",
"Synchronous": "Sinkron",
"Asynchronous": "Azinkron",
"Solid Aggressive": "Tagus kreñv",
"Hollow": "Toull",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Delioù penn-da-benn e-barzh ur c'hoad a denn solut : nebeutoc'h a gwiskadoù treuzwelus.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Ar delioù gant un tu digor a denn ivez : bordoù buanoc'h ha stankoc'h.",
"Drops faces between leaves: faster, but gaps look through.": "Lakaat a ra fazioù etre ar delioù : buanoc'h, met toulloù a sell drezo.",
"Hotbar: Previous Slot": "Barrenn-dre-lien : Lec'h a-raok",
"Hotbar: Next Slot": "Barrenn-dre-lien : Lec'h war-lerc'h",
"horizon.agent.connected.one": "Kevreet : 1 kevreadenn ajañs",
"horizon.agent.connected.many": "Kevreet : %s kevreadennoù an ajañs",
"horizon.agent.connected.one_ago": "Kevreet : 1 kevreadenn ajañs, ur gemennadenn ziwezhañ %s s zo",
"horizon.agent.connected.many_ago": "Kevreet : %s kevreadennoù an ajañs, ar gemennadenn ziwezhañ %s s zo",
"horizon.agent.waiting": "O c'hortoz un ajañs.",
"horizon.agent.off": "Diweredekaet : n'hall ket an ajañsoù tizhout ar c'hoari-mañ.",
"horizon.agent.session_opened": "Digoret evit ar prantad-mañ gant ar programm en deus kroget ar c'hoari.",
"horizon.agent.socket": "Socket: %s (eilañ ar munudoù evit e hent klok)",
"horizon.agent.local_only": "Lec'hel hepken : an ajañsoù war an urzhiataer-mañ, biskoazh ar rouedad.",
"Auto (%s nits)": "Emgefre (%s nits)",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Kuzhat a ra ar patromoù a-dreñv bloc'hoù dister. Hizivaat an ensavadurioù. Dilezet gant pakadoù personelaet.",
"horizon.extras.repro_keys": "Berradennoù enrollañ",
"horizon.extras.repro_keys.tooltip": "F3+R a grog pe a paouez gant un enrolladenn advouezhiañ ha F3+E a skriv e strollad. Diweredekaet dre ziouer : un enrolladenn a eil ar bed.",
"horizon.agent.record_session": "Kregiñ gant an enrollañ",
"horizon.agent.record_stop": "Paouez gant an enrollañ",
"horizon.agent.export_repro": "Ezporzhiañ ar pakad advouezhiañ",
"horizon.repro.started": "Kroget eo an enrollañ ; F3+R a harz anezhañ hag a skriv ar pakad.",
"horizon.repro.stopped": "Paouezet gant an enrollañ : %s",
"horizon.repro.bundle": "Pakad advouezhiañ : %s",
"horizon.repro.no_recording": "N'eus enrolladenn ebet da ezporzhiañ c'hoazh.",
"horizon.repro.replay_started": "Adc'hoari kroget : %s",
"horizon.pack.addInfo": "Ouzhpennañ ur pakad arc'hwelioù adalek Modrinth",
"horizon.pack.addWorking": "Goulenn Modrinth...",
"horizon.pack.addNone": "N'eus bet kavet pakad arc'hwel ebet",
"horizon.pack.addTitle": "Ouzhpennañ ur pakad arc'hwelioù",
"horizon.pack.install": "Staliañ",
"horizon.pack.installing": "O staliañ",
"horizon.pack.installed": "Staliet",
"horizon.pack.search": "Klask",
"horizon.pack.searchHint": "Klask Modrinth...",
"horizon.pack.filters": "Siloù",
"horizon.pack.filter.realistic": "Gwirvoudel",
"horizon.pack.filter.simplistic": "Eeun",
"horizon.pack.filter.vanillaLike": "Heñvel ouzh ar vanilha",
"horizon.pack.addFailed": "N'haller ket ouzhpennañ ar pakad : %s",
"horizon.extras.header.cosmetics": "Kosmetikoù",
"horizon.extras.cosmetics_mine": "Ma kosmetikoù",
"horizon.extras.cosmetics_mine.tooltip": "Kuzhet : pep c'hoarier Horizon a wel ac'hanoc'h hep ho kosmetikoù, c'hwi en o zouez. Ar memes treuzfurmadur hag armel al lañser.",
"horizon.extras.cosmetics_others": "Kosmetikoù ar c'hoarierien all",
"horizon.extras.cosmetics_others.tooltip": "Ar pezh a vez gwisket gant ar c'hoarierien Horizon all : pep tra, o doareoù tikedenn anv hepken, pe netra. Kuzhet, ar c'hoari ne c'houlenn ket biskoazh petra a wiskont.",
"horizon.extras.cosmetics_auras": "Auraoù ha roudoù tud all",
"horizon.extras.cosmetics_auras.tooltip": "Auraoù ha roudoù a vez gwisket gant c'hoarierien all. Diweredekaet dre ziouer.",
"horizon.extras.cosmetics.shown": "Diskouezet",
"horizon.extras.cosmetics.hidden": "Kuzhet",
"horizon.extras.cosmetics.name_tags": "Tikedennoù anvioù hepken"
}
Loaded 100 of 668 files, more files were not shown because too many files have changed in this diff. Show more