Compare commits

...
Author SHA1 Message Date
Ludovic Marie b6d002a723 Merge pull request #12567 from oloion/fix/launcher-server-buttons-and-stop
🤖 Agent: server Stop/Restart fall back from the API to the CLI and report its error; a job panic shows a translated message.
2026-11-08 22:34:56 +01:00
Ludovic Marie 85ca681ff1 Merge pull request #12643 from v0ntr3n/fix/japanese-ime-enter-12387
🤖 Agent: the Enter that ends an IME composition no longer reaches the focused field (should_suppress_key is now wired into window_event).
2026-11-08 22:34:52 +01:00
Ludovic Marie 22287141a5 Merge pull request #12730 from Ragbie/fix/hurt-teleport-each-hurt
🤖 Agent: enderman/shulker teleport inside each hurt via ai::hurt, and contact damage is re-tested after a teleport.
2026-11-08 22:34:49 +01:00
Ludovic Marie 6aa8930d3f Merge pull request #12729 from iseaprojects/i18n/es-es-rest
🤖 Agent: es_es gets its 128 missing keys.
2026-11-08 22:34:45 +01:00
Ludovic Marie f4262df3a2 Merge pull request #12757 from iseaprojects/i18n/fr-fr-rest
🤖 Agent: fr_fr gets its 510 missing game keys.
2026-11-08 22:34:41 +01:00
Ludovic Marie 09d0f1e1d8 Merge pull request #12753 from iseaprojects/i18n/pt-br-rest
🤖 Agent: pt_br gets its 544 missing game keys.
2026-11-08 22:34:36 +01:00
Ludovic Marie d40955d303 Merge pull request #12748 from arslanarm/shaderpacks/rain-writes-no-depth
🤖 Agent: weather never writes depth in shader packs; checked black-box against Iris with the author's own probe packs, no source read.
2026-11-08 22:34:32 +01:00
Horizon agentandClaude Opus 5.5 e02a4bf0d3 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:33 +01:00
Horizon agentandClaude Opus 5.5 c6292de19c Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:32 +01:00
Ludovic Marie 802a858cc1 Merge pull request #12542 from martipops/ui/controller-shell
🤖 Agent: Controller Settings move onto settings_shell; the binding rows keep Undo/reset, and the screen's waiting key comes from OptionsUi::waiting().
2026-11-08 22:23:28 +01:00
Ludovic Marie 753594d0a0 Merge pull request #12710 from umbx-dot/launcher/works-on-instance
🤖 Agent: native builds try the instance's commit first and move to the pin only for a pinned mod left out as OtherVersion (made_elsewhere, unit-tested).
2026-11-08 22:23:24 +01:00
Ludovic Marie 341675a110 Merge pull request #12690 from MrCloudy2/fix/mob-shot-next-tick
🤖 Agent: shots joining during the entity pass tick on the next tick (EntityTickList.forEach), checked by the new mob_shot_join_tick harness.
2026-11-08 22:23:21 +01:00
Ludovic Marie 5f86c3c505 Merge pull request #12651 from CocofireHD/fix/12512-piglin-drop-swing
🤖 Agent: PiglinAi.throwItemsTowardPos swings OFF_HAND only when the list isn't empty, and the hub's animate packet names that hand.
2026-11-08 22:23:17 +01:00
Ludovic Marie 7df108a7be Merge pull request #12604 from J5Casey/braid/mod-toast
🤖 Agent: add_toast is a thin helper over the existing toast path, and toast_allowed gains the kind "mod", so mods' toasts follow the player's toast settings.
2026-11-08 22:23:13 +01:00
Horizon agent ec1b4198fe Merge remote-tracking branch 'origin/main' into mm-12651b 2026-11-08 22:18:32 +01:00
Horizon agent 5da4b13265 Merge remote-tracking branch 'origin/main' into mm-12604
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-11-08 22:12:25 +01:00
Ludovic Marie 81403ce81c Merge pull request #12699 from RLC248/fix/12520-sculk-loaded-state
🤖 Agent: sculk placement finishes before command/item entity tags load; sculk_saved_fields harness added.
2026-11-08 22:09:01 +01:00
Ludovic Marie 3f87ea235e Merge pull request #12716 from astelmach20/agent-break-state-change
🤖 Agent: break follows a block that changes into another one.
2026-11-08 22:08:58 +01:00
Ludovic Marie b65ebc85e2 Merge pull request #12704 from RINNECODER/up6/lan-sync-load-over
🤖 Agent: the LAN host's world sync reuses kept advancements and network recipes for the same packs: about 0.7 s less before the first frame.
2026-11-08 22:08:54 +01:00
Ludovic Marie 0d7046557d Merge pull request #12702 from RINNECODER/up6/biome-modifiers-over-packs
🤖 Agent: biome modifiers are re-applied from the saved unmodified biomes, so nothing applies twice.
2026-11-08 22:08:50 +01:00
Ludovic Marie 2c32f22313 Merge pull request #12700 from RINNECODER/up6/litho-weighted-selector
🤖 Agent: lithostitched weighted_selector maps to weighted_random_selector, weight 1 by default.
2026-11-08 22:08:46 +01:00
Ludovic Marie 2620806a43 Merge pull request #12697 from RINNECODER/up6/blockstate-string-values
🤖 Agent: 1.21.1 string uvlock/x/y/weight values in variant objects are typed.
2026-11-08 22:08:42 +01:00
Ludovic Marie d956b41093 Merge pull request #12695 from RINNECODER/up6/litho-biome-modifiers
🤖 Agent: Lithostitched add/remove_features become NeoForge biome modifiers; config predicates count as on.
2026-11-08 22:08:39 +01:00
Ludovic Marie 9253f573d8 Merge pull request #12692 from iseaprojects/fix/launcher-java-on-path-windows
🤖 Agent: the Windows PATH scan probes java.exe.
2026-11-08 22:08:36 +01:00
Ludovic Marie 4a53264476 Merge pull request #12687 from apeirs/fix/lod-render-thread-lock
🤖 Agent: far-terrain queue locks are try_lock on the render thread: work moves a frame, nothing is dropped.
2026-11-08 22:08:32 +01:00
Ludovic Marie 64b7852431 Merge pull request #12680 from glitchsnake/fix/12558-equine-ravager-attrs
🤖 Agent: chested equines jump 0.5 on load and no equine attributes are synthesized; fixes the fuzzer case #12558.
2026-11-08 22:08:29 +01:00
Ludovic Marie cc4abd50ea Merge pull request #12672 from MrCloudy2/fix/projectile-hit-margin
🤖 Agent: projectile hit margin follows computeMargin by tickCount; the new projectile_hit_margin harness pins it.
2026-11-08 22:08:25 +01:00
Ludovic Marie e676b097eb Merge pull request #12670 from hildlabs/launcher-github-stars
🤖 Agent: GitHub stars show as an icon beside ratings; the dead string is removed from every lang file.
2026-11-08 22:08:22 +01:00
Ludovic Marie 1432581c19 Merge pull request #12667 from hildlabs/registry-icon-version
🤖 Agent: icon addresses carry ?v=<sha256 prefix>, so a changed icon is fetched again.
2026-11-08 22:08:18 +01:00
Horizon agent adf39cbeb2 Merge remote-tracking branch 'origin/main' into mm-12710
# Conflicts:
#	launcher/lang/cs.json
2026-11-08 22:08:17 +01:00
Horizon agent c67f07bbdb Merge remote-tracking branch 'origin/main' into mm-12690
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-08 22:07:55 +01:00
Horizon agent 8295846dfd Merge remote-tracking branch 'origin/main' into mm-12542 2026-11-08 22:05:40 +01:00
Ludovic Marie 4d6f818c3a Merge pull request #12821 from astelmach20/s-agent-collect-hurt
🤖 Agent: this is the top of the agent-control stack (12715 to 12816): screen waits, durability, --sneak, in-the-way errors, events --limit; the stack's rustfmt is fixed.
2026-11-08 22:02:20 +01:00
Ludovic Marie b1d131e6e3 Merge pull request #12685 from fbikat/fix-12522-conversion-times
🤖 Agent: ConversionTracker's affliction time is -1 when not afflicted (FreezingTime, DrownedConversionTime), as in vanilla.
2026-11-08 22:02:15 +01:00
Ludovic Marie 582dc76fbd Merge pull request #12661 from NikitaMGrimm/fix-orb-value
🤖 Agent: orb Value is kept as a short, including ≤ 0; points() clamps only for gains.
2026-11-08 22:02:11 +01:00
Ludovic Marie 45ffc8628d Merge pull request #12531 from martipops/ui/pad-glyphs
🤖 Agent: pad glyphs resolve from the action through the player's Glyphs, with letter mode handled inside pad_glyph.
2026-11-08 22:02:07 +01:00
Ludovic Marie 889f3eddfe Merge pull request #12717 from astelmach20/agent-goto-distance
🤖 Agent: goto reports horizontal_distance.
2026-11-08 22:02:04 +01:00
Ludovic Marie 7372b5842e Merge pull request #12657 from oloion/perf/render-frame-pacing-scratch
🤖 Agent: renderer scratch buffers are reused across frames instead of reallocated.
2026-11-08 21:59:59 +01:00
Ludovic Marie 8455585a36 Merge pull request #12656 from RLC248/fix/12525-projectile-save-defaults
🤖 Agent: projectiles skip ticking outside entity-ticking chunks, like vanilla; trident pickup/owner fields are saved.
2026-11-08 21:59:56 +01:00
Ludovic Marie 98b205d5de Merge pull request #12655 from iseaprojects/fix/death-protection-wire
🤖 Agent: death_protection is decoded with DeathProtection.CODEC defaults; nesting is bounded at 32.
2026-11-08 21:59:52 +01:00
Ludovic Marie f2d12697f6 Merge pull request #12635 from ItzKubass/patch-1
🤖 Agent: 249 Czech launcher strings, placeholders kept, JSON parses.
2026-11-08 21:59:49 +01:00
Ludovic Marie 11b31e9c09 Merge pull request #12627 from RINNECODER/up5/overlay-clone-threads
🤖 Agent: namespace folders are cloned whole when only one mod supplies them; copies use at most 8 threads.
2026-11-08 21:59:45 +01:00
Ludovic Marie af902896d4 Merge pull request #12619 from ThatCakeIsAPie/perf/far-bake
🤖 Agent: far-terrain samples bake under <dimension>/horizon_lod with a fingerprint of build key + world datapacks; removal only touches that folder.
2026-11-08 21:59:41 +01:00
Ludovic Marie 4d855f9ac0 Merge pull request #12616 from ThatCakeIsAPie/perf/lattice-reuse
🤖 Agent: the climate lattice is reused only for data-pack climates that read interpolated density; the vanilla path is unchanged.
2026-11-08 21:59:37 +01:00
Ludovic Marie 106eec903f Merge pull request #12605 from J5Casey/braid/client-fireworks
🤖 Agent: client_fireworks is bounded to 64 explosions per rocket with finite-value checks.
2026-11-08 21:59:34 +01:00
Ludovic Marie 43f632aec8 Merge pull request #12602 from J5Casey/braid/inventory-changed
🤖 Agent: player_inventory_changed fires only when mods are loaded, so players without mods pay nothing; the hook stamp test passes.
2026-11-08 21:59:30 +01:00
Ludovic Marie b0cf285a76 Merge pull request #12596 from Art34222/fix/windows-version-in-use
🤖 Agent: a running .exe in a version folder keeps that version; the nightly cleanup skips folders in use.
2026-11-08 21:59:26 +01:00
Ludovic Marie bba6a462e0 Merge pull request #12571 from glitchsnake/fix/12526-spectral-arrow-small-fireball-display-name
🤖 Agent: Projectile::entity_type unifies spectral_arrow, and CustomName/data work for every projectile id.
2026-11-08 21:59:23 +01:00
Ludovic Marie 90d6546eca Merge pull request #12609 from gigamans442/fix/terrain-source-ownership-20261108
🤖 Agent: shader-pack terrain source records are only built with mesher.shader_pack on; otherwise a section mesh carries two empty Vecs.
2026-11-08 21:59:19 +01:00
Ludovic Marie 480c65877f Merge pull request #12652 from gigamans442/fix/block-particle-program-routing-20261108
🤖 Agent: optional gbuffers_block / particles_translucent programs fall back like the format says; RenderPearl (MIT) was read for program names only.
2026-11-08 21:59:14 +01:00
Ludovic Marie 3498cc6165 Merge pull request #12646 from Joansitoh/fix/launcher-sidebar-card-overflow
🤖 Agent: long translations wrap now; min_h instead of a fixed h keeps short ones unchanged.
2026-11-08 21:59:11 +01:00
Ludovic Marie 5096ebc1dd Merge pull request #12636 from v0ntr3n/fix/main-parity-12373
🤖 Agent: fixes #12373: NaN bits are checked per arch (x86's default sqrt NaN is negative), so mannequin saves round-trip.
2026-11-08 21:59:07 +01:00
CocofireHDandopencode 93a3d53b3e Merge remote-tracking branch 'origin/main' into fix/12512-piglin-drop-swing
# Conflicts:
#	crates/horizon-client/src/game/lan.rs

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:57:29 +01:00
Horizon agentandClaude Opus 5.5 82ccc27493 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:54:51 +01:00
Horizon agentandClaude Opus 5.5 5adf98a327 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:54:45 +01:00
Horizon agentandClaude Opus 5.5 77f967a80e Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:54:39 +01:00
Horizon agentandClaude Opus 5.5 5146537ec6 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:54:23 +01:00
Horizon agentandClaude Opus 5.5 4489732c79 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:54:14 +01:00
Ludovic Marie c57bf04727 Merge pull request #12564 from kacpherek/tools/ledger-float-blame
🤖 Agent: ledger float gaps get blame lines; the oracle's tickMark is a no-op and JFR stops before halt.
2026-11-08 21:50:14 +01:00
Ludovic Marie 38afa7e7d4 Merge pull request #12783 from iseaprojects/fix/reviewers-test-itarqos5
🤖 Agent: unblocks main's verify on both counts: the reviewer set matches .github/trusted-reviewers.txt (itarqos5), and the 1.12.2 upgrade case runs only where the datafix tables are prepared (#12756).
2026-11-08 21:49:17 +01:00
Ludovic Marie d7ff6beecf Merge pull request #12723 from TheMrZZ/fix/predicate-reference-graph
🤖 Agent: the dedicated server refuses unbound or cyclic predicate references at all_of/any_of/inverted only, and exits 0 like vanilla.
2026-11-08 21:49:14 +01:00
Ludovic Marie c99bd1a44d Merge pull request #12713 from wxundup/fix/boat-paddling-attacks
🤖 Agent: startAttack refuses busy hands while paddling; the lan_e2e boat check covers it.
2026-11-08 21:49:10 +01:00
Ludovic Marie e81ac01453 Merge pull request #12683 from TheMrZZ/fix/inline-loot-reference
🤖 Agent: a nested loot table reference is checked against the registry at parse; inline_loot_reference harness added.
2026-11-08 21:49:06 +01:00
Ludovic Marie 31fe010646 Merge pull request #12658 from Joansitoh/fix/launcher-scroll-chaining
🤖 Agent: an inner list hands the wheel to the page at its ends.
2026-11-08 21:49:02 +01:00
Ludovic Marie adc19a8a07 Merge pull request #12634 from CocofireHD/fix/12530-ominous-banner
🤖 Agent: the raid banner carries item_name/rarity/tooltip_display on the wire like Raid.getOminousBannerInstance; hide_tooltip=false is omitted as optionalFieldOf does.
2026-11-08 21:48:59 +01:00
Ludovic Marie ba43b0c40f Merge pull request #12631 from NikitaMGrimm/parity/conduit-enchanting-block-entity
🤖 Agent: conduit target and enchanting table block entities save in BlockEntity key order, like vanilla's NBT.
2026-11-08 21:46:02 +01:00
Ludovic Marie 26ff2b5e34 Merge pull request #12630 from martipops/fix/split-offscreen
🤖 Agent: splitscreen windows obey --offscreen, and vsync pacing is off when nothing presents.
2026-11-08 21:45:59 +01:00
Ludovic Marie 3639259761 Merge pull request #12629 from Bortlesboat/fix/relay-bridge-slot-test-windows
🤖 Agent: test-only: the relay bridge test now runs on Windows.
2026-11-08 21:45:55 +01:00
Ludovic Marie c5d0a1c935 Merge pull request #12626 from RINNECODER/up5/mod-verdict-cache
🤖 Agent: the pack verdict is cached per (path, mtime); a nested edit outside dev mode only picks live-switch vs restart, never wrong content.
2026-11-08 21:45:52 +01:00
Ludovic Marie 26415620ea Merge pull request #12622 from apeirs/perf/dx12-occlusion-culling
🤖 Agent: DX12 uses in-shader occlusion; the measurement shows +51-60 % on Adreno, other backends unchanged.
2026-11-08 21:45:49 +01:00
Ludovic Marie ff4b01cd07 Merge pull request #12614 from J5Casey/braid/owner-attachments
🤖 Agent: the owner's autosave now writes live guest attachments, and the lan state is taken and restored on every path.
2026-11-08 21:45:45 +01:00
Ludovic Marie 559baffb0c Merge pull request #12611 from yahya-nusseibeh/fix/rsa-key-limits
🤖 Agent: the RSA checks mirror RSAKeyFactory.checkKeyLengths: 16384 bits max, public exponent ≤ 64 bits above 3072.
2026-11-08 21:45:42 +01:00
Ludovic Marie e52114437e Merge pull request #12608 from v0ntr3n/fix/neoforge-oracle-resilience
🤖 Agent: the NeoForge oracle catches its own failures and halts, so a broken case no longer hangs the run.
2026-11-08 21:45:38 +01:00
Ludovic Marie e0c325ca5a Merge pull request #12601 from devtalh/fix/prism-flat-meta-version
🤖 Agent: Prism metadata reads both the flat and nested layouts, nested preferred.
2026-11-08 21:45:34 +01:00
Ludovic Marie 93f178d4dd Merge pull request #12599 from MSedgeMC/fix/tempt-not-spectator
🤖 Agent: forNonCombat targeting now excludes spectators through canBeSeenByAnyone, matching vanilla.
2026-11-08 21:45:30 +01:00
Ludovic Marie 4552a50849 Merge pull request #12598 from MSedgeMC/fix/bee-universal-anger
🤖 Agent: isAngryAtAllPlayers now needs the universal-anger rule, an angry mob and no persistent target, as in vanilla.
2026-11-08 21:45:26 +01:00
Ludovic Marie 15583061c1 Merge pull request #12594 from MSedgeMC/fix/loot-toggle-tooltips
🤖 Agent: toggle_tooltips removes an emptied tooltip_display like a default patch, so the item compares equal to a plain one.
2026-11-08 21:45:22 +01:00
Ludovic Marie 149e80cff5 Merge pull request #12593 from MSedgeMC/fix/loot-set-written-book-pages
🤖 Agent: set_written_book_pages reuses the list operation path, so insert/replace_section behave like vanilla's.
2026-11-08 21:45:18 +01:00
Ludovic Marie e81f9ad646 Merge pull request #12591 from MSedgeMC/fix/loot-set-lore
🤖 Agent: set_lore goes through list_operation like vanilla; its 256-line cap is not modelled, which only matters for absurd packs.
2026-11-08 21:45:15 +01:00
Ludovic Marie 1645a251ec Merge pull request #12548 from MSedgeMC/fix/bubble-column-first-tick
🤖 Agent: the first-tick bubble column check moved into tick_with, so the server thread runs it too; pearls keep their creature-tick column hooks, as vanilla does both.
2026-11-08 21:45:11 +01:00
Horizon agentandClaude Opus 5.5 b15db5c782 Older worlds test: upgrade 1.12.2 only when the datafix tables are prepared
Since #12756 the pre-1.13 upgrade reads datafix-tables.json from the prepared
resources. CI's verify has none, so the test panicked there and on every
checkout nothing was prepared in. It now checks 1.12.2 only with the tables,
1.16.5 always.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:44:10 +01:00
Horizon agent e1d257e6f1 Merge remote-tracking branch 'origin/main' into fix-12783 2026-11-08 21:38:05 +01:00
Horizon agentandClaude Opus 5.5 12c5dc6169 Servers: stop and restart fall back to the CLI and report its error; a panicking job says so through tr
The API call failing (or refused) now runs horizon-server stop/restart, whose
error ends the job, instead of reporting success either way. The panic message
reuses the translated "Could not run {program}: {error}" line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:19:47 +01:00
Horizon agentandClaude Opus 5.5 3ef68721a8 IME: a focused field gets no key while composing or right after it ends
should_suppress_key was only called by its tests; the window now asks it for every
key pressed while a text field is focused, as GLFW never delivers the keys an
input method consumes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:18:01 +01:00
Horizon agentandClaude Opus 5.5 ae487a0658 Format: rustfmt the agent-control stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:14:04 +01:00
Horizon agentandClaude Opus 5.5 d1e15acd9b Format: rustfmt horizon-ctl mcp.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:11:57 +01:00
fbikatandClaude Opus 5.5 9f8a8ac0e6 Save the live conversion counters, and keep isInPowderSnow through /data merge
A ticking skeleton, zombie or husk that left the snow or the water saved the
conversion and affliction times it was loaded with; the live counters now
win, so it saves -1 as vanilla's ConversionTracker does. apply_entity_nbt
copies in_powder_snow across the rebuild, as it does last_hurt, since
Entity.load leaves isInPowderSnow alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:01:41 +00:00
astelmach20andClaude Opus 5.5 ef1bbc2c19 horizon-ctl collect: stop as soon as the player is hurt
An agent ran `collect 20` to pick up the gear it dropped on death. A
creeper blew up (16 damage) and a zombie kept hitting, and collect kept
walking from item to item until it returned with the player at 2 health.
The hurt events were in the event log, but nothing read them.

collect now reads the events after each item and stops with kind "hurt",
listing the hurt events, the inventory change so far and the items left.
Items it could not reach carry `why`: the goto error, when there was one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:36:09 -05:00
astelmach20andClaude Opus 5.5 a65582a876 Agent control: break replies with the held item, also on a timeout
An iron pickaxe broke halfway through a tunnel. The next break of stone
replied only "timed out before the block broke", which doesn't say the
player is now mining with a bare hand. break now includes `held` in its
reply, both on success and on a timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:23:42 -05:00
astelmach20andClaude Opus 5.5 6dbd14d78e Agent control: goto with whole-number X and Z aims at the block's center
`goto 8 46 82` aimed at the corner of block (8, 82), so with the default
--near 0.6 it reported arrived while the player still stood in block
(8, 81), one block up a staircase. Whole numbers now name the block and
aim at its center (8.5, 82.5); decimals stay exact positions, so callers
passing entity positions are unaffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:43 -05:00
astelmach20andClaude Opus 5.5 b7fe922e7c horizon-ctl collect: walk over the dropped items nearby
Mining a coal vein left 20 of 24 coal on the ground: the drops fell into the
pit, out of pickup range, so break replied picked_up {} and the agent had to
read entities and goto each item by hand. collect [RADIUS] walks to every item
within RADIUS (default 8), nearest first, trying each once, and replies with
inventory_change and the items it could not reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:07:24 -05:00
astelmach20andClaude Opus 5.5 61ea2cbca0 horizon-ctl hunt: walk up to a mob and hit it until it dies
Animals and monsters move several blocks between an agent's calls, so goto
to an entities position followed by attack ID kept failing with "out of
reach". hunt ID re-reads the position, walks within reach, attacks, waits for
the weapon to recharge and repeats, then walks over the drops. It replies with
hits and inventory_change.

interact-entity's help now says it fails when the entity is not under the
crosshair and replies with inventory_change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:00:35 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 473bd8eb0e test: orb merge fixture uses the signed orb value and points()
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:00:25 +01:00
astelmach20andClaude Opus 5.5 9d911fbe3e Agent control: events --limit N keeps the newest N
events with no --since returned all 256 kept events, which is too much for an
agent that only wants to know what just happened. --limit N keeps the newest N
and the reply says how many older ones it skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:57:28 -05:00
Ludovic Marie 5f7cadfff0 Merge pull request #12786 from horizon-rust/perf/frozen-entities
🤖 Agent: End gateways now skip entities past the simulation distance, as vanilla's entityInside does from the entity's own tick. Its new test fails on main's end.rs and passes here. One risk to watch: an entity already in a gateway when its chunk stops ticking keeps its traveller state until the chunk ticks again. The 13 End/portal/server harnesses pass on this head.
2026-11-08 19:52:03 +01:00
astelmach20andClaude Opus 5.5 74dd0ed4c3 horizon-ctl interact-entity: fail when the entity is not under the crosshair
interact-entity right-clicked whatever look-at left under the crosshair and
replied ok, so shearing a sheep behind a tree or out of reach looked like it
worked. It now fails naming what is in the way (an entity, a block, or nothing
in reach), and its reply adds inventory_change: the items gained or lost, such
as the wool a shear drops into the inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:50:25 -05:00
astelmach20andClaude Opus 5.5 7ab41043fa horizon-ctl mcp: run the base64 test again
The paced test landed between #[test] and the base64 test, so the base64 test
lost its attribute and stopped running (duplicate_macro_attributes, dead_code).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:50:23 -05:00
RLC248andCodex 074be79a6a Initialize sculk before loading placed item data
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:48:22 +01:00
iseaprojectsandopencode b080361402 Trusted reviewers test: name itarqos5 as well
1f9719f9b2 added 169198624 itarqos5 to .github/trusted-reviewers.txt and the
team page, but left the expectation in test_the_repos_list_names_its_reviewers_by_id
at the five names it had. A pull request's verify checks out the merge with main,
so the mismatch fails the job on main and on every open pull request, behind the
failures those jobs are meant to report.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:38:56 -04:00
Ludovic Marie 99bd965511 Merge pull request #12773 from horizon-rust/docs/team-literal
🤖 Agent: the reviewer line uses the numeric id 169198624, so a renamed login can't inherit it (resolve_reviewer_ids --check: 0 problems).
2026-11-08 19:31:04 +01:00
Horizon agentandClaude Opus 5.5 1f9719f9b2 Add itarqos5 (Literal) to the Horizon Team and the trusted reviewers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:30:11 +01:00
astelmach20 42cfe492f3 Agent control: hurt names its cause on a server's client too
In single player the client is the integrated server's client and its
combat tracker stays empty: the cause now comes from the server's
DamageEvent (damage type and the causing entity's type).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9724a9ae96)
2026-11-08 13:29:28 -05:00
Ludovic Marie 2e2dd80a9b Merge pull request #12763 from horizon-rust/fix/drop-partner-server
🤖 Agent: the removal only touches the entry the game added (marker + the game's own name), so a player's own Afri-Network entry stays.
2026-11-08 19:23:49 +01:00
Horizon agentandClaude Opus 5.5 ab1f82cf2b Clippy: a slice from a reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:19:36 +01:00
astelmach20 e6bc883aac Format: rustfmt the vital events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 885e93eeed)
2026-11-08 13:19:00 -05:00
astelmach20 d8a25fcb16 Format: rustfmt the in-the-way lookup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ef60a012f7)
2026-11-08 13:19:00 -05:00
Ludovic Marie 228fbb7d99 Merge pull request #12756 from horizon-rust/datafix-prepare
🤖 Agent: The copied tables now come from the player's jar. A dump of the old compiled-in tables and the newly loaded ones is byte-identical (sha256 d85fc80c…), and all 6 datafix shards pass. One risk: OUTPUT_VERSION 4 makes every install prepare again once, and a missing datafix-tables.json panics on pre-1.13 data instead of upgrading it wrong.
2026-11-08 19:17:43 +01:00
astelmach20andClaude Opus 5.5 75b5ac8ec1 horizon-ctl mcp --paced: the world stands still while the agent thinks
After each acting tool call the pause menu opens (stopping a single-player
world, as vanilla's does) and it closes before the next; reading tools
leave it open.

Fixes #12754

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:14:41 -05:00
iseaprojectsandopencode 48683b1757 French: controller settings, recipe transfer and the Studio timeline
The controller page: Bindings, Play, Device, Look, Movement, Menus,
Actions, Reset, Detected, Glyphs and Feedback, with the sentence under each
one (dead zones, trigger threshold, sneak toggle, the on-screen keyboard,
Nintendo button positions, the DualSense light and triggers, glyph
families, rumble) and the lines it prints while you rebind. Then the recipe
transfer messages and the Studio timeline and top bar: Freecam: ON, + Key,
− Del, marker, scene, sequence, File/Camera/View and the button glyphs.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:13:53 -04:00
iseaprojectsandopencode f720c33d24 French: PvP & HUD
The whole `horizon.pvp` block, 198 keys, none of which existed in French:
the custom crosshair (shape, size, gap, thickness, colour, outline, centre
dot and the colour it takes over an entity), the block outline and the hit
colour, the HUD widgets (armour, effects, held count, ping, coordinates,
server, combo, FPS, CPS, keystrokes, clock, reach, speed) with their
corners, background and text, chat timestamps, compact chat, copy on click
and search, the PvP preset, the in-game HUD editor, combat feel with the
1.8 rules, and the visual tweaks (time of day, weather, water, lava, fire,
shield, totem, enchantment glint).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:13:53 -04:00
iseaprojectsandopencode 41d5e253ca French: the graphics help texts, Realms and the pack browser
Flat keys, the ones whose English sentence is also the key, so only the
value moves: `GPU Culling`, `Upscaling Sharpness`, `HDR Lighting`,
`Smooth Gradients`, `Opaque Ice`, `Hidden Faces`, `Entity Culling
Precheck`, the `%s nits` and `%s mobs` units, and the sentences that say
when a feature is off or why it cannot run. Plus the Realms sign-in line
and the pack browser: Add, Install, Filters, Realistic, Simplistic,
Vanilla-like and 16x/32x/64x.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:13:53 -04:00
iseaprojectsandopencode 3bb3899088 French: Studio, its recordings and its pages
The `/studio` commands come over whole: `record`, `replay` (open, pause,
resume, speed, seek, status, close and their errors), `freecam`,
`keyframe` and `path`, with their usage lines and status text. Then the
Studio screen (saved recordings, the empty-folder hint, open folder, play
replay, refresh, unknown server), the Music page with every situation
(menu, surface, creative, underwater, End, boss, credits, biomes), the
page titles, and the Extras.../Mods.../Replay Studio.../Music... entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:13:52 -04:00
iseaprojectsandopencode b5249678a2 French: the Extras screen, the new options and the controller guide
71 keys that fell back to English: `horizon.extras.*` (World, chunk
pregeneration and its tooltip, the Chat and Menus headers, the Music page
line, Zoom, the resource-pack browser and the friends relay notice), the
options added since 918 keys (`3D Skin Layers`, `Play in Background`,
`Gameplay Guide` and its three values, `Trigger Threshold`, the Nintendo
buttons and keyboard choices), and every label of the on-screen controller
guide (Use, Open, Take Half, Swap Hands, Release to Shoot...).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:13:52 -04:00
iseaprojectsandopencode 7b8eb4e6f4 Portuguese: controller settings, recipe transfer and the Studio timeline
The controller page and the lines it prints while you rebind, the pad notice,
the recipe-transfer messages, and the Studio timeline and top bar.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:41 -04:00
iseaprojectsandopencode 2ffc0b65ad Portuguese: PvP & HUD
The whole `horizon.pvp` block: the custom crosshair, the block outline, the hit
colour, the HUD widgets with their corners, chat timestamps, compact chat, copy
and search, the preset, the HUD editor, combat feel and the 1.8 rules, and the
visual tweaks.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:41 -04:00
iseaprojectsandopencode 0ae809ff1a Portuguese: moderation, tempban, options and page entries
`/mute`, `/mutelist` and `/tempban` with their durations and reasons, the
remaining options, and three page entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:41 -04:00
iseaprojectsandopencode 3a5555a05e Portuguese: the graphics help texts, Realms, Extras and packs
Flat keys, the ones whose English sentence is also the key, so only the value
moves: GPU Culling, Upscaling Sharpness, Opaque Ice, Hidden Faces and the
sentences that say what each setting costs. Plus the Realms sign-in line, the
Extras entries, friends, splitscreen, the pack browser and the emote wheel.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:41 -04:00
iseaprojectsandopencode b51c4fd973 Portuguese: Studio, its pages and the mod browser
The `/studio` commands and the Studio screen, the Music page with every
situation, the page titles and the `...` entries, and the whole mod browser.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:40 -04:00
iseaprojectsandopencode 2a33624c1e Portuguese: the new options and the controller guide
The options added since the file was last touched and all 48 labels of the
on-screen controller guide, which is the first thing a new player reads.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:10:40 -04:00
astelmach20andClaude Opus 5.5 9bbcb50aed Agent control: events report damage, healing and hunger
hurt (amount, health, damage type, attacker), heal and food events; hurt
and death carry the player position, where a death drops the inventory.

Fixes #12749

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:09:04 -05:00
umbx-dotandClaude Opus 5.5 4cc2d109bb Launcher: build on the Horizon the instance plays first
A native mod pinned to an older commit moved the whole build there,
even when it would have built on the Nightly the player picked (a
player's report on 11-04, and again with Freecam+ on fdc87ae).

- native_mods::build compiles every mod on the base's own commit first.
  Only a mod that pins another commit and is left out for not building
  there moves the build to the pinned revision, once; a build that
  can't move keeps the first one (a MISMATCH still asks Build anyway).
  Rebuild on Horizon (on_base) never moves.
- The fit line for such a version says so.
- The instance's Mods tab shows each native mod's standing: in the
  last build on the Horizon played, moved to another commit (amber),
  left out and why (red), or, before a build, how it should build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:05:03 +01:00
Horizon agentandClaude Opus 5.5 e7052b5cf9 datafix: prepare the 1.13 data fixer tables from the player's jar
The id tables of the 1.13-era data fixers (flat_tables.rs) and BlockStateData
(block_state_data.txt) were copied from vanilla's code into the repo. They now
come from the player's own server jar, like the other prepared resources:

- VanillaDataFixTablesExporter reads the fixers' static tables by reflection
  and records BlockStateData's register calls in call order (its register is
  prefixed with a recorder in a private copy of the class, java.lang.classfile),
  then checks the recorded calls rebuild vanilla's MAP and ID_BY_OLD_NAME.
  horizon-prepare runs it as a new step and writes datafix-tables.json;
  OUTPUT_VERSION 4, so installs prepare again.
- horizon-world's datafix/tables.rs reads that file once into the same
  &'static slices the fixes used (set_resources from the game's --resources,
  else HORIZON_RESOURCES, else the checkout's .local); no per-call cost.
- check_public_tree.py rejects the retired paths and any tracked file holding
  50+ entries shaped like those tables; gen_flat_tables.py and
  gen_block_state_data.py are gone.

The loaded tables dump byte for byte the same as the old compiled-in ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:02:42 +01:00
Ludovic Marie 0395b7220e Merge pull request #12731 from horizon-rust/fix/8513-dxc-igpu
🤖 Agent: discrete GPUs never see the DXC DLL: FXC is now set explicitly, because wgpu's default Auto would load the dxcompiler.dll the zip puts beside the exe for every GPU. Only an IntegratedGpu on DX12 reopens with DXC, once at device creation, and keeps FXC if the DLL fails or the DXC instance picks another GPU.
2026-11-08 18:58:22 +01:00
astelmach20andClaude Opus 5.5 dd6d2d09a2 Agent control: a command running when the window closes says so
Fixes #12728

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:52:36 -05:00
astelmach20andClaude Opus 5.5 dd0a1e8c39 Agent control: entities says what the search covered
Fixes #12727

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:52:36 -05:00
astelmach20andClaude Opus 5.5 84f8a89fce Agent control: errors name what is in the way
Fixes #12726

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:52:35 -05:00
astelmach20andClaude Opus 5.5 bab9bd9eec Agent control: place and interact take --sneak
Fixes #12725

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:52:35 -05:00
astelmach20andClaude Opus 5.5 f511bc7488 Agent control: item stacks report max_damage and durability_left
Fixes #12724

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:52:34 -05:00
Ludovic Marie 43f69216d3 Merge pull request #12721 from horizon-rust/docs/java-sort-comments
🤖 Agent: comments only; java_sort's body is byte-for-byte unchanged, the comments now cite listsort.txt and the Arrays.sort Javadoc instead of OpenJDK's private method names.
2026-11-08 18:52:21 +01:00
Horizon agentandClaude Opus 5.5 9bde691abc Take the Afri-Network partner server back out of the server list
The partnership ended, but #9299 still adds Afri-Network below Horizon
Official on every first start. Stop offering it, and take it out once
from lists where the game added it: horizon_partner_servers.txt lists it
and the entry still has the name the game gave it. A player's own entry
for that address (their name) stays. Then the marker goes, so this runs
once. servers::remove_where keeps the other entries and their fields;
insert_after had no other caller and goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:52:12 +01:00
iseaprojectsandopencode 425652fce4 Spanish: the 128 keys es_es still missed
`en_us.json` grew from 308 to 1047 keys in the week before #11826, which
took `es_es` to 918. The keys added after it fell back to English on every
Spanish screen: the whole controller guide (49), Studio's freecam, keyframe,
path, timeline and top bar (48), the new options (8), and 23 flat keys such
as `Upscaling Sharpness`, `Opaque Ice` and the DualSense and Steam keyboard
descriptions.

All 128 are translated now. The file keeps `en_us.json`'s key order, which
it already followed, so the diff is the new lines only; keys, `%s`
specifiers and every existing value are unchanged. `es_ar`, `es_cl`,
`es_ec`, `es_mx`, `es_uy` and `es_ve` read this file, so they gain the text
too.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 13:51:13 -04:00
Ludovic Marie 6651840801 Merge pull request #12720 from horizon-rust/fix/no-iris-identity-macros
🤖 Agent: removes IS_IRIS/IRIS_VERSION/IRIS_FEATURE_* as OQ-2 decided; real_packs over BSL, Complementary (both) and Sildur's: no pack loses a program, Complementary now loads terrain and water (9 skipped → 3).
2026-11-08 18:49:51 +01:00
Florian ErnstandClaude Opus 5.5 2d75ab2e8f A dedicated server refuses a world whose predicates reference a missing or cyclic predicate
Vanilla's server never reaches Ready when the world's packs have a
predicate that references a predicate no pack defines (the registry
freezes with unbound values) or predicates that reference each other
(loot validation: recursively called). Horizon read the packs' data
lazily after Done and started such a world.

The dedicated start now reads the data store right after the world
loads, and a partial check of the predicate graph (all_of/any_of terms,
inverted term, inline conditions followed) logs vanilla's diagnostics
and ends the start before anything listens, with exit code 0. Other
holder positions, other registries, single player, LAN and /reload are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:46:44 +01:00
umbx-dotandClaude Opus 5.5 953512487b Launcher: Back to results is a filled button with an arrow
As a ghost button it read like the page's text and was hard to find.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:45:31 +01:00
wxundandCodex a9307ea8b7 Merge origin/main into fix/boat-paddling-attacks
Keep main's controlling-passenger check and shared controller movement helper, while retaining the boat attack guard and regression coverage.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:43:07 -05:00
astelmach20andClaude Opus 5.5 6bf6b1e2c8 Agent control: goto also reports the horizontal distance
goto arrives when the horizontal distance is within near (and the height
within a block or so), but its reply gave only the 3D distance, so an
arrival one block below read as farther than near. Report
horizontal_distance next to it.

Fixes #12707

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:42:52 -05:00
astelmach20andClaude Opus 5.5 33f1f52d62 Agent control: break keeps mining a block that changes into another
step_break took any state change for the break, so dirt spreading to
grass while mined ended the task with ok and no drops. Count the block
broken only when it is gone (air, or the bare fluid of a waterlogged
block or ice); otherwise track the new state and keep mining.

Fixes #12706

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:42:41 -05:00
astelmach20andClaude Opus 5.5 5ad154d184 Agent control: interact waits for the server's open-screen before replying
In single player and as a LAN guest the menu of a crafting table,
furnace or chest opens when the server's packet arrives, after the
2-tick report. Wait up to 20 more ticks while nothing came of the click,
as place already does, so the reply's screen is the opened menu.

Fixes #12705

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:42:27 -05:00
Florian ErnstandClaude Opus 5.5 243066eeca Test: a dedicated server refuses a world whose predicate graph is missing a reference or cycles
Checkpoint before the fix: the startup harness and its contract (a valid
world serves its reference; a missing reference or a cycle ends the
start by itself before Ready, with vanilla's diagnostics).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:41:23 +01:00
Evrim CicekandClaude Opus 5.5 08528c929d A hurt's teleport leaves the tick's block scan and fire contact where vanilla's does
Entity.applyEffectsFromBlocks fixes the tick's movements before stepOn,
so a magma, cactus or campfire hurt that teleports an enderman no longer
moves the rest of the scan; a baseTick hurt that teleported it reads the
fire and lava contact at the landing spot (snapTo resets the old
position); and a hurt left from outside the mob's tick still teleports
before the rest of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:41:08 +03:00
Horizon agentandClaude Opus 5.5 2863d031b9 Make the End gateway ticking test a child of end.rs
It reads EndState's private gateways map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:40:12 +01:00
fbikatandClaude Opus 5.5 f780545a63 Format the DrownedConversionTime save line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:38:08 +00:00
wxundandCodex adba2e3fd0 Restore the empty hand after boat interaction checks
Clear the spyglass used by the new regression before the existing fist-based boat destruction check.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:37:24 -05:00
umbx-dotandClaude Opus 5.5 39f398091b Launcher: Find mods shows what works on the instance
Find mods on an instance opened the Mods page as it was left, often on
the last project's page, and the Versions tab kept the fit worked out
for the instance selected when the page opened.

- Find mods opens the registry's mod list with "Only what works on
  {instance}" on: native mods with no version that builds there, or
  moves the build to its commit without a clash, stay out.
- Each registry native mod's row says how it works on the instance.
- With the filter on, the Versions tab hides the versions that don't
  work there and says how many.
- Picking another instance opens the project page again, searches
  again and drops the last message.

Closes #12709

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:37:21 +01:00
wxundandCodex e2c5f32ad4 Block new attacks while paddling boats
Apply the existing busy-hands attack guard to the server-thread client and include controller steering in the shared busy state. Add explicit parity scenarios and host/guest regression checks.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:33:29 -05:00
Evrim CicekandClaude Opus 5.5 6c6aa33d33 An enderman or shulker hurt twice in a tick teleports inside each hurt
Enderman.hurtServer draws and teleports inside every hurt (even one the
hurt cooldown turns away) and Shulker.hurtServer inside a low-health one,
so the hurts of a mob's own tick (baseTick, tickEffects, block contact,
cramming, water) now each run hurt_teleports right after their damage
instead of one call after all of them.

Closes #12660

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:33:18 +03:00
Tran Anh Van 4aeaf7d69e test(snbt): use 22-key mannequin compound (capacity 32) in shared bucket 5 test
At 25 keys, Java HashMap capacity doubles from 32 to 64 (threshold 24), splitting `immovable` (bucket 5) and `pose` (bucket 37) into different buckets. Using a 22-key mannequin compound keeps capacity at 32 where both keys collide in bucket 5.
2026-11-09 00:30:59 +07:00
RLC248andCodex 3ad431c6a3 Keep sculk block entity tags after command placement
Apply watched sculk transitions before loading placement NBT so the next
tick retains saved sensor frequencies and shrieker warning levels.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 18:29:48 +01:00
Ludovic Marie dd2aa4c808 Merge pull request #12684 from horizon-rust/docs/cleanroom-shader-spec
🤖 Agent: docs only; every line of the three published files was read for private material, and only local paths, the machine name, a private rules file name and the unpublished plan link were replaced (each file says so at the top).
2026-11-08 18:25:37 +01:00
iseaprojectsandopencode cd225f801e Launcher: find a Java installed on the Windows PATH
`java::installed()` looked in every PATH entry's `bin` for a file named `java`. Windows spells the program `java.exe`, so `canonicalize` never found one and a JDK the PATH alone names (not JAVA_HOME, not one of the folders installers use) stayed invisible. A Java instance then downloaded Mojang's runtime instead of using it, which also fails offline, and `horizon-launcher java list` did not show it.

The scan is now `homes_from_path()`, which asks for the name the OS ships (`java.exe` on Windows), with a test over a fake home.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 13:25:17 -04:00
MrCloudy2andClaude Opus 5.5 7344d153b0 A mob's shot first moves on the tick after it is fired
ServerLevel.tick walks its entities with EntityTickList.forEach, over the
list as it was when the pass began: an entity that joins during the pass
(a breeze's wind charge, a skeleton's arrow) first ticks on the next tick.
Horizon ticked the projectiles mob events add in that same tick, so a
mob's shot took a step before anything could meet it where it was fired.
Projectiles whose id is above the pass's start (ticked_entities_through,
as dropped items already use) now wait; a player's or a dispenser's shot,
which joins before the pass, still ticks at once.

Harness: test_mob_shot_join_tick_parity (dedicated servers, frozen and
stepped: a breeze's first wind charge is first seen where it was fired, a
dispenser's has already flown).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:24:58 +01:00
fbikatandClaude Opus 5.5 88b8be3eea Save skeleton FreezingTime -1 outside powder snow, and zombie villager and zombified piglin DrownedConversionTime
ConversionTracker.addAdditionalSaveData writes the affliction time only while
the mob is afflicted. Mobs now cache isInPowderSnow each tick, so a skeleton
outside powder snow saves FreezingTime -1, as a fresh or NoAI one does in
vanilla. Zombie villagers and zombified piglins save Zombie's tracker too;
Horizon never starts their water conversion, so DrownedConversionTime is -1.

Fixes #12522.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:23:30 +00:00
Horizon agentandClaude Opus 5.5 3417c795ec End gateways and the tracker skip entities frozen past the simulation distance
EndGatewayBlock.entityInside comes from the entity's own tick
(applyEffectsFromBlocks), and ServerLevel.tick ticks only the entities in
entity-ticking chunks. find_end_gateway_portals swept every item, mob, boat,
cart and pearl in the End through the gateway check, so an item frozen past
the simulation distance could enter a gateway, and each one cost a block
sweep every tick. It now skips them, and handle_end_gateway_portals leaves a
waiting one's state alone and returns at once when nothing is in a gateway
(it built a Vec and a HashSet of every entity in the End each tick).

The tracker's send pass skips the entities with nothing to send (idle, not
moved, no sync asked, leash as sent) that no client starts tracking, without
looking their state up again, and sweeps the states only when one left.

The official SMP's AFK carpet dupers in the End pile up tens of thousands of
such items (31,972 on 2026-11-08). With 16k of them and one player, the
dedicated server's tick goes from 12.5 to 9.3 ms (end_gateways 2.18 -> 0,
lan_track_entities 3.39 -> 2.8).

Fixes #12682

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:23:20 +01:00
apeirs 2d708acee1 Far terrain: the render thread never waits for the sampling and meshing threads' queue locks
request_samples and dispatch_meshes locked the job queues that the far terrain
threads share, and those threads run behind the render thread
(far_terrain_current). One preempted while holding a lock kept the frame
waiting: on a busy 8-core laptop at render distance 24 with LOD distance 1024,
lod_wanted took up to 216 ms of wall time with no CPU time. Both now try_lock
and, when the queue is busy, send their requests on a later frame.
2026-11-08 18:21:46 +01:00
Florian ErnstandClaude Opus 5.5 38a989a235 An inline /loot table's reference to a missing loot table fails the parse
ResourceOrIdArgument.lootTable decodes an inline table while the command
is parsed. A minecraft:loot_table entry's value is a HolderSet<LootTable>
(HolderSetCodec over RegistryFileCodec), so an id the loot table registry
does not hold fails the decode, and the command never runs. Horizon
accepted any compound and ran the table.

The inline decode now follows the table's pools, entries and composite
children to each such entry's value, and checks one identifier against
the registry's ids (the world's tables, refreshed on /reload), with
vanilla's either messages. A nested inline table passes its error up.
Lists, tags, other fields and other codec errors are not checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:20:22 +01:00
LokiandAntigravity 4803ac509a entity_nbt: correct equine and ravager attributes on load and save (fixes #12558)
Co-Authored-By: Antigravity <[email protected]>
2026-11-09 02:19:32 +09:00
Ludovic Marie 5919b2ee37 Merge pull request #12675 from horizon-rust/fix/shop-render-skin
🤖 Agent: the hero's Buy and the prices now share one ownership rule (shop::owns), and a Shop picture's cache key changes when its item files arrive, so the bare-player bake no longer sticks.
2026-11-08 18:18:24 +01:00
Horizon agentandClaude Opus 5.5 a3ea52c7ae HUD: describe java_sort from listsort.txt, not OpenJDK's method names
The comments named OpenJDK's private TimSort methods. They now describe the
algorithm (one run, then binary insertion) and cite Tim Peters's listsort.txt and
the Arrays.sort Javadoc. No code change (#6741).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:17:55 +01:00
Horizon agentandClaude Opus 5.5 f606cb820c DX12: DXC on integrated GPUs, FXC elsewhere, with Microsoft's dxcompiler.dll in the Windows x64 zip (#8513)
The adapter is first requested on an FXC instance; an integrated GPU on
DirectX 12 is requested again on an instance that loads dxcompiler.dll
from beside the game, once at device creation. A missing DLL, one that
fails to load, or an instance that offers another GPU keeps FXC with one
log line. WGPU_DX12_COMPILER still overrides the choice.

The Windows x64 game build downloads DXC v1.9.2609 from Microsoft's
release (zip and DLL SHA-256 checked) and ships its licence files and a
notice beside it. Measurements by Mayonesu0 and Cottleta on #8513.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:15:52 +01:00
Horizon agentandClaude Opus 5.5 76ef5f41ee Shader packs: define no other loader's identity macros (OQ-2)
Horizon no longer defines IS_IRIS, IRIS_VERSION, IRIS_HAS_TRANSLUCENCY_SORTING or
any IRIS_FEATURE_<name>, as the clean-room decision OQ-2 says: Horizon is not that
loader and does not claim its identity. iris.features.required still refuses a
pack that needs a feature Horizon lacks (Macros::feature_notes); features Horizon
has stay available, and packs take the path they take without those macros.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:15:20 +01:00
Tran Anh Van 645e685dc3 fix(parity): use exact main-based reconstructions for snbt, environment_timeline, and codec
Replaces the pre-reconstruction files from 16d1d18 with the exact SHA-verified main files patched for #12373 (fixing snbt::tests::compact_form_matches_vanilla, removing unused t_or helper, and preserving target-arch NaN sign bits).
2026-11-09 00:14:46 +07:00
Horizon agentandClaude Opus 5.5 b5fd86ea95 Docs: publish the shader-pack clean-room spec and provenance log
The specification the shader-pack rewrite was built from, its provenance log and
the two-team rules, as written in October, with local paths, machine names and
private file names replaced. Linked from the docs index; CONTRIBUTING.md and the
pull request template ask shader-pack PRs to name their sources (#6739, #1555).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:12:02 +01:00
Florian ErnstandClaude Opus 5.5 5b01bbb736 Test: an inline /loot reference to a missing loot table fails the parse
Checkpoint before the fix: the Game tests and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:11:33 +01:00
RINNECODERandClaude Opus 5.5 d86f9b8ae1 LAN host: a world's own registries keep the recipes its packs' data already read
With world packs that change biomes, lan_host read
the sync data with the world's packs, then read it all again for the world's
biome registry: the recipes and advancements twice from the same files (~0.7 s
on the server thread before the first frame). SyncData::load_over keeps them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:43 -06:00
RINNECODERandClaude Opus 5.5 5c90dcf106 Mod content: biome modifiers apply over a world's data packs
A world pack that replaces a vanilla biome (Terralith's) dropped every
mod's biome modifier features and spawns: the content root baked them into
its biomes before the packs loaded. NeoForge applies modifiers once every
pack is in, against the final biomes and tags. The content root now keeps
its modifiers (biome-modifiers.json), and the generator's data and the
biome table apply them again over the packs' biomes and biome tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:42 -06:00
RINNECODERandClaude Opus 5.5 6a9479bd4e Port pack: Lithostitched weighted_selector features become 26.3's weighted_random_selector
An entry without a weight weighs 1; 26.3 reads only {data, weight} entries.
Regions Unexplored's 79 tree, shrub and flower groups used it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:42 -06:00
RINNECODERandClaude Opus 5.5 f41546e06e Port pack: block state variants take 1.21.1's string booleans and numbers (uvlock "true")
Gson read them; 26.3's codec does not, so Tom's Simple Storage's cable
connectors lost their whole block state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:42 -06:00
RINNECODERandClaude Opus 5.5 d543e34821 Port pack: Lithostitched add_features/remove_features modifiers become NeoForge biome modifiers
The game already applies data/<ns>/neoforge/biome_modifier files; Regions
Unexplored's 34 vanilla_changes were left. A mod's own <ns>:config predicate
is taken at its default (on); any other predicate leaves the modifier out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:42 -06:00
Horizon agentandClaude Opus 5.5 bdc89c7247 Launcher Shop: draw the items on its pictures, no Buy on an owned pack
- A Shop picture's key now counts only the item files already loaded. The catalogue is read
  from disk at once and the files come a moment later, so the first picture (the bare player)
  stayed for good: the cosmetics never showed on the Shop's looks and pack.
- The pack's hero shows "Owned" alone when the account owns every item of it, instead of
  "Owned" beside a Buy button (`shop::owns`, also used by the open offer and the prices).
- Without its own skin in the cache, the account wears the default skin its UUID has in the
  game (Steve, Alex…), from the player's prepared game files, and its own skin once the
  profile heads have fetched it, instead of a plain figure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:09:10 +01:00
MrCloudy2andClaude Opus 5.5 652bb95f02 A young projectile does not hit what it only passes near
ProjectileUtil.getHitResultOnMoveVector grows the boxes of the entities on
a projectile's way by computeMargin: max(0, min(0.3, (tickCount - 2) / 20)),
nothing for its first two ticks and 0.3 from its eighth. Horizon grew them
by 0.3 from the first tick for wind charges, fireballs, thrown items, llama
spit and shulker bullets, so a fresh one burst on anything it passed within
0.3 of (arrows already used the margin).

Harness: test_projectile_hit_margin_parity (dedicated servers, frozen and
stepped: a wind charge passing 0.2 beside a mob on its second tick flies
on, one passing as close on its eighth hits).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:56:57 +01:00
JoansitohandClaude Sonnet 5.5 785b64e2ea launcher: let the wheel reach the page when an inner list cannot scroll that way
#11860 stopped the wheel unconditionally on Home's patch log and the console logs, so a log
with little content, or one already at its top or bottom, swallowed the wheel and the page
behind it never moved. A shared `TakeWheel::take_wheel` now stops propagation only when the
list's own offset, clamped, actually changed with the step; otherwise the event goes on to
the page's #content scroller. Scrollbar::wrap uses it too.

Fixes #12650

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 17:52:23 +01:00
Claude e905e18569 Launcher: drop the "{count} stars on GitHub" string from every language
The page no longer spells the stars out, and the i18n tests refuse a
string the code doesn't ask for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2TmRHLoVrJS29k1sF9
2026-11-08 16:50:11 +00:00
RLC248andCodex 56777de07b Fix summoned trident pickup and projectile save state
Preserve pickup modes, owner identity and projectile flags through save/load
and data edits. Tick tridents and other projectiles only in entity-ticking
chunks so unloading cannot advance a saved fireball's flags.

Add differential coverage for defaults, pickup modes, owner proximity,
flight versus stuck ticks and guarded chunk reloads against vanilla 26.3.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 17:49:07 +01:00
iseaprojectsandopencode 12da8060d9 Carry a server's death_protection component to the client's hands
/give @s apple[death_protection={}] still showed a plain totem when it saved the player: the wire reader kept component 36 as opaque bytes, so the stack's data never held minecraft:death_protection and findTotem's hand lookup fell back to a new totem.

Decode the component as DeathProtection.CODEC saves it: the death_effects list (absent when empty), each ConsumeEffect in its codec form with defaults left out. A patch that removes the component now also reaches the stack as !minecraft:death_protection, so totem_of_undying[!death_protection] stops protecting.

Fixes #12632

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 12:49:01 -04:00
Claude 42eecc5c6b Launcher: a registry project's GitHub stars as a star, in the list and the card
The GitHub stars of a registry project (index `stars`) showed only on its
page, spelled out in the meta line ("1 stars on GitHub"), while the list
and the side card said "No ratings yet" for a project people had already
starred.

Now a project's GitHub stars show wherever its rating does, as GitHub's
mark, a star and the number: in the registry's rows before the downloads,
in the side card and on the page beside "Sign in with GitHub to rate".
"No ratings yet" stays for a project with neither ratings nor stars. The
page's meta line drops the words, which also had no singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2TmRHLoVrJS29k1sF9
2026-11-08 16:47:33 +00:00
gigamans442andCodex 1fb2b9d0e7 Select authored translucent block and mixed-particle programs
Load optional material programs and route tagged geometry only when the
variant is available. Keep legacy grouping/state for packs without usable
variants, and retain block identity and ordinary block shadows.

Add original loader, CPU material-run and explicit offscreen render controls.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 11:40:04 -05:00
02756b92c4 Piglin: swing the off hand when barters are thrown
`PiglinAi.throwItemsTowardPos` swings the off hand once before a
non-empty throw, so a barter leaves the hand the piglin admired it
in; the throws themselves looked like items appearing from nowhere
(#12512).

The guest flush sent every mob swing as the main hand. It now reads
the arm `swing_hand` recorded, so an off-hand swing reaches guests as
`ClientboundSwingAnimationPacket` with hand 1.

Co-Authored-By: Qoder <[email protected]>

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 17:40:00 +01:00
JoansitohandClaude Sonnet 5.5 3ae016d664 launcher: the sidebar's update card and Discord button wrap long translations
The glass sidebar's "A new version is available" card and "Join the Discord" button had fixed
heights, and the card's title and the Discord label no room to shrink: a longer translation
spilled out of them. The heights are now minimums (`ui::solid` too) and the labels wrap and
centre, so a long label grows its box. Labels that fit look as before.

Fixes #12645

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 17:35:33 +01:00
Claude 789b488061 Registry: a new icon is a new address
The index listed a registry icon at the same address however often it
changed (icons/<id>.png on the registry's site), and the launcher keeps
images by address for a week before asking again: an author who uploads
a new icon sees the old one in the launcher for up to seven days, while
the website already shows the new one.

An icon kept in the registry is now listed with ?v= and the first 12 hex
digits of its SHA-256. The same icon keeps its address; a new one gets a
new address, so every cache keyed by address fetches it at once. GitHub
Pages ignores the query. Screenshots keep their plain address: the edit
form reads their registry paths from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2TmRHLoVrJS29k1sF9
2026-11-08 16:34:28 +00:00
martipopsandClaude Opus 5.5 d491259de9 Draw Nintendo's glyphs, Xbox's View and Menu, PlayStation's Create and Options
Nintendo's art was Xbox's files copied (a red B, LB for L), and View,
Create and − were one plain minus. tools/pad_glyphs/gen.py draws the
missing ones in the hand-made set's style: plain white A, B, X, Y on the
dark key, L, R, ZL, ZR, − and +, two squares for View, three lines for
Menu, and their own icons for Create and Options. It uses the standard
library only and rewrites the files byte for byte. Xbox's face buttons
keep their colours.

Nintendo's shoulders, triggers, − and + get their art back in
pad_glyph::ART, and a test fails if two families share a picture other
than the sticks and the D-pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:23:39 -05:00
Tran Anh Van 22f64bbd46 fix(client): preserve IME input when pressing Enter to confirm character conversion
Fixes #12387
2026-11-08 23:22:33 +07:00
Oloion 81c62c63a3 perf: reuse frame scratch buffers in renderer 2026-11-08 19:21:50 +03:00
ItzKubass de76c6cfc6 Merge branch 'main' into patch-1 2026-11-08 17:13:29 +01:00
Tran Anh VanandGemini 3 Pro 16d1d18ef1 Fix mannequin_save key order, environment_timeline NaN sign, and inline_feature_parse codec errors
Co-Authored-By: Gemini 3 Pro <[email protected]>
2026-11-08 23:12:52 +07:00
36a9541fb7 Banner: keep the raid captain's ominous name on the wire
`StackData` keeps the raid banner as its `ominous_banner` flag rather
than the name, rarity and hidden layers it stands for, so a stack sent
to a guest carried only the patterns and read as a white banner. Send
the components `Raid.getBannerComponentPatch` describes, and write the
tooltip display NBT the way its codec does (#12530).

Co-Authored-By: Qoder <[email protected]>

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 17:03:25 +01:00
Nikita Maximilian Grimm e3d4a12cf4 Merge remote-tracking branch 'origin/main' into fix-orb-value 2026-11-08 17:01:01 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 edefee0edb experience orb saved Value: keep all six orbs inside the loaded chunk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:00:59 +01:00
martipopsandClaude Opus 5.5 1a7b54a71b Splitscreen offscreen tests: say what they check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:00:29 -05:00
gigamans442andCodex gpt-6.1-sol af30750fe9 Merge accepted main and qualify terrain source fixtures
Keep accepted colormap and world-warp behavior while refreshing the source-owner controls and legacy terrain binding fixtures.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 10:57:56 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 fdaf42a7ae Experience orbs: a float Value floors as NumericTag.shortValue does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:57:19 +01:00
Nikita Maximilian Grimm b40792ba62 Merge remote-tracking branch 'origin/main' into parity/conduit-enchanting-block-entity 2026-11-08 16:53:41 +01:00
ItzKubass 24e1f1b376 Missing line fix 2026-11-08 16:51:03 +01:00
J5CaseyandClaude Opus 5.5 61aa03d1ab Single-player owner: autosaves keep its live player attachments
While the integrated server's owner plays, its attachments live in
`Game::guest_attachments`; `store_owner` (autosave, pause save) stored the
hub guest's empty copy, so `players/data/<singleplayer_uuid>.dat`, the file
the owner loads from, lost them until a clean quit rewrote it. A crash after
an autosave rolled mods' player data back while the inventory stayed
current. `store_owner` now stores the live attachments, as `save_guest`
does on leaving; the map stays their one owner. Fixes #12612.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-08 09:42:14 -06:00
yahya-nusseibehandClaude Opus 5.5 d0da18c651 Login: refuse server RSA keys past Java's size limits
The client encrypts its shared secret and the server's challenge with
whatever public key the server's Hello carries. RsaPublicKey::from_der
had a minimum modulus size but no maximum, and no limit on the
exponent; an encryption costs the modulus squared times the exponent's
bits, so one oversized key (2 KiB to 4 KiB already takes seconds per
encryption, a full 2 MiB frame years) pinned a core and held the login
on "Encrypting…" with nothing to cancel it. No account is needed:
it happens before encryption, on any server a player joins.

from_der now applies RSAKeyFactory.checkKeyLengths, which vanilla's
KeyFactory runs on the same key: a modulus of at most 16384 bits and,
past 3072 bits, an exponent of at most 64 bits. Within those limits the
worst case is about 0.1 s per encryption. Real keys (2048-bit, e =
65537) are unaffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-08 17:38:55 +02:00
BortlesboatandClaude Opus 5.5 5717940f26 horizon-relay: the bridge slot test closes the host's end, so it passes on Windows
a_bridge_gives_its_slot_back_when_either_end_closes failed on Windows with
"bridges stuck at 1". When the guest leaves, the relay's copy from the guest
shuts the host's socket down, but Windows doesn't wake the read the other
copy already has blocked on it (as in #6183); that read returns once the
host closes, which the test's host never did. The test now reads the end
of the bridge on the host's side and closes it, as a host's pump does. On
other systems it still checks first that the slot comes back without the
host's help.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:38:03 -05:00
Tran Anh Van a8a83423be fix(neoforge-oracle): isolate failing loot/component codecs and halt on fatal error 2026-11-08 22:36:25 +07:00
Tran Anh Van 75d834b5f0 fix(neoforge-oracle): isolate failing loot/component codecs and halt on fatal error 2026-11-08 22:35:14 +07:00
Kacper c98a51a664 Merge pull request #12581 from MSedgeMC/fix/no-ghast-retaliation
A mob does not retaliate against a ghast
2026-11-08 16:32:41 +01:00
Kacper 9d55121379 Merge pull request #12580 from TheMrZZ/fix/sulfur-prime-phase
A sulfur cube loaded with a TNT body primes from its second tick
2026-11-08 16:32:34 +01:00
Kacper 3a90aecb12 Merge pull request #12579 from ilguerro/fix/thorns-mob-attacks
Thorns answers a mob's attacks on a mob, and a player's reaches the dragon
2026-11-08 16:32:27 +01:00
Kacper 524543dfa8 Merge pull request #12578 from MSedgeMC/port_map/rename-realm-world
port map: renameAndDropPlayer names the Realms download helper
2026-11-08 16:32:20 +01:00
Kacper 53a993c972 Merge pull request #12577 from MSedgeMC/port_map/set-damage-value
port map: ItemStack.setDamageValue is set_damage_value
2026-11-08 16:32:13 +01:00
Kacper 36c8983d68 Merge pull request #12576 from MSedgeMC/port_map/portal-ticket
port map: Entity.placePortalTicket is the portal ticket
2026-11-08 16:32:06 +01:00
Kacper 39c43b82bd Merge pull request #12575 from MSedgeMC/port_map/atlas-dump
port map: TextureAtlas.dumpContents is the F3 atlas dump
2026-11-08 16:32:00 +01:00
Kacper 0b0aded17c Merge pull request #12574 from MSedgeMC/port_map/minecart-redstone
port map: AbstractMinecart.getRedstoneDirection is wired under the feature
2026-11-08 16:31:53 +01:00
Kacper d978845087 Merge pull request #12566 from vnsmoreira/fix/particle-sprite-size
Resource packs: particle sprites of any size reach the atlas
2026-11-08 16:31:46 +01:00
Kacper 957eb14a5e Merge pull request #12565 from vnsmoreira/fix/level-change-shared-flags
Server: a level change keeps the player's shared flags in sync
2026-11-08 16:31:39 +01:00
Kacper 601772b4fa Merge pull request #12553 from TheMrZZ/fix/mob-landing-support
A landing mob falls on the block that supports it
2026-11-08 16:31:32 +01:00
Kacper 6a61fdbe28 Merge pull request #12550 from MrCloudy2/fix/mob-water-climb
A mob in waterlogged scaffolding or on an underwater ladder climbs when it hits a wall
2026-11-08 16:31:25 +01:00
Kacper be8b04774f Merge pull request #12549 from MrCloudy2/fix/mob-climbable-push
A mob on a ladder or scaffolding is not pushed before its own tick
2026-11-08 16:31:18 +01:00
Kacper 0698e8d46d Merge pull request #12545 from NikitaMGrimm/parity-fuzz-scenarios
parity_fuzz: persist and spawn scenarios, with field names from vanilla's own saves
2026-11-08 16:31:11 +01:00
Kacper 9afeda6be3 Merge pull request #12544 from NikitaMGrimm/parity-fuzz-runner
parity_fuzz: run generated scenarios in vanilla and Horizon and report each divergence once, shrunk
2026-11-08 16:31:04 +01:00
Kacper 89bb9243ab Merge pull request #12541 from glitchsnake/fix/12518-boat-underwater-buoyancy
game: use 0.01f32 buoyancy for boat under water (fixes #12518)
2026-11-08 16:30:58 +01:00
Kacper 981b36ef36 Merge pull request #12540 from NikitaMGrimm/fix/portal-ticket-outer-ring
The outer ring of a portal ticket runs scheduled ticks and block events
2026-11-08 16:30:51 +01:00
Kacper c3f2a9928e Merge pull request #12538 from vnsmoreira/packs/polytone-particle-colors
Polytone: colormaps on particles, blocks and fluids, and sound emitters
2026-11-08 16:30:38 +01:00
Kacper 5a8e4ea92c Merge pull request #12537 from vnsmoreira/packs/polytone-particle-rendering
Polytone: draw GPU particles, rotation modes and additive particles
2026-11-08 16:30:31 +01:00
Kacper 3427559f65 Merge pull request #12535 from umbx-dot/launcher/version-followups
Launcher: native mod version follow-ups (kept versions, build fallback, changelogs)
2026-11-08 16:30:24 +01:00
Kacper ecf510cfc2 Merge pull request #12529 from TheMrZZ/fix/stand-break-loot
A broken armor stand rolls its entity loot table
2026-11-08 16:30:12 +01:00
Kacper 8201422dc0 Merge pull request #12528 from apeirs/perf/adreno-dx12-flat-draws
Adreno under DirectX 12: draw vanilla terrain flat (2-13% more FPS)
2026-11-08 16:30:05 +01:00
Kacper dec609bb7c Merge pull request #12496 from Akirabane/fix/11705-recipe-book-paper
Recipe book: take the entries a server's packet carries
2026-11-08 16:29:58 +01:00
Kacper d67da2cf27 Merge pull request #12434 from Evan-lhm/fix/mod-content-below-packs
Packs: lay the resource packs over the mods' content root
2026-11-08 16:29:51 +01:00
Kacper 4aa63389e5 Merge pull request #11955 from Stink-O/native/world-warp
Native mods: a world warp, WGSL every terrain and entity vertex goes through
2026-11-08 16:29:43 +01:00
Kacper 92be6a71c2 Merge pull request #11703 from ilguerro/perf/eager-chunk-saves
Save changed chunks during play, as vanilla's eager chunk saves do
2026-11-08 16:29:36 +01:00
Kacper f1d2c12e29 Merge pull request #11570 from TheMrZZ/fix/h112-guest-container-close-event
Emit the final ordinary guest container close game event
2026-11-08 16:29:29 +01:00
Kacper 4e5296d180 Merge pull request #11068 from Delete228/content-block-state-light-upstream
Content mods: choose light emission per block state
2026-11-08 16:29:23 +01:00
ItzKubass d6bce553cc Translate Czech language strings in cs.json 2026-11-08 16:29:05 +01:00
gigamans442andCodex gpt-6.1-sol 085e9f6064 Keep shader terrain attributes tied to their source block
Retain per-quad source state, emission and ownership through meshing, sorting and arena uploads. Resolve pack material aliases by state and retain native geometry when metadata is unavailable.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 10:28:45 -05:00
apeirs 2d5d714788 DirectX 12: occlusion culling of solid sections, the depth test in the shader as on Metal 2026-11-08 16:20:54 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8b55208237 Using an enchanting table still opens its menu
With its block entity, an enchanting table took the container branch of
open_menu. It opens its station first, as a beacon does. The command
harness reads a station menu's slots, and the corpus group uses a table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:15:50 +01:00
MSedgeMC a4e0fc8a75 A spectator holding food does not tempt an animal
`TemptGoal` finds its player with `TargetingConditions.forNonCombat()`,
which never picks a spectator. Horizon only checked that the player was
alive, so a spectator still holding wheat tempted nearby animals.
2026-11-08 15:15:26 +00:00
J5CaseyandClaude Opus 5.5 854ab1726c Native helper: client_fireworks, a firework's bursts with no rocket
`native_client_fireworks(at, velocity, explosions)` (client,
`host.client_fireworks`) draws a firework's explosions as
`ClientLevel.createFireworks` does for an exploding rocket: the existing
`ParticleEngine::fireworks` starter, with its shapes, colours, fade,
trail, twinkle, flash and blast and twinkle sounds, and the smoke puff
for none. No entity, no damage, nothing on the server. `FireworkExplosion`
and `FireworkShape` are re-exported from `mod_api`. False on a server,
without a client player or level, for non-finite numbers and past 64
explosions. Fixes #12597.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-08 09:14:00 -06:00
martipopsandClaude Opus 5.5 3254251593 Splitscreen: an --offscreen run shows none of its windows
With --offscreen the game's first window is created hidden, but
splitscreen showed windows anyway: ending splitscreen called
set_visible(true) and turned the run windowed (options.offscreen =
false, a windowed renderer), Window 1 was shown again once a player
played in it, extra player windows were created visible, and the
compositors presented on every window (which maps a Wayland window
whatever set_visible says).

Split keeps the run's --offscreen: windows are created and set hidden,
nothing is presented, the frame limiter paces the frames, and ending
splitscreen draws offscreen again. Without --offscreen nothing changes.

Fixes #12586

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:13:52 -05:00
MSedgeMC e6c80be2c4 Universal anger lets an angry bee pick the player
`NeutralMob.isAngryAtAllPlayers` is the universal-anger rule: with it on,
a still-angry bee targets any valid player even when its persistent anger
target is gone. Horizon's `BeeBecomeAngryTargetGoal` only accepted the
stored target, so a bee whose target was cleared never picked the player
under the rule.
2026-11-08 15:13:41 +00:00
MSedgeMC fc633db30b test: the first-tick bubble column push is a server's too
The second tick of a throwable in a column is in the column's water, so its
drag is getWaterDrag's 0.8F rather than the air's 0.99F; the assertion now says
so instead of expecting the air drag.
2026-11-08 15:12:38 +00:00
MSedgeMC 2bb5bb2965 fix: toggle_tooltips hides and shows the item's tooltip lines
ToggleTooltips updates the item's minecraft:tooltip_display component:
each false toggle adds the component to hidden_components, each true one
removes it, and a hide_tooltip an earlier function set stays. The loot
engine had no arm, so a datapack table's toggles changed nothing. The item
layer's TooltipDisplay already reads the component from `other`, so only
the loot arm was missing.
2026-11-08 15:12:16 +00:00
MSedgeMC 5088717918 fix: set_written_book_pages writes the book's pages
SetWrittenBookPagesFunction runs the function's ListOperation over the
pages of the item's minecraft:written_book_content component, keeping the
title and author an earlier function wrote. The loot engine had no arm, so
a datapack table's pages changed nothing. The item layer's
written_book_from_tag already reads the content, so only the loot arm was
missing.
2026-11-08 15:12:07 +00:00
MSedgeMC 711dad1923 fix: set_lore writes the item's lore lines
SetLoreFunction runs the function's ListOperation over the item's
minecraft:lore component and writes the lines back. The loot engine had no
arm, so a datapack table's lore changed nothing. The item layer already
reads the component from `other` (the tooltip's lore_texts, the LAN item
sync and /item modify), so only the loot arm was missing.
2026-11-08 15:11:51 +00:00
J5CaseyandClaude Opus 5.5 7bdd8c9ae1 Native helper: add_toast, a mod's own toast in the advancement toast slots
`native_add_toast(id, icon, title, description, challenge)` (client,
`host.add_toast`) queues a toast drawn as vanilla's `AdvancementToast`:
the item icon, the mod's title in the frame label's place (yellow, or pink
with the challenge sound) and the description as the white line, wrapped
and faded in when long. It shares the five slots and the queue with the
advancement, recipe and system toasts. `native_toast_add` hears it first
as kind `mod` with the mod's id, so a cancelled one isn't queued. False on
a server and without a window. Fixes #12590.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-08 09:11:45 -06:00
J5CaseyandClaude Opus 5.5 39e6679121 Native hook: player_inventory_changed, a player's inventory changing
`native_player_inventory_changed(player, stacks)` is heard on the server
when the per-tick `InventoryChangeTrigger` check finds a player's
inventory changed, before any criterion is looked at, so it fires whether
or not the tree has `inventory_changed` advancements. `stacks` are the 41
slots in `Inventory` numbering (main 0..36, armour feet to head 36..40,
off hand 40). The player's state is swapped in. It fires for the single
player, LAN guests and a dedicated server's players, never on a client
of a remote server, and once at a player's first check after joining.
With no mods the slots aren't copied. Fixes #12587.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-08 09:11:42 -06:00
ArtemandClaude Opus 5.5 c01554de38 Launcher on Windows: a program running from a version keeps it in use
The Windows check only looked at the game's runtime slots, so removing a
version that horizon-ctl, horizon-server or horizon-prepare ran from went
ahead, stopped at the running image with os error 5, and left the version
half deleted. Any running .exe in the version's folder now counts, as any
process naming the folder does on Unix. The cleanup after a Nightly update
checks too: it removed old versions without asking.

Fixes #12588

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:07:56 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 2f86a8c15f Experience orbs: a missing Value is 0, and 0 or below stays as given
ExperienceOrb.readAdditionalSaveData reads getShortOr("Value", 0) with no
clamp and saves (short) getValue() back. Horizon read a missing, 0 or
negative Value as 1, so such an orb saved Value 1s and gave 1 XP. The
orb's value is now a signed int; a pickup gives only a positive value, as
playerTouch gives only a positive remainder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:07:07 +01:00
martipopsandClaude Opus 5.5 31f22dbe88 Merge upstream/main into ui/pad-glyphs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:04:49 -05:00
martipopsandClaude Opus 5.5 67d42261fc Show Nintendo's shoulders, triggers, − and + as their labels
The Nintendo glyph art for L, R, ZL, ZR, − and + was a byte copy of
Xbox's LB, RB, LT, RT, View and Menu, so a Nintendo pad was told to press
LB or LT. Those parts now show their label on a key (L, R, ZL, ZR, −, +),
as parts without art already do, and the copied files are gone.

The face buttons keep their copied art: Xbox's A, B, X and Y print the
same letters as the Nintendo buttons, only in Xbox's colours. A test
fails if a Nintendo picture is an Xbox copy that prints another label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:04:43 -05:00
MSedgeMC 07c9753b76 A mob does not retaliate against a ghast
`Mob.canAttack` refuses a ghast for every mob, and
`HurtByTargetGoal.canUse` ends with `canAttack(lastHurtByMob)`. Horizon's
`spares_target` only knew the iron golem's creeper rule, so a ghast's
fireball made a mob take the ghast as its target.
2026-11-08 14:48:53 +00:00
devtalhandClaude Sonnet 5.5 5d97cf0158 horizon-prepare: find Prism's flat meta/net.minecraft/<version>.json
Prism Launcher 11.1.1 keeps its Minecraft component as
meta/net.minecraft/26.3.json, not meta/net.minecraft/26.3/26.3.json,
so preparing from a Prism root failed with "Missing .../versions/26.3/26.3.json".
The flat file is now the last candidate; existing candidates keep their order.
The launcher already reads this path (launcher/src/sources.rs meta_client_jar).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:45:05 +03:00
Florian ErnstandClaude Opus 5.5 b79fd522cb A sulfur cube loaded with a TNT body primes from its second tick
SulfurCube.tick checks for power (primeWhenOnPoweredPosition, which
needs the body's explosionData) before super.tick adopts the body
(detectEquipmentUpdates → collectEquipmentChanges). A cube loaded with
a TNT body on power therefore primes on its second tick. Horizon's
priming pass ran after the mobs' ticks and read the live body, so such
a cube primed on its first tick and blew up a tick early. The pass now
considers only the cubes whose adopted body explodes as the tick starts.

Part of #12572

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:38:55 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 5f5b78fb47 Conduits and enchanting tables placed by /setblock have block entities
ConduitBlockEntity keeps its Target (the destroyTarget reference) and
EnchantingTableBlockEntity its CustomName, so /data reads and changes
them and a save keeps them. The conduit's server tick reads and writes
the target through the block entity. /data get block prints a block
entity's own fields before components, id and position, as
saveWithFullMetadata writes them.

Fixes #12519

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:38:31 +01:00
RINNECODERandClaude Opus 5.5 7983faa6ce Mod content overlay: whole namespace folders cloned, other files copied on threads, tables beside them
The cold overlay of a large modpack (156k entries) spent half its time cloning files
one by one and a third waiting for the tables. A mod's own namespace folder the base
lacks is now cloned whole (clonefile, one call), the files the copies leave out
deleted from the clone; the other files are copied on up to 8 threads, while the
tables are rewritten beside them. The overlay's tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:36:41 -06:00
RINNECODERandClaude Opus 5.5 36f20dd0aa Startup reads each mod's content once: the live-switch verdict is kept per mod version
start_mods (the window's, then the server thread's) read every installed mod's
content again to tell whether it switches live, after content_root had read it:
3-4 s each with a large content-mod modpack. The verdict is now kept by path and
modification time, seeded by content_root; --dev-mod folders are always read.
Also traces mods.content_root, mods.overlay and world.preload (HORIZON_STARTUP_TRACE).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:36:41 -06:00
MSedgeMC de5617664b port_map: renameAndDropPlayer names the Realms download helper
The row skipped the method for having no caller, but Horizon calls
rename_and_drop_player when a downloaded Realms world has no links.
2026-11-08 14:34:55 +00:00
MSedgeMC e9d179e183 port_map: ItemStack.setDamageValue is set_damage_value
The row dismissed the method as a field write, but set_damage_value is the
documented clamped write and the anvil, grindstone and mob-slot paths call
it.
2026-11-08 14:34:31 +00:00
MSedgeMC e790fdeead port_map: Entity.placePortalTicket is the portal ticket
The row said arriving entities only wait in portals.arrivals. The ticket is
in the tree: Game::place_portal_ticket adds a PORTAL ticket of radius 3 for
300 ticks, server side, on the nether and end arrival paths.
2026-11-08 14:34:04 +00:00
MSedgeMC 7e78e51558 port_map: TextureAtlas.dumpContents is the F3 atlas dump
The row skipped the dump as debug only, but HudAtlas::dump_contents is
documented as the method and F3's dump-dynamic-textures shortcut writes
the atlas PNG through it.
2026-11-08 14:33:35 +00:00
MSedgeMC ee2e77db35 port_map: AbstractMinecart.getRedstoneDirection is wired under the feature
The row said Horizon only runs the old track behaviour. It runs both:
redstone_direction is called from move_along_track_new when
minecart_improvements is on, so point the row at it and name the gate.
2026-11-08 14:33:11 +00:00
Florian ErnstandClaude Opus 5.5 c0e2fb0c99 Measure a loaded sulfur cube's priming phase (H249 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:31:07 +01:00
LokiandAntigravity 4442aaed80 entity: fix spectral arrow and small fireball display names (fixes #12526)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 23:29:43 +09:00
ItzKubass d2c801b26f Fix Czech translation typos, grammar, and consistency
Fixed typos, grammar issues, and awkward phrasing in the Czech translation.

Improved terminology consistency to better align with standard Minecraft concepts.

Unified key terms across the file for launcher, pack, build, and instance.

Corrected grammatical cases, declension, and gender agreement.

Converted button labels to infinitive form for consistency.

Translated remaining English strings.

Fixed inconsistent T/V forms to keep formal address throughout.

Corrected product name spelling and its declension.

Replaced mistranslations with accurate terms.

Fixed preposition usage.

Corrected typos.

Improved category translations to match Minecraft/Modrinth conventions.
2026-11-08 15:26:49 +01:00
Arslan ArmanulyandClaude Opus 5.5 cca1842d3c Shader packs: rain and snow never write depth, as in Iris
Iris 1.11.7 leaves no rain or snow in depthtex0, whether rain.depth is
absent, true or false, and whether gbuffers_weather or its fallback draws
them: probe packs of ours, read in final, in our rain scene and in a
snowy_plains patch of it. Horizon wrote their depth unless rain.depth was
false, so a pack's later passes saw the rain columns as near geometry.

The weather passes now test depth without writing it, and rain.depth is
no longer read. The GPU weather test now expects the scene's depth after
rain and snow for every rain.depth value.

Part of #10492

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:23:41 +05:00
Vinícius MoreiraandClaude Opus 5.5 2e237b9aa1 Resource packs: particle sprites of any size reach the atlas
The GUI atlas, which the particles draw from, left out every texture over 1024 wide or 512 tall
without a word: a cap meant to keep GUI textures small. Vanilla's particle atlas stitches any
size, and packs draw light shafts, fog banks and flares from 1024- and 1500-wide particle
textures, which drew nothing. Particle sprites are no longer capped; GUI textures still are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:19:47 -03:00
Stink-OandClaude Opus 5.5 2bac359308 Native mods: a world warp, WGSL every terrain and entity vertex goes through
A render hook, native_world_warp, lets a client mod give the frame a world warp: its
world_warp(pos, section) -> Warped moves (and tints) every terrain vertex, far terrain's tiles
included, and every entity vertex; after a line // [fragments] its world_fragment(f) -> Painted
paints, retextures or cuts every terrain pixel by where the point really is. It reads sixteen
floats, the camera split for precision and the frame's partial tick at group 2.

The stock shaders hold only comment markers where it goes in; gfx/world_warp.rs fills them in
copies, validates them with naga and builds the warp's own pipelines (no back-face culling) the
first time a source is seen. While it draws, nothing is culled by where it was (frustum, caves,
CPU and GPU occlusion, section faces) and the frame keeps to the per-section draws. A frame
without a warp costs nothing more.

Tests: the warped shaders validate with and without a pixel stage and stay inside GL's 15
inter-stage variables, the stock shaders hold none of it, a broken source is refused with
naga's message, the hook keeps the last mod's warp. Example world_warp_check draws it through
the real renderer offscreen (and --bench times a frame with and without one).
docs/native-mods.md "World warps".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:19:30 +00:00
Kacpherek c2aaa3d39f blame: rank by what vanilla ran at the divergent tick
test_mob_ai_parity.py --writers runs the oracle under a JFR method trace of
the traced fields' setters (JDK 25) and marks where each tick starts, so
each divergence knows which vanilla methods set the field that tick. blame
lists first those that set it at the divergent tick and not at the one
before, then the others that set it that tick, then the static candidates.
When the motion differs the position isn't blamed separately: it follows.

With FloatGoal users not marked as floaters again (#542), the pig's first
divergence (tick 2, v) now lists LivingEntity.jumpInLiquid and
Mob.jumpInLiquid -> jump_in_liquid first and second; the static order put
jump_in_liquid sixth.
2026-11-08 15:12:28 +01:00
Vinícius MoreiraandClaude Opus 5.5 ef65ffc544 Server: a level change keeps the player's shared flags in sync
A level change sends ClientboundRespawnPacket with KEEP_ALL_DATA, so the client's new player
keeps the old one's synched shared flags (on fire, invisible, crouching...). The hub forgot what
it had sent, so a flag that cleared after the change never reached the client: a creative player
who left lava through a dimension change kept the fire on its screen until the next time the
flags changed. The flags sent now survive the change, and ServerEntity.sendDirtyEntityData's
update reaches the player as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:09:56 -03:00
MSedgeMC c7739935c3 fix: a throwable spawned in a bubble column is pushed on its first tick, on a server too
ThrowableProjectile.tick starts with handleFirstTickBubbleColumn: every bubble
column block in the box, entered precisely, pushes the projectile before its
gravity and inertia, so the push takes the tick's drag with it.

Horizon had that scan only in Projectile::tick_remote, the client tick of a
server's projectile, so a snowball or an egg thrown into a bubble column rose
for a guest and sank for the host, whose creature tick never ran it for them
(it pushes pearls through their own onAbove/onInsideBubbleColumn pass, which is
a different vanilla call and does not cover the first tick).

The scan is now Projectile::first_tick_bubble_column, run by the shared tick
before its gravity and inertia, so tick and tick_remote both get it once; the
swept columns applyEffectsFromBlocks pushes stay where they were.
2026-11-08 14:04:30 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 a33d9e37c8 parity_fuzz: persist and spawn scenario families, with field names from vanilla's own saves
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:55:03 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f496fff3af parity_fuzz: run scenarios in vanilla and Horizon, compare reads by field, shrink and group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:55:03 +01:00
Oloion a36ab31067 servers: prevent child pipes hanging on Windows and use REST API for server controls 2026-11-08 16:53:32 +03:00
MrCloudy2andClaude Opus 5.5 6c68f82884 A mob in waterlogged scaffolding or on a ladder under water climbs when it hits a wall
LivingEntity.travelInWater sets the vertical movement to 0.2 when
horizontalCollision && onClimbable(). Horizon checked the spider flag
instead, so a mob in waterlogged scaffolding or on a waterlogged ladder
never climbed, and a spider got the climb even when it was not climbing
(Spider.onClimbable is its climbing flag, which on_climbable returns).

Harness: test_mob_water_climb_parity (dedicated servers, frozen and
stepped: a mob in waterlogged scaffolding moving into a wall climbs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:53:08 +01:00
MrCloudy2andClaude Opus 5.5 def33e3892 A mob on a ladder or scaffolding is not pushed before its own tick
LivingEntity.isPushable reads onClimbable live from the block the mob
stands in. Horizon's cached flag was only refreshed by the mob's own push
step, so on the first tick after a load or a spawn (or after it moved)
other mobs pushed it out of a ladder or scaffolding. It is refreshed from
where each mob stands before the tick's snapshot.

Harness: test_mob_climbable_push_parity (dedicated servers, frozen and
stepped: a pair on plain ground pushes apart, a pair in scaffolding stays).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:53:08 +01:00
Florian ErnstandClaude Opus 5.5 01ecc1c7b5 A landing mob falls on the block that supports it
Entity.move refreshes the supporting block (setOnGroundWithMovement's
checkSupportingBlock) before checkFallDamage, whose landing block is
getOnPosLegacy, the supporting block when there is one. Horizon's mob
move refreshed it after the fall, so on the landing tick the fall used
the column under the mob's centre: a pig resting on stone with its
centre over honey took honey's fifth of the fall. The support is now
refreshed first.

Fixes #12536

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:50:50 +01:00
Loki facc7f17ea Merge remote-tracking branch 'origin/main' into fix/12518-boat-underwater-buoyancy 2026-11-08 22:46:31 +09:00
LokiandAntigravity 0aebe90bbc game: use 0.01f32 buoyancy for boat under water (fixes #12518)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 22:45:36 +09:00
Nikita Maximilian Grimm c73b892e42 Merge remote-tracking branch 'origin/main' into fix/portal-ticket-outer-ring 2026-11-08 14:43:29 +01:00
Florian ErnstandClaude Opus 5.5 4273e513c0 Measure a mob's landing support (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:37:10 +01:00
umbx-dotandClaude Opus 5.5 c288428168 Launcher: native mod version follow-ups
- The installed list shows a version picked on the Versions tab, with
  a button to go back to the recommended one.
- A build that leaves a registry native mod out installs that mod's
  next older version that says it builds here, and builds once more.
- The details say when an older version fits and the newest doesn't.
- Updates are looked for again when the instance's Horizon changes.
- The Versions tab shows each version's changelog.

Closes #12534

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:34:24 +01:00
Vinícius MoreiraandClaude Opus 5.5 fa99fc98e6 Polytone: colormaps on particles, blocks and fluids, and sound emitters
Custom particles take their `colormap` (the vanilla grass, foliage, dry
foliage and water colours, or a pack's colormap) multiplied into their
colour, sampled every tick, per block or once as its `cache` says. Block and
fluid modifiers' colormaps tint the blocks and water they target in the
chunk mesh, keeping the block's own tint where the colormap's pixel is
transparent. Colormaps read the named samplers (`biome_id` with its mapper,
in pixel columns, `temperature`, `downfall`, `y_level`, `day_time`), so
packs' sky and fog colormaps that pick a column per biome load too.
Particle and block sound emitters play their sound events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:31:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 5cc05d62f4 Polytone: draw GPU particles, rotation modes and additive particles
Resource packs' GPU particles (`"type": "gpu"`) are spawn records whose
position, size, colour and frame are a function of the time: gravity and
friction as drag, fade, sway, spin, colour and size toward their ends,
texture strips, `area` spreading one spawn over many quads, and quads under
the open sky only with `kill_below_heightmap`. Custom and GPU particles turn
as their `rotation_mode` says (facing the camera, around Y, flat, along their
motion), and `additive_translucent` ones go to the frame's additive pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:31:09 -03:00
martipopsandClaude Opus 5.5 0043afcd0c Merge ui/pad-glyphs into ui/controller-shell
Controller Settings' binding rows take the player's resolved `Glyphs`
(`Context::glyphs`, from `OptionsUi::pad_glyphs`). docs/ui-kit.md keeps
main's HUD kit guide and gains the settings shell as its own section and
table row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:27:45 -05:00
apeirs 5883c0a485 Adreno under DirectX 12: draw vanilla terrain flat
wgpu's indirect multi-draw is ExecuteIndirect on DX12, and on Qualcomm's Adreno
each per-section entry costs the GPU more than the flat path's compute copy and
one index list per page. Measured on an Adreno X1-45 (Windows 11 ARM64), the
four perf_bench scenes held still, three alternating rounds: 3-11% higher FPS at
render distance 12, 2-13% at 32, the same picture.
2026-11-08 14:17:48 +01:00
Florian ErnstandClaude Opus 5.5 66ff309502 A broken armor stand rolls its entity loot table
ArmorStand.brokenByAnything calls dropAllDeathLoot before it pops the
equipment: a stand that is not small (isBaby is isSmall) rolls its
type's table minecraft:entities/armor_stand when mob_drops is on. Horizon
only popped the equipment, so a data pack's loot for armor stands never
dropped when a stand burnt out, was blown up or broken by /damage. The
break now rolls that table first, with seed 0 (the table's random
sequence, else the level's random), and spawns each stack at the stand.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:14 +01:00
Florian ErnstandClaude Opus 5.5 cfeab5a7e9 Measure a broken armor stand's loot table (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:07:25 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 1c1ad8ec32 The outer ring of a portal ticket runs scheduled ticks and block events
A portal ticket's streaming area had radius ENTITY_TICKING - level (1 for
the level-30 ticket), so chunks finished only out to ring 2 and a ring-2
chunk never had all its neighbours: its scheduled block and fluid ticks and
block events never ran. Vanilla keeps ring 3 FULL (level 33), so ring 2
(BLOCK_TICKING) runs them. The area's radius is now BLOCK_TICKING - level.

tools/test_portal_ticket_ticking_parity.py sends an item through a Nether
portal on real vanilla and Horizon dedicated servers and compares a
repeater, a piston and water in rings 1, 2 and 3 of the exit's ticket.

Fixes #12288

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:51:55 +01:00
martipopsandClaude Opus 5.5 1241a139e9 Merge upstream/main into ui/pad-glyphs
Resolve docs/ui-kit.md onto main's HUD kit guide, with the glyph section added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:47:33 -05:00
Kacpherek b5aa2303fb port_ledger blame: from a parity trace's first difference to the code
A tick-by-tick parity trace says where a run first differs from vanilla, not
which code computed the value. `blame ENTITY FIELD...` lists the vanilla
methods an entity's tick runs (overrides resolved, plus the goals its
registerGoals builds) that write the field, directly (putfield) or through
its setter, its own class first, each with its Rust port and the ledger's
gaps on it. test_mob_ai_parity.py prints these under each mob's first
divergence when a ledger has been built, and the port of the vanilla frame
of the first random draw when a draw differs.
2026-11-08 13:43:25 +01:00
Kacpherek 616ba9f131 port_ledger: float gaps, a widened float literal spelled as a double
A vanilla method that adds `0.3F` to a double works with 0.30000001192092896
(javac folds the widened float into the bytecode); a port that writes 0.3
computes another number. The new `float` gap kind finds a method's widened
float constants in the jar and flags its port (or the fn whose inline comment
names it) when the port, or a fn it calls, spells the value only as a double:
not as `x_f32`, `f64::from(x_f32)`, an f32 const, or the widened value
written out.
2026-11-08 13:43:25 +01:00
AkirabaneandClaude Sonnet 5.5 32715a15da Recipe book: take the entries a server's packet carries
The client kept only the display id of each entry of a
ClientboundRecipeBookAddPacket and looked it up in the numbering of its
own recipes. A server that numbers its recipes differently (a plugin's
recipes, a datapack, another load order) had its recipes dropped, or
shown as another recipe, such as TNT missing on a Paper server.

Vanilla's ClientRecipeBook stores the RecipeDisplayEntry it is sent. The
book now takes each entry as it came: its display, result, category,
group and the ingredients the craftable filter checks. The book's own
entry stays when it is the same recipe (same result, category, layout
and ingredients), so a server of the client's own recipes shows what it
did before, and the entries taken join the groups of the ones kept. An
entry this book cannot show removes the recipe that had its number; an
entry of a category the client does not know changes nothing.

Fixes #11705

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 13:36:50 +01:00
martipopsandClaude Opus 5.5 8480ab53d5 Move Controller Settings onto the settings shell
Controller Settings now describes its pages and rows as a settings_shell
Screen, and the shell draws and drives it, as it does Extras and Graphics.
controller_ui loses its own drawing, scrolling, focus, search, click and
Undo code; Undo is the shell's, comparing the whole PadSettings as before
(Screen::differs).

The shell gains Value::Binding: a row that binds a key or a controller
button. The shell starts the wait (Output::Bind), marks the row while the
host waits, draws the button's glyph art, writes the strip and draws the
row's reset (Reset::{None, Default, Changed}). Extras' sort key and
Graphics' HDR shortcut use it in place of Value::Key and Output::BindKey.
Value::Read is a value the focus reaches and nothing presses.

What players get that is new: the search gives the keys back once the
pad moves the focus off it, no stale ring on the page list after RB or
LB, glyph art in binding rows, a "Not bound" note in the strip, Left and
Right stepping choices, rows scrolled into view when focused, and the
same look and focus as Extras and Graphics. Part of #11487.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:32:42 -05:00
Evan-lhm 3fa4cbd9a8 Merge remote-tracking branch 'origin/main' into fix/mod-content-below-packs 2026-11-08 13:21:06 +01:00
Evan-lhmandClaude Opus 5.5 f2719daa05 Mod content tests: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:21:06 +01:00
Delete228andCodex e9aa68c3ed Merge Horizon main into the state-dependent light PR
Keep entity-context geometry and state-dependent light in the same content compiler. Exercise both selectors in the light propagation fixture.

Co-authored-by: Codex <[email protected]>
2026-11-08 15:11:38 +03:00
Florian Ernst 8b4a95b099 Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 12:53:45 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a978363647 Merge origin/main into perf/eager-chunk-saves
dedicated.rs: main's `stop_now` loop condition, keeping the per-tick
`server_tick_deadline` the eager saves read for `haveTime`.
ModifiedChunks gains `clear()` (dirty set only, like `drain`) for main's
new mod_block_entity_tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 12:45:37 +01:00
Evan-lhmandClaude Opus 5.5 c853168fb7 Mod content: mark a reused root beside it, not in it (#12403)
With the packs now laid over the content root, rewriting the root's stamp
on every reuse changed a file the pack overlay depends on, so a second
launch with packs on rebuilt the overlay (53 s instead of 18 s). Mark the
reuse in `<root>.used` beside the root; pruning takes the newer of the two
times and removes the marker with its root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:39:35 +01:00
861ac2b28d Thorns answers a mob's attacks on a mob, and a player's reaches the dragon
A mob wearing Thorns now hurts the mob that attacks it, as
EnchantmentHelper.doPostAttackEffects runs after each vanilla attack
caller: melee, stings, cube contacts, golem hits, charges, spear stabs,
direct arrows, tridents, wind charges, fireballs, spit, shulker bullets,
wither skulls and owned evoker fangs. Ravager roars, guardian beams and
retaliation, hot-cube contact and other non-attacks get their own
DamageMob event that skips it. The damage rolls from the attacker's own
random, the chance from the level's.

The dragon's head and wing contacts run it for mobs (part by part, as
knockBack and hurt walk each list) and for players, whose Thorns now
hurts the dragon through its body part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:33:49 +01:00
martipopsandClaude Opus 5.5 4dd40471f8 Make the letter mode an opaque Glyphs value
Review follow-up. Glyph::of and Glyph::menu took any GlyphStyle, so a
caller could pass the raw setting and lose a Nintendo pad's letters,
and NintendoLetters leaked into the setting's enum.

pad_glyph::Glyphs { family, letters } is now opaque. Only
Glyphs::of(&PadSettings, family) makes it, plus Glyphs::unheld for
splitscreen's prompts. GlyphStyle is the setting again. A join prompt
now shows the joining pad's own family, not the viewer's.

The on-screen keyboard reads its input through MenuJob, the map its
hint glyphs use. The tests build letter mode from parsed settings and
pin A, B, X and Y on their own. The source scan is gone, since the
types enforce the rule now. An unknown action in GlyphOf::Bound trips
a debug assert, and pad status lists each guide row's label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:13:40 -05:00
Evan-lhmandClaude Opus 5.5 dcc73b1491 Packs: lay the resource packs over the mods' content root (#12403)
The content root was built over the pack overlay, so every pack change
(and a rebuilt overlay at launch) gave it a new stamp and a full rebuild:
84k files and 2-3 minutes on Windows with a 23-mod modpack. Build it over
vanilla instead and lay the packs over it: vanilla, then mods, then packs,
which is also vanilla's order (a pack that retextures a mod block wins).

Fixes #12403

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:49:07 +01:00
Florian Ernst 518adb688e Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 09:56:02 +01:00
martipopsandClaude Opus 5.5 a407637998 Merge upstream/main into ui/pad-glyphs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:05:41 -05:00
martipopsandClaude Opus 5.5 56b95a98b9 Ask for every controller glyph by what its button does
A Nintendo pad going by its letters reads its A as the part that
confirms and jumps, but only the binds list and the on-screen keyboard
showed that A. Each caller had to apply the letters itself, and the
guide, declared buttons and notices didn't.

Letter mode is now part of the resolved glyph style:
GlyphStyle::NintendoLetters, which only PadSettings::glyph_style
produces. It is never saved or offered. GlyphStyle::on_pad turns a part
into the button that plays it, the same way input is read.

Glyphs are asked for by what the button does. GlyphOf::Bound(action)
follows the player's binding and GlyphOf::Menu(MenuJob) the fixed menu
map. pad_glyph::Glyph is built only from those, and draw, width, rect,
art, label and color take only a Glyph, so no code outside pad_glyph
can turn a bare button into art or text. Splitscreen's join and
lost-controller prompts stay by position through Glyph::unheld, and a
test keeps that the only exception.

horizon-ctl `pad family FAMILY` and `pad connection connected FAMILY`
let the e2e harness pose as a Nintendo pad. docs/ui-kit.md starts the
UI kit guide with the glyph chain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:04:40 -05:00
Lyle ColeandClaude Opus 5.5 837f0410ea Far terrain: keep the generator's far samples with the world
Every session sampled each far tile again (LodSampler::grid): the noise,
biomes and surface rules for 16x16 columns. lod::cache keeps the chunks
generated for detail, but not these samples. lod::bake keeps them in the
dimension's horizon_lod/ folder (append-only bucket files, a record per
tile, zlib), so a reopened world reads them back instead.

- Saved chunks still win over baked samples (SavedLodSampler applies them
  on top as before), so edits show as they did.
- A fingerprint guards the folder: lod::cache's build key (version,
  commit or executable time, block states, biomes), the level's height
  range and the world's data packs (path, size and time of each file).
  When it changes the folder is emptied and baking starts again.
- Reduced far detail's approximate samples are kept apart from full ones.
- A record cut short (a crash mid-write) ends its file there; failed
  samples (all-empty columns) are never kept.

Measured with a far-grid bench on a world with its data packs (9 tiles
a level, one sampling thread, 16x16 columns a tile; the baked tiles read
back identical):

| world, detail     | sample (ms/tile) | write | read after reopening | disk/tile |
|-------------------|------------------|-------|----------------------|-----------|
| To The Max, full  | 7.6 / 42 / 48 / 52 | 0.06-0.11 ms | 0.05-0.07 ms | 4-11 KB |
| To The Max, reduced | 4.8 / 13 / 13 / 23 | 0.05-0.08 ms | 0.04-0.05 ms | 2-8 KB |
| vanilla, full (horizon-world lod_grid_bench) | 2.0 / 8.5 / 9.5 / 8.6 | | | |

(levels 0 / 2 / 4 / 6: 16 to 1024 blocks a tile). Writing adds under
1.5% to a tile's first sampling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:10 -06:00
Lyle ColeandClaude Opus 5.5 b5dff95be8 Noise fill: reuse the density cells the biome stage already sampled
A data pack whose climate reads the interpolated terrain density (To The
Max's ridges are a spline of it) samples that density's input on its 4x4x4
cell lattice in the biome stage. The noise fill then samples the same cells
again for its interpolation. Interpolated samplers now carry their
compiled function's identity. The biome stage keeps the lattice samples
they produce (a 1024-chunk cache beside the biome cache), and the noise
fill reads its corners from its own chunk's and its +X/+Z neighbours'
samples, sampling only the cells none of them holds (the top layer).

This applies only when every sampler under the interpolated input is
pointwise (a new DensitySampler::pointwise_inputs: each value depends on its
own position only, never on the volume's extent or world fields), so the
values are the ones sampling the whole volume gives. Vanilla's climate
doesn't read the interpolated density, so its worldgen is unchanged.

Measured on this branch's base, 35f3dad20, laptop (Ryzen 5 7640U, 4
threads, fast profile), runs interleaved:
- To The Max (seed 0, 16x16 chunks, world_chunkgen_bench): 150.5 -> 162.6
  finished chunks/s (+8%); noise 6.55 -> 4.30 ms a chunk. Terrain,
  decoration and structure-start checksums unchanged.
- Vanilla (chunkgen_bench, 32x32, seed 0): 5.05 -> 5.10 ms CPU a chunk
  (noise); terrain and decorated snapshots byte-identical.

Ported from ThatCakeIsAPie/horizon-ttm (bfec4acb2) onto upstream's
whole-cell interpolator (fill_whole_cells).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:08 -06:00
Florian ErnstandClaude Opus 5.5 a75f3169eb Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:35:39 +01:00
Delete228andCodex 753477f5b4 Merge Horizon main into the state-dependent light PR
Co-authored-by: Codex <[email protected]>
2026-11-07 20:00:52 +03:00
Horizon agentandClaude Opus 5.5 3dcb5cba92 Merge origin/main into #11570
command_exec_corpus.py: keeps this branch's isolated 'guest container
close event' group beside main's spectral duration and item Health groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:13:29 +01:00
Horizon agent 37db8a337e Merge origin/main into PR #11570
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
2026-11-07 14:17:46 +01:00
Horizon agentandClaude Opus 5.5 5547077993 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:00 +01:00
Horizon agentandClaude Opus 5.5 79fe52def4 Merge origin/main into fix/h112-guest-container-close-event
command_exec corpus: keep main's craft-map group in ISOLATED_GROUPS and its import beside guest_close_event. Oracle: keep main's merchant_saved_stats op; close_menu goes through handleContainerClose. The test's Joined literal gains main's new configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:11:48 +01:00
Riccardo GuerrieroandClaude Opus 5.5 bb9e6d6ebf Save a column again when an edit's light changes it
A block edit relit in the frame changes light up to 15 blocks into the
columns around it, but only the edited column was marked unsaved. With
eager saves a neighbour saved a moment earlier kept its old light on disk.
Vanilla's ChunkHolder.sectionLightChanged marks every chunk whose light
section changed unsaved; mark_light_changes now does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:48:55 +01:00
TheMrZZandCodex gpt-6.1-sol 2aa319becc Emit the final ordinary guest container close game event
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 03:13:16 +01:00
Delete228andCodex 5c591b13a4 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
137486c83a Save changed chunks while playing to reduce work when leaving a world
Follow vanilla's eager chunk queue, cooldown and per-tick/time/write limits.
Keep save state with its level and respect disabled saves and server pauses.
Save only lit (full) chunks eagerly and only once a world is open; an unlit
one keeps its place in the queue.
Move proto NBT encoding to the region writer without changing its encoder.
Cover selection regressions and repeated edits reaching real region files.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:46:12 +01:00
Delete228andCodex d2cb5a272b Compile state-dependent light emission from content declarations
Resolve typed state_light selectors once into ordinary gameplay emission rows while preserving scalar light and template defaults. Reject invalid and uncovered selector values. Verify native facts readback, cache invalidation, and real cross-chunk lighting transitions.

Validation: 37 declaration tests and 48 content compiler tests pass; strict client/mods clippy and workspace formatting pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 14:50:59 +03:00
428 changed files with 26428 additions and 8576 deletions

No files matched your search

+2 -1
View File
@@ -25,7 +25,8 @@
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
## Self-check
+1
View File
@@ -14,3 +14,4 @@
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
+5
View File
@@ -291,6 +291,11 @@ jobs:
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
if [[ "${{ matrix.name }}" == windows ]]; then
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
fi
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+1 -1
View File
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
+3 -1
View File
@@ -76,7 +76,9 @@ another project's source may also be read and used, **only when that project's l
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.
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## Before asking for review
+26
View File
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## DirectX Shader Compiler
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
- Only x64: the Windows SDK's list of distributable files
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
wgpu never does.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
+53
View File
@@ -14,6 +14,8 @@ pub enum PistonPart {
Entity,
State,
Properties,
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
BlockEntity,
}
struct Pretty {
@@ -255,6 +257,20 @@ fn piston_hash_order(
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
PistonPart::BlockEntity => {
// saveAdditional's fields, then `components`, then saveMetadata's
// id and position; the fields keep the default order among them.
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
let mut entries = java_hash_order(c);
entries.sort_by_key(|(k, _)| {
let key = string_of(k);
(
hash_bucket(c.len(), k),
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
)
});
return entries;
}
}
entries
}
@@ -313,6 +329,14 @@ pub fn java_hash_order(
"NoBasePlate",
"Marker",
"Pose",
// Mannequin.addAdditionalSaveData.
"profile",
"hidden_layers",
"main_hand",
"pose",
"immovable",
"description",
"hide_description",
];
// BlockEntity.saveMetadata writes the position after the entity's data.
const AFTER: &[&str] = &["x", "y", "z"];
@@ -397,6 +421,21 @@ mod tests {
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
let tag = parse_snbt(
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
)
.unwrap()
.0;
let rendered = pretty(&tag).plain();
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
let immovable_idx = rendered.find("immovable: 1b").unwrap();
assert!(
pose_idx < immovable_idx,
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
@@ -441,4 +480,18 @@ mod tests {
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
}
#[test]
fn block_entity_metadata_follows_its_fields() {
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
// and `components` share a bucket, and saveAdditional wrote `Target` first.
let tag = parse_snbt(
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
)
.unwrap()
.0;
assert_eq!(
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
);
}
}
+100 -54
View File
@@ -82,49 +82,6 @@ impl Button {
fn bit(self) -> u32 {
1 << self as u32
}
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
pub fn label(self, style: GlyphStyle) -> &'static str {
let i = self as usize;
match style {
GlyphStyle::PlayStation => [
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
"↓", "←", "→",
][i],
GlyphStyle::Nintendo => [
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
][i],
GlyphStyle::Xbox | GlyphStyle::Auto => [
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
"←", "→",
][i],
}
}
/// The glyph's colour (RGB): face buttons in their family's colours,
/// the rest white.
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
let face = match self {
Button::South => 0,
Button::East => 1,
Button::West => 2,
Button::North => 3,
_ => return [255, 255, 255],
};
match style {
GlyphStyle::PlayStation => [
[124, 178, 232],
[235, 110, 110],
[222, 145, 205],
[92, 205, 170],
][face],
GlyphStyle::Nintendo => [255, 255, 255],
GlyphStyle::Xbox | GlyphStyle::Auto => [
[110, 200, 90],
[235, 90, 80],
[80, 150, 240],
[240, 200, 60],
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
@@ -427,6 +384,101 @@ impl HeldDrop {
}
}
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuJob {
/// A: press, take, type.
Confirm,
/// B: back, close.
Back,
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
Alternate,
/// Y: the screen's third job (the exit, a quick move, a space).
Extra,
/// LB: the page or tab before.
PagePrev,
/// RB: the page or tab after.
PageNext,
/// LT: the group before; held, the modifier (shift, direct craft).
GroupPrev,
/// RT: the group after.
GroupNext,
/// View: throw.
View,
/// Menu (Start): done, and cancelling a binding.
Start,
/// The left stick pressed in.
LeftStick,
}
impl MenuJob {
pub const ALL: [MenuJob; 11] = [
MenuJob::Confirm,
MenuJob::Back,
MenuJob::Alternate,
MenuJob::Extra,
MenuJob::PagePrev,
MenuJob::PageNext,
MenuJob::GroupPrev,
MenuJob::GroupNext,
MenuJob::View,
MenuJob::Start,
MenuJob::LeftStick,
];
/// The part that does the job (by place; a pad going by its letters is read so the part
/// still lands: `PadState::by_letter`).
pub const fn button(self) -> Button {
match self {
MenuJob::Confirm => Button::South,
MenuJob::Back => Button::East,
MenuJob::Alternate => Button::West,
MenuJob::Extra => Button::North,
MenuJob::PagePrev => Button::LeftBumper,
MenuJob::PageNext => Button::RightBumper,
MenuJob::GroupPrev => Button::LeftTrigger,
MenuJob::GroupNext => Button::RightTrigger,
MenuJob::View => Button::Select,
MenuJob::Start => Button::Start,
MenuJob::LeftStick => Button::LeftStick,
}
}
/// The job a part does in menus.
pub fn of(button: Button) -> Option<MenuJob> {
Self::ALL.into_iter().find(|job| job.button() == button)
}
}
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
/// from one, so a rebinding or a pad's letters reach every glyph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlyphOf {
/// A gameplay action (`key.jump`...), on the player's binding.
Bound(&'static str),
/// A menu's fixed job.
Menu(MenuJob),
}
impl From<MenuJob> for GlyphOf {
fn from(job: MenuJob) -> Self {
GlyphOf::Menu(job)
}
}
impl GlyphOf {
/// The part that does it under these settings; none while the action is unbound.
pub fn button(self, settings: &PadSettings) -> Option<Button> {
match self {
GlyphOf::Bound(action) => {
// A misspelt action would quietly have no glyph.
debug_assert!(
ACTIONS.iter().any(|a| a.0 == action),
"no pad action {action}"
);
settings.binding(action)
}
GlyphOf::Menu(job) => Some(job.button()),
}
}
}
/// Which button pictures prompts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GlyphStyle {
@@ -635,6 +687,10 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl PadSettings {
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
pub fn by_letter(&self, family: GlyphStyle) -> bool {
self.nintendo_letters && family == GlyphStyle::Nintendo
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -1921,11 +1977,6 @@ bind_key.hotbar.2:north
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
@@ -2353,12 +2404,7 @@ bind_key.hotbar.2:north
}
#[test]
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
fn styles_resolve_and_conflicts() {
assert_eq!(
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
GlyphStyle::PlayStation
@@ -2,7 +2,7 @@
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
//! places the rows around what was drawn.
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
/// What a use does, by what takes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -100,26 +100,27 @@ pub struct Facts {
pub off_hand_item: bool,
}
/// One button's line: the button and the translation key of what it does.
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
/// and the translation key of the caption.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
pub with: Option<Button>,
pub button: Button,
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
pub with: Option<GlyphOf>,
pub of: GlyphOf,
pub caption: &'static str,
}
impl Row {
fn new(button: Button, caption: &'static str) -> Self {
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
Row {
with: None,
button,
of: of.into(),
caption,
}
}
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
Row {
with: Some(with),
button,
with: Some(GlyphOf::Menu(with)),
of: GlyphOf::Menu(job),
caption,
}
}
@@ -155,11 +156,11 @@ impl Guide {
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
/// on Start, which is always pause.
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
let row = |action: &str, caption: Option<&'static str>| {
let button = settings
let row = |action: &'static str, caption: Option<&'static str>| {
settings
.binding(action)
.filter(|button| *button != Button::Start)?;
Some(Row::new(button, caption?))
Some(Row::new(GlyphOf::Bound(action), caption?))
};
let player = !facts.spectator;
let any_item = facts.main_hand_item || facts.off_hand_item;
@@ -308,10 +309,10 @@ pub struct MenuFacts {
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
pub fn resolve_menu(f: &MenuFacts) -> Guide {
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
use Button::{Select, South, West};
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
use MenuJob::{LeftStick, PageNext, PagePrev, View};
const TAKE: &str = "horizon.controller.guide.take";
const MOVE: &str = "horizon.controller.guide.quick_move";
let under = match f.under {
@@ -321,25 +322,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
result: true,
same,
..
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
Under::Slot { result: true, .. } => Vec::new(),
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
Under::Slot { item, same, .. } if f.carrying => vec![
Row::new(
South,
Confirm,
if item && !same {
"horizon.controller.guide.swap_stack"
} else {
"horizon.controller.guide.put"
},
),
Row::new(West, "horizon.controller.guide.put_one"),
Row::new(Alternate, "horizon.controller.guide.put_one"),
],
Under::Slot { item: true, .. } => vec![
Row::new(South, TAKE),
Row::new(West, "horizon.controller.guide.take_half"),
Row::new(North, MOVE),
Row::new(Select, "horizon.controller.guide.drop"),
Row::new(Confirm, TAKE),
Row::new(Alternate, "horizon.controller.guide.take_half"),
Row::new(Extra, MOVE),
Row::new(View, "horizon.controller.guide.drop"),
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
],
Under::Nothing | Under::Slot { .. } => Vec::new(),
@@ -347,43 +348,44 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
direct_craft,
alternatives,
} => [
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
direct_craft.then_some(Row::pair(
LeftTrigger,
South,
GroupPrev,
Confirm,
"horizon.controller.direct_craft",
)),
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
]
.into_iter()
.flatten()
.collect(),
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
};
let screen = [
f.book.then_some(Row::pair(
LeftBumper,
RightBumper,
PagePrev,
PageNext,
"horizon.controller.guide.pages",
)),
f.book.then_some(Row::pair(
LeftTrigger,
RightTrigger,
GroupPrev,
GroupNext,
"horizon.controller.guide.categories",
)),
(f.tabs && !f.book).then_some(Row::pair(
LeftBumper,
RightBumper,
PagePrev,
PageNext,
"horizon.controller.guide.tabs",
)),
];
let close = Row::new(East, "horizon.controller.guide.close");
let close = Row::new(Back, "horizon.controller.guide.close");
let rows = std::iter::once(close)
.chain(under)
.chain(screen.into_iter().flatten());
let (right, left) =
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
let (right, left) = rows.partition(|r| {
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
});
Guide { left, right }
}
@@ -495,8 +497,11 @@ mod tests {
..f
}
}
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
rows.iter().map(|r| (r.button, r.caption)).collect()
/// Each row's button on these settings, and its caption.
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
rows.iter()
.map(|r| (r.of.button(settings).unwrap(), r.caption))
.collect()
}
fn captions(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.caption).collect()
@@ -509,9 +514,10 @@ mod tests {
item_use: Some(Verb::Place),
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &PadSettings::default());
let settings = PadSettings::default();
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
rows(&guide.right, &settings),
[
(Button::RightTrigger, "horizon.controller.guide.break"),
(Button::LeftTrigger, "horizon.controller.guide.place"),
@@ -519,7 +525,7 @@ mod tests {
]
);
assert_eq!(
rows(&guide.left),
rows(&guide.left, &settings),
[
(Button::South, "key.jump"),
(Button::RightStick, "key.sneak"),
@@ -543,7 +549,7 @@ mod tests {
};
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
rows(&guide.right, &settings),
[(Button::North, "horizon.controller.guide.break")]
);
// Start only ever pauses.
@@ -845,8 +851,8 @@ mod tests {
["close", "place_recipe", "place_recipe_all", "alternatives"]
);
assert_eq!(recipe.left.len(), 1);
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
assert_eq!(recipe.left[0].button, Button::South);
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
let plain = on(
Under::Recipe {
direct_craft: false,
@@ -892,8 +898,8 @@ mod detail_tests {
fn rows() -> Guide {
Guide {
left: vec![Row::new(Button::South, "left")],
right: vec![Row::new(Button::East, "right")],
left: vec![Row::new(MenuJob::Confirm, "left")],
right: vec![Row::new(MenuJob::Back, "right")],
}
}
@@ -927,7 +933,7 @@ mod detail_tests {
mod slot_tests {
use super::*;
/// The rows for the slot: South or West, whichever column they're in.
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
let guide = resolve_menu(&MenuFacts {
under: Under::Slot {
@@ -942,7 +948,7 @@ mod slot_tests {
[guide.left, guide.right]
.iter()
.flatten()
.filter(|row| matches!(row.button, Button::South | Button::West))
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
.count()
}
+2 -2
View File
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
@@ -0,0 +1,480 @@
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
use client::{
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
graphics::GraphicsApi,
hud::HudBatch,
mesher::{SectionMesh, TerrainVertex, Vertex},
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
/// What the mod below gives the renderer each frame.
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
struct Mod;
impl native_render::RenderHooks for Mod {
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
if let Some(warp) = WARP.lock().unwrap().as_ref() {
*event.warp = warp.clone();
}
}
}
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let here = pos.x + warp.camera.x + warp.camera_rest.x;
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
return Warped(pos, vec3(1.0));
}
";
/// Cuts every face.
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(-1.0));
}
";
/// Paints the terrain green by its pixels and moves nothing.
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(1.0));
}
// [fragments]
fn world_fragment(f: Fragment) -> Painted {
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
}
";
/// Moves entities ten blocks right: off the screen's middle.
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
}
";
fn read(path: &Path) -> Vec<[u8; 4]> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes.as_chunks::<4>().0.to_vec()
}
/// Pixels like `want` (within 40 a channel), of an image.
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
pixels
.iter()
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
.count()
}
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
fn wall() -> SectionMesh {
let mut mesh = SectionMesh::default();
let corners = [
[0., 0., 12.],
[0., 16., 12.],
[16., 16., 12.],
[16., 0., 12.],
];
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
for i in order {
mesh.solid
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
}
}
mesh.sort_solid();
mesh
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
renderer.fullbright = true;
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
renderer.upload((0, 10, 0), &wall());
// an entity quad in front of the wall, middle of the screen
let entity: Vec<Vertex> = [
[7.5, 103.5, 6.0],
[7.5, 104.5, 6.0],
[8.5, 104.5, 6.0],
[8.5, 103.5, 6.0],
]
.into_iter()
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
.map(|(p, uv)| Vertex {
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
uv,
layer: 0,
color: 0xff40_40ff,
light: 0xf0,
normal: [0, 0, 0, 0],
glint_uv: [0.0; 2],
})
.collect();
if std::env::args().any(|a| a == "--bench") {
return bench(&mut renderer, out);
}
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.entities = &entity;
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
let path = out.join(format!("{name}.png"));
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
read(&path)
};
let warp = |source: &str, on: bool, lift: f32| {
let mut w = native_render::WorldWarp::default();
w.source = Some(source.into());
w.on = on;
w.params[0] = lift;
Some(w)
};
let plain = shot("plain", None);
let wall_px = count(&plain, [255, 255, 255]);
let entity_px = count(&plain, [255, 64, 64]);
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
assert!(
entity_px > 50,
"the entity shows without a warp: {entity_px}"
);
let off = shot("off", warp(LIFT, false, 4.0));
assert!(off == plain, "a warp that is off draws as no warp");
let lifted = shot("lift", warp(LIFT, true, 4.0));
let red = count(&lifted, [255, 0, 0]);
assert!(red > 500, "the warp tints the wall's left half red: {red}");
assert!(lifted != plain);
let cut = shot("cut", warp(CUT, true, 0.0));
let left = count(&cut, [255, 255, 255]);
assert!(
left < wall_px / 20,
"a cut leaves the wall out: {left} of {wall_px}"
);
let painted = shot("paint", warp(PAINT, true, 0.0));
let green = count(&painted, [0, 255, 0]);
assert!(
green > wall_px / 2,
"the warp's own pixels paint the wall: {green}"
);
let shifted = shot("shift", warp(SHIFT, true, 0.0));
let entity_left = count(&shifted[..], [255, 64, 64]);
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
(60..120)
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
.collect()
};
assert!(
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
);
let broken = shot(
"broken",
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
);
assert!(broken == plain, "a warp that doesn't compile is left out");
let back = shot("after", None);
assert!(back == plain, "with the warp gone the frame is as before");
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
"cut_wall_pixels_left": left, "painted_green_pixels": green,
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
}))?,
)?;
println!(
"world warp check passed: {}",
out.join("result.json").display()
);
Ok(())
}
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
fn hills(cx: i32, cz: i32) -> SectionMesh {
let height = |x: i32, z: i32| {
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
};
let mut mesh = SectionMesh::default();
let mut quad = |c: [[f32; 3]; 4]| {
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
mesh.solid
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
}
};
for x in 0..16 {
for z in 0..16 {
let h = height(x, z);
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
quad([
[fx, fh, fz],
[fx, fh, fz + 1.],
[fx + 1., fh, fz + 1.],
[fx + 1., fh, fz],
]);
// the step down to a lower neighbour, toward +x and +z
for (dx, dz) in [(1, 0), (0, 1)] {
let n = height(x + dx, z + dz);
if n < h {
let (lo, hi) = (n as f32, fh);
if dx == 1 {
quad([
[fx + 1., lo, fz],
[fx + 1., hi, fz],
[fx + 1., hi, fz + 1.],
[fx + 1., lo, fz + 1.],
]);
} else {
quad([
[fx, lo, fz + 1.],
[fx + 1., lo, fz + 1.],
[fx + 1., hi, fz + 1.],
[fx, hi, fz + 1.],
]);
}
}
}
}
}
mesh.sort_solid();
mesh
}
/// `--bench`: milliseconds a frame without a warp and with one.
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
const R: i32 = 24;
renderer.resize(1920, 1080);
let mut sections = 0;
for cx in -R..=R {
for cz in -R..=R {
// (keys count sections from y -64: 8 is y 64 to 80)
renderer.upload((cx, 8, cz), &hills(cx, cz));
sections += 1;
}
}
let hud = HudBatch::new([1920, 1080]);
let mut frame = fixture_frame(&hud);
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
frame.eye = frame.camera;
frame.pitch = 15.0;
frame.eye_rotation = [0.0, 15.0];
frame.distance = 16.0 * R as f32;
frame.detail_distance = frame.distance;
frame.depth_far = 2000.0;
frame.cave_culling = true;
let mut time = |warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
for _ in 0..30 {
renderer.render(&frame).unwrap();
}
let started = std::time::Instant::now();
const FRAMES: u32 = 240;
for _ in 0..FRAMES {
renderer.render(&frame).unwrap();
}
// the last frame's picture, so the GPU has caught up
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("bench.png"),
size: None,
scaled: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
};
let mut wave = native_render::WorldWarp::default();
wave.source = Some(
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let x = pos.x + warp.camera.x + warp.camera_rest.x;
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
}\n"
.into(),
);
wave.on = true;
let plain = time(None);
let warped = time(Some(wave));
let again = time(None);
let result = serde_json::json!({
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
"frame_ms_without_warp_after": again,
"graphics_adapter": client::crash::system_details("bench").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
});
println!("{result}");
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
Ok(())
}
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) = fixture(window) {
eprintln!("world warp check failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(8.0, 104.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(8.0, 104.0, 0.5),
eye_rotation: [0.0, 0.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 128.0,
detail_distance: 128.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: false,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+28
View File
@@ -660,6 +660,34 @@ mod pack_tests {
}
}
#[cfg(test)]
impl Biomes {
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<_> = entries
.iter()
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
.collect();
Self {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
}
#[cfg(test)]
mod color_tests {
use super::*;
@@ -85,6 +85,11 @@ pub enum BlockEntity {
DaylightDetector,
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
Bell,
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
/// shape and animation are kept separately (`block_entity_anim`).
Conduit(Option<[i32; 4]>),
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
EnchantingTable,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
@@ -444,6 +444,12 @@ impl BlockEntity {
if name == "minecraft:bell" {
return Some(Self::Bell);
}
if name == "minecraft:conduit" {
return Some(Self::Conduit(None));
}
if name == "minecraft:enchanting_table" {
return Some(Self::EnchantingTable);
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -540,6 +546,8 @@ impl BlockEntity {
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::Conduit(_) => name == "minecraft:conduit",
Self::EnchantingTable => name == "minecraft:enchanting_table",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
@@ -579,6 +587,8 @@ impl BlockEntity {
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Conduit(_) => "minecraft:conduit",
Self::EnchantingTable => "minecraft:enchanting_table",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
@@ -614,6 +624,8 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EnchantingTable
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -645,6 +657,8 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EnchantingTable
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
@@ -681,6 +695,8 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EnchantingTable
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
// [world warp entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let at = position;
// [end world warp entity]
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
return out;
+255 -16
View File
@@ -163,7 +163,8 @@ pub struct Frame {
/// The sky and block light at the camera.
pub sky_light: i32,
pub block_light: i32,
/// The camera's biome's climate (`o.temperature`, `o.downfall`).
/// The camera's biome (`o.biome()`), and its climate (`o.temperature`, `o.downfall`).
pub biome: String,
pub temperature: f32,
pub downfall: f32,
}
@@ -216,6 +217,9 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
Ok(V::Str(f.biome.as_str().into()))
}
"rand" if on(Root::Random) => {
// A splitmix64 step: the frame's own sequence.
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
@@ -265,9 +269,9 @@ fn as_number(value: &Value) -> f64 {
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
struct Colormap {
x: horizon_polytone::expr::Program,
y: horizon_polytone::expr::Program,
pub(crate) struct Colormap {
x: Axis,
y: Axis,
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
/// grass colormap scales downfall by temperature.
triangular: bool,
@@ -278,11 +282,72 @@ struct Colormap {
rgba: Vec<u8>,
}
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
/// `biome_id` sampler with its mapper.
enum Axis {
Expr(horizon_polytone::expr::Program),
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
}
impl Axis {
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
Self::BiomeId(mapper, biome) => {
let biome = match biome.eval(host).ok()? {
horizon_polytone::expr::Value::Str(s) => s,
_ => return Some(0.0),
};
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
}
}
}
}
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
/// are divided by its `texture_size`, when it has one (pixel coordinates).
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
let object = match json {
Json::String(id) => {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = pack_root.join(format!(
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
));
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
}
other => other.clone(),
};
let Some(object) = object.as_object() else {
return HashMap::new();
};
let size = object
.get("texture_size")
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
.filter(|s| *s > 0.0)
.unwrap_or(1.0);
object
.iter()
.filter(|(k, _)| *k != "texture_size")
.filter_map(|(k, v)| {
let id = if k.contains(':') {
k.clone()
} else {
format!("minecraft:{k}")
};
Some((id, v.as_f64()? / size))
})
.collect()
}
impl Colormap {
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall). The `biome_id` axis isn't
/// supported yet.
fn load(pack_root: &Path, id: &str) -> Option<Self> {
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
let (namespace, path) = id.split_once(':')?;
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
let png = base.with_extension("png");
@@ -295,13 +360,26 @@ impl Colormap {
};
let json: Json =
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
let axis = |key: &str, default: &str| -> Option<horizon_polytone::expr::Program> {
let compile = horizon_polytone::expr::Program::compile;
let axis = |key: &str, default: &str| -> Option<Axis> {
let src = match &json[key] {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
_ => default.to_owned(),
};
match horizon_polytone::expr::Program::compile(&src) {
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
let src = match src.trim() {
"biome_id" => {
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
}
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
"downfall" => "o.downfall".to_owned(),
"y_level" => "o.y / 128".to_owned(),
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
_ => src,
};
match compile(&src).map(Axis::Expr) {
Ok(program) => Some(program),
Err(e) => {
log::warn!("colormap {id} {key}: {e}");
@@ -322,24 +400,145 @@ impl Colormap {
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
/// it was, the default `fallback_colormap: original`) or an axis that fails.
fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.eval(host).ok()?.num().clamp(0.0, 1.0);
let mut y = self.y.eval(host).ok()?.num().clamp(0.0, 1.0);
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.value(host)?.clamp(0.0, 1.0);
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
if self.triangular {
y *= x;
}
let pixel = |v: f64, size: u32| {
let p = (1.0 - v) * f64::from(size.saturating_sub(1));
let pixel = |axis: &Axis, v: f64, size: u32| {
let last = size.saturating_sub(1);
if let Axis::BiomeId(..) = axis {
return ((v * f64::from(size)).floor() as u32).min(last);
}
let p = (1.0 - v) * f64::from(last);
let p = if self.rounds { p.round() } else { p.floor() };
(p as u32).min(size.saturating_sub(1))
(p as u32).min(last)
};
let (px, py) = (pixel(x, self.width), pixel(y, self.height));
let (px, py) = (
pixel(&self.x, x, self.width),
pixel(&self.y, y, self.height),
);
let i = (py as usize * self.width as usize + px as usize) * 4;
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
}
}
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
pub(crate) fn polytone_colormap_modifiers(
pack_root: &Path,
) -> Vec<(bool, horizon_polytone::Targets, String)> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
continue;
};
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
continue;
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let relative = path
.strip_prefix(pack_root)
.unwrap_or(&path)
.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
{
Ok(targets) => out.push((fluid, targets, id)),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
}
out
}
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
/// and the block keeps its own tint.
pub(crate) struct BlockHost<'a> {
at: [i32; 3],
biome: &'a crate::biome::BiomeInfo,
}
impl<'a> BlockHost<'a> {
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
Self { at, biome }
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
Root::Object => 1,
Root::Random => 2,
_ => 0,
})
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::Value as V;
let num = |n: f64| Ok(V::Num(n));
match (target, name) {
(V::Object(1), "x" | "y" | "z") => {
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
}
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
}
fn function(
&mut self,
name: &str,
_: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
Err(format!("{name} is not available to block colormaps"))
}
}
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
struct Resolver {
frame: Arc<RwLock<Frame>>,
@@ -1029,4 +1228,44 @@ mod tests {
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
}
#[test]
fn biome_id_colormaps_read_the_column_their_mapper_gives_the_biome() {
// Left to right: red, green, transparent, blue.
let png = png_row(&[
[255, 0, 0, 255],
[0, 255, 0, 255],
[9, 9, 9, 0],
[0, 0, 255, 255],
]);
let env = with_pack_files(&[
(
"assets/minecraft/polytone/dimension_modifiers/overworld.json",
br#"{"attributes_modifiers": {"visual/sky_color": "mypack:sky"}}"#,
),
(
"assets/mypack/polytone/colormaps/sky.json",
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": "mypack:biomes"}"#,
),
("assets/mypack/polytone/colormaps/sky.png", &png),
(
// Pixel columns from the left, over a texture_size of the image's width.
"assets/mypack/polytone/biome_id_mappers/biomes.json",
br#"{"minecraft:desert": 3, "swamp": 1, "minecraft:plains": 2, "texture_size": 4}"#,
),
]);
let sky = |biome: &str| {
env.set_frame(Frame {
biome: biome.to_owned(),
..Frame::default()
});
env.sample(&Sample::default()).sky_color
};
assert_eq!(sky("minecraft:desert"), [0.0, 0.0, 1.0]);
assert_eq!(sky("minecraft:swamp"), [0.0, 1.0, 0.0]);
// A biome the mapper doesn't name is 0: the first column.
assert_eq!(sky("minecraft:forest"), [1.0, 0.0, 0.0]);
// Plains land on the transparent pixel: the colour before stays.
assert_eq!(sky("minecraft:plains"), [128.0 / 255.0; 3]);
}
}
+16 -13
View File
@@ -5,7 +5,7 @@ use crate::{
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
let e = &s.extras;
let value = match (item.key_name(), item.switch(e)) {
(Some(key), _) => Value::Key(key, item.value(e)),
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
(None, Some(on)) => Value::Switch(on),
(None, None) => Value::Choice(item.value(e)),
};
@@ -116,13 +116,10 @@ impl Screen for ExtrasScreen {
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
ExtraRow::Item(item) => match item.key_name() {
Some(key) => vec![Output::BindKey(key)],
None => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
},
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
ExtraRow::Zoom(i) => {
s.mods.toggle(i);
vec![Output::Event(Event::Changed)]
@@ -208,15 +205,21 @@ mod tests {
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
use crate::settings_shell::Capture;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
s.extras.sort = true;
let mut shell = Shell::<ExtrasScreen>::default();
let row = ExtraRow::Item(Item::SortKey);
let key = s.extras.sort_key.clone();
let at = shell.point_at(&s, SIZE, row);
assert_eq!(
screen.press(&mut s, row, false),
vec![Output::BindKey(extras::SORT_KEY)]
shell.click(&mut s, at, SIZE, false, false, false),
vec![
Output::Event(Event::ButtonClick),
Output::Bind(Capture::Key(extras::SORT_KEY))
]
);
screen.wheel_step(&mut s, row, true);
ExtrasScreen.wheel_step(&mut s, row, true);
assert_eq!(s.extras.sort_key, key);
}
+301 -78
View File
@@ -185,6 +185,7 @@ pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
mod flat;
@@ -241,6 +242,7 @@ mod visbuffer;
mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -1206,6 +1208,8 @@ pub struct Renderer {
solid_cutout: SolidPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// A native mod's world warp (`world_warp`): nothing made until a mod gives one.
world_warps: world_warp::WorldWarps,
/// Flat section draws (`flat`), where multi-draw is emulated.
flat: Option<flat::Flat>,
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
@@ -1558,11 +1562,23 @@ pub struct Renderer {
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// Shader-pack terrain pages used by post processing, retained between frames.
pack_pages: Vec<(u32, wgpu::Buffer)>,
pack_pages: Vec<pack_post::TerrainPage>,
/// Camera-relative selection outline vertices, retained between frames.
outline_edges_scratch: Vec<[f32; 6]>,
/// Selection outline line vertices, retained between frames.
outline_lines_scratch: Vec<[f32; 8]>,
/// Debug line vertices, retained between frames.
debug_lines_scratch: Vec<[f32; 8]>,
/// Screen effect vertices, retained between frames.
screen_vertices_scratch: Vec<crate::screen_effects::ScreenVertex>,
/// LOD leaving chunk columns, retained between frames.
lod_leaving_scratch: crate::fast_hash::FxHashSet<(i32, i32)>,
/// Shadow visible boxed sections, retained between frames.
shadow_boxed_scratch: Vec<resident::Visible>,
/// Shadow visible seen section keys, retained between frames.
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
/// Shadow translucent section draws, retained between frames.
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
@@ -1956,11 +1972,14 @@ fn mip_chain(
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
/// try at a backend that had no adapter at all. On DirectX 12, `dx12_compiler`
/// compiles the shaders (`gfx/dx12_compiler.rs`), unless `WGPU_DX12_COMPILER`
/// names another.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
dx12_compiler: wgpu::Dx12Compiler,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -1980,6 +1999,7 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.backend_options.dx12.shader_compiler = dx12_compiler;
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
@@ -2378,30 +2398,19 @@ async fn open_backend(
allow_cpu: bool,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
let open = |dx12_compiler| {
wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
dx12_compiler,
))
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
let instance = open(wgpu::Dx12Compiler::Fxc);
let (surface, adapter) = match surface_and_adapter(&instance, window, offscreen).await {
Ok(found) => found,
Err(NoDevice::Failed(e)) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
Err(NoDevice::NoAdapter(e)) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
// NoAdapter, so `open_with_retry` asks again: with the non-conformant
// adapters shown, wgpu ranks a real GPU (hasvk on Ivy Bridge or Haswell)
@@ -2412,6 +2421,27 @@ async fn open_backend(
adapter.get_info().name
)));
}
// An integrated GPU on DirectX 12 compiles with DXC: the same request
// again, on an instance that loads the DLL beside the game. Anything that
// goes wrong keeps the FXC instance.
let forced = dx12_compiler::forced();
let dxc = if dx12_compiler::wants_dxc(&adapter.get_info(), forced) {
open_dxc(&adapter.get_info(), window, offscreen, open).await
} else {
None
};
let dxc_found = dxc.is_some();
let (instance, surface, adapter) = dxc.unwrap_or((instance, surface, adapter));
if adapter.get_info().backend == wgpu::Backend::Dx12 {
crate::crash::set_detail(
"DirectX 12 shader compiler",
match (forced, dxc_found) {
(true, _) => "WGPU_DX12_COMPILER",
(false, true) => "DXC",
(false, false) => "FXC",
},
);
}
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
@@ -2499,6 +2529,73 @@ async fn open_backend(
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// A surface for `window` (none `offscreen`) and the adapter `open_backend`
/// asks `instance` for. `Failed` is the surface's error, `NoAdapter` the
/// request's.
async fn surface_and_adapter(
instance: &wgpu::Instance,
window: &Arc<Window>,
offscreen: bool,
) -> std::result::Result<(Option<wgpu::Surface<'static>>, wgpu::Adapter), NoDevice> {
let surface = if offscreen {
None
} else {
Some(
instance
.create_surface(window.clone())
.map_err(|e| NoDevice::Failed(e.to_string()))?,
)
};
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
.map_err(|e| NoDevice::NoAdapter(e.to_string()))?;
Ok((surface, adapter))
}
/// The DirectX 12 instance, surface and adapter with DXC for the integrated
/// GPU `fxc` (`gfx/dx12_compiler.rs`), `open` making the instance; `None`, with
/// one line in the log, when the DLL is missing, doesn't load, or the instance
/// offers another GPU: the FXC instance stays.
async fn open_dxc(
fxc: &wgpu::AdapterInfo,
window: &Arc<Window>,
offscreen: bool,
open: impl FnOnce(wgpu::Dx12Compiler) -> wgpu::Instance,
) -> Option<(
wgpu::Instance,
Option<wgpu::Surface<'static>>,
wgpu::Adapter,
)> {
let Some(dll) = dx12_compiler::bundled_dll() else {
log::info!(
"Horizon renderer: no {} beside the game; DirectX 12 compiles shaders with FXC",
dx12_compiler::DLL
);
return None;
};
let instance = open(dx12_compiler::dxc(&dll));
let why = match surface_and_adapter(&instance, window, offscreen).await {
Ok((surface, adapter)) if dx12_compiler::same_adapter(fxc, &adapter.get_info()) => {
log::info!(
"Horizon renderer: DirectX 12 compiles shaders with DXC ({}) on this integrated GPU",
dll.display()
);
return Some((instance, surface, adapter));
}
Ok((_, adapter)) => format!("it offered another GPU, {}", adapter.get_info().name),
Err(NoDevice::Failed(e) | NoDevice::NoAdapter(e)) => e,
};
log::warn!(
"Horizon renderer: DXC from {} didn't load ({why}); DirectX 12 compiles shaders with FXC",
dll.display()
);
None
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -4756,6 +4853,7 @@ impl Renderer {
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
// so Metal draws vanilla terrain flat at any count.
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| flat::is_adreno_dx12(&info)
|| info.backend == wgpu::Backend::Metal
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
@@ -4820,6 +4918,16 @@ impl Renderer {
// (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
let world_warps = world_warp::WorldWarps::new(world_warp::Kit {
terrain: bindings.clone(),
pages: flat.as_ref().map(|f| f.draw_layout.clone()),
entity: entity_bindings.clone(),
static_entity: static_entity_bindings.clone(),
format: frame_format,
sprite_constants,
hdr_output: hdr_output.is_some(),
});
let visbuf = flat
.as_ref()
.filter(|_| visbuffer::wanted(&device))
@@ -4970,6 +5078,7 @@ impl Renderer {
solid_cutout,
translucent,
translucent_mark,
world_warps,
flat_on: flat.is_some(),
packed_quads,
cull_on: cull.is_some(),
@@ -5178,6 +5287,12 @@ impl Renderer {
pack_pages: Vec::new(),
outline_edges_scratch: Vec::new(),
outline_lines_scratch: Vec::new(),
debug_lines_scratch: Vec::new(),
screen_vertices_scratch: Vec::new(),
lod_leaving_scratch: crate::fast_hash::FxHashSet::default(),
shadow_boxed_scratch: Vec::new(),
shadow_seen_scratch: crate::fast_hash::FxHashSet::default(),
shadow_translucent_scratch: Vec::new(),
pack_lava_layers: Vec::new(),
hud,
menu_blur: None,
@@ -6048,6 +6163,31 @@ impl Renderer {
id
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
if solid_slots > 0 {
self.arena.write_sources(
&self.device,
encoder,
&mut self.section_uploads,
page,
start,
solid_slots,
if packed { &[] } else { &mesh.solid_sources },
);
}
if translucent_vertices > 0 {
self.translucent_arena
.as_mut()
.unwrap_or(&mut self.arena)
.write_sources(
&self.device,
encoder,
&mut self.section_uploads,
translucent_at.0,
translucent_at.1,
translucent_vertices,
&mesh.translucent_sources,
);
}
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
let solid_bytes: &[u8] = if packed {
bytemuck::cast_slice(&mesh.packed)
@@ -7078,8 +7218,8 @@ impl Renderer {
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
/// (its block's light), three values a layer.
/// `block_ids` gives each source state its model and fluid material ids (two values
/// per state); emission and ownership come from the mesher's quad records.
/// Returns the programs that run and the notes of loading them, or why the pack can't run:
/// the dimension then runs without a pack.
pub fn set_shader_pack(
@@ -8441,10 +8581,37 @@ impl Renderer {
0,
bytemuck::bytes_of(&crosshair_globals),
);
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// A native mod's world warp (`world_warp`), asked once a frame. While it draws, nothing is
// left out by where it was (the frustum, the caves, occlusion, faces turned away): the warp
// may bring it into view. Its frames keep to the per-section draws its pipelines are built
// for. A shader pack that draws the terrain itself (`pack_terrain`) isn't warped.
let warp_on = !frame.title && {
let warp = native_render::world_warp();
self.world_warps.frame(
&self.device,
&mut self.uploads,
&mut encoder,
&warp,
frame.camera,
// (the frame's partial tick: `weather::Time::new`'s third value)
frame.weather_time.values[2],
!pack_terrain,
)
};
// Frustum culling and camera-relative origins.
let cull_proj = views.cull().view_proj(pose);
let view = Frustum {
planes: frustum(cull_proj),
planes: if warp_on {
[Vec4::W; 6]
} else {
frustum(cull_proj)
},
};
let camera = frame.camera;
let camera_section = camera_section(camera, self.layout.min_y());
@@ -8452,7 +8619,7 @@ impl Renderer {
// (above the screen's top, the far shore, the trees against the sky).
let mirror_plane = frame
.water_plane
.filter(|_| !frame.title && self.wants_water_plane());
.filter(|_| !frame.title && !warp_on && self.wants_water_plane());
let mirror = mirror_plane.map(|plane| Frustum {
planes: frustum(mirror_view_projection(
cull_proj,
@@ -8462,11 +8629,11 @@ impl Renderer {
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection. With the mirror, those
// facing its eye are kept too: it sits straight below, at this camera-relative y.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
let eye = projection_center(cull_proj).filter(|_| self.sides_on && !warp_on);
let mirror_low = mirror_plane
.zip(eye)
.map(|(plane, eye)| 2.0 * (plane - frame.camera.y as f32) - eye.y);
let culling = !frame.title && frame.cave_culling;
let culling = !frame.title && frame.cave_culling && !warp_on;
if culling {
crate::spans::time("occlusion", || {
self.start_occlusion(camera, frame.detail_distance)
@@ -8487,12 +8654,6 @@ impl Renderer {
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
// GPU culling's fading sections, decided at `fades.now`: before the gate, the record
@@ -8526,7 +8687,8 @@ impl Renderer {
&& !pack_terrain
&& shadow_view.is_none()
&& mirror_plane.is_none()
&& !displaced;
&& !displaced
&& !warp_on;
#[cfg(debug_assertions)]
if gpu_frame {
if self.gpu_checks.is_multiple_of(64) {
@@ -8582,11 +8744,10 @@ impl Renderer {
let cutout = &self.lod_cutout;
// Sections fading out (`leave_section`) are past the render distance:
// drawn there until they have, or they popped out at once.
let leaving: crate::fast_hash::FxHashSet<(i32, i32)> = self
.lod_leaving
.iter()
.map(|&((x, _, z), _)| (x, z))
.collect();
self.lod_leaving_scratch.clear();
self.lod_leaving_scratch
.extend(self.lod_leaving.iter().map(|&((x, _, z), _)| (x, z)));
let leaving = &self.lod_leaving_scratch;
let mut visible = std::mem::take(&mut self.visible);
visible.clear();
crate::spans::time("visibility", || {
@@ -8633,6 +8794,8 @@ impl Renderer {
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
reach: v.distance,
};
let mut boxed = std::mem::take(&mut self.shadow_boxed_scratch);
let mut seen = std::mem::take(&mut self.shadow_seen_scratch);
crate::spans::time("shadow visibility", || {
// Within the unculled distance every section, then those in the light's box.
if let Some(d) = v.unculled {
@@ -8653,13 +8816,15 @@ impl Renderer {
&mut shadow_visible,
);
}
let mut boxed = Vec::new();
boxed.clear();
self.sections
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
let seen: crate::fast_hash::FxHashSet<_> =
shadow_visible.iter().map(|s| s.key).collect();
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
seen.clear();
seen.extend(shadow_visible.iter().map(|s| s.key));
shadow_visible.extend(boxed.drain(..).filter(|s| !seen.contains(&s.key)));
});
self.shadow_boxed_scratch = boxed;
self.shadow_seen_scratch = seen;
// Nearest the light first, so the depth test rejects what lies under it.
let light = v.light;
shadow_visible.sort_unstable_by(|a, b| {
@@ -8821,6 +8986,7 @@ impl Renderer {
&& self.multi_draw
// Occlusion is found from the camera, not the mirror's eye.
&& mirror.is_none()
&& !warp_on
// It tests the CPU's lists, which leave out GPU culling's sections.
&& !gpu_frame
&& visible.len() >= c.min_sections
@@ -8834,7 +9000,7 @@ impl Renderer {
// ones (`Part`), each solid pass drawing its own: `[..opaque_draws]`,
// then the rest; phase 2 likewise. A fading list's quads all draw in the
// cutout pass.
let split_solid = self.info.backend == wgpu::Backend::Metal;
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
let parts = |fading: bool| -> &'static [(u8, Quads)] {
match (split_solid, fading) {
(false, _) => &[(0, Quads::All)],
@@ -9130,7 +9296,8 @@ impl Renderer {
// them for `every_face`), then translucent ones (in their last sorted order; depth
// decides there).
let shadow_first = self.draws.len() as u32;
let mut shadow_translucent = Vec::new();
let mut shadow_translucent = std::mem::take(&mut self.shadow_translucent_scratch);
shadow_translucent.clear();
self.shadow_runs.clear();
if let Some(view) = &shadow_view {
let base = listed;
@@ -9192,12 +9359,13 @@ impl Renderer {
self.shadow_runs
.regroup(&mut self.draws, &mut self.draw_pages, shadow_first as usize);
self.shadow_runs.clear();
for (distance, draw, pages) in shadow_translucent {
for (distance, draw, pages) in shadow_translucent.drain(..) {
self.shadow_runs
.push(distance, pages.0, self.draws.len(), 1, [0; 3]);
self.draws.push(draw);
self.draw_pages.push(pages);
}
self.shadow_translucent_scratch = shadow_translucent;
self.shadow_runs.regroup(
&mut self.draws,
&mut self.draw_pages,
@@ -9243,16 +9411,19 @@ impl Renderer {
mapped_at_creation: false,
});
}
let all: Vec<[f32; 8]> = debug_lines_opaque_first(frame.debug_lines)
.chain(frame.debug_lines_on_top)
.chain(frame.debug_crosshair)
.copied()
.collect();
let lines = &mut self.debug_lines_scratch;
lines.clear();
lines.extend(
debug_lines_opaque_first(frame.debug_lines)
.chain(frame.debug_lines_on_top)
.chain(frame.debug_crosshair)
.copied(),
);
self.uploads.buffer(
&mut encoder,
&self.debug_line_buffer,
0,
bytemuck::cast_slice(&all),
bytemuck::cast_slice(lines),
);
}
self.weather.prepare(
@@ -9394,6 +9565,7 @@ impl Renderer {
// path.
let oit_on = self.improved_transparency
&& !frame.title
&& !warp_on
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.is_none()
&& oit::supported(&self.device);
@@ -9765,8 +9937,9 @@ impl Renderer {
// Every upload before the frame's first pass: a pass ends wgpu-core's
// command buffer, so copies between passes would need one more.
if !frame.screen.is_empty() {
let vertices: Vec<crate::screen_effects::ScreenVertex> =
frame.screen.iter().flat_map(|q| q.vertices).collect();
let vertices = &mut self.screen_vertices_scratch;
vertices.clear();
vertices.extend(frame.screen.iter().flat_map(|q| q.vertices));
if vertices.len() as u64 > self.screen_capacity {
self.screen_capacity = (vertices.len() as u64).next_power_of_two();
self.screen_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -9781,7 +9954,7 @@ impl Renderer {
&mut encoder,
&self.screen_buffer,
0,
bytemuck::cast_slice(&vertices),
bytemuck::cast_slice(vertices),
);
}
self.hud.prepare(
@@ -9807,6 +9980,7 @@ impl Renderer {
// They copy the CPU's lists alone (GPU culling is never offered
// where vanilla frames draw flat).
&& !gpu_frame
&& !warp_on
&& !self.draws.is_empty()
&& (listed >= flat.min_sections
|| pack_terrain
@@ -10355,16 +10529,25 @@ impl Renderer {
let material = self
.native
.as_ref()
.filter(|_| !frame.title)
.filter(|_| !frame.title && !warp_on)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
let hdr = self.hdr.is_some();
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
// A world warp's pipelines in place of the terrain's and the entities' (`warp_on`).
let warped = self.world_warps.pipelines().filter(|_| warp_on);
let warp_group = self.world_warps.group().filter(|_| warp_on);
let solid_pipeline = match warped {
Some(w) => w.solid.get(hdr),
None => material.map_or(self.solid.get(hdr), |(m, _)| &m.solid),
};
// Two solid passes only where a discard costs the GPU its hidden surface
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
// would double the solid draw calls and vertex work for nothing.
let split_solid = self.info.backend == wgpu::Backend::Metal;
let translucent_pipeline = material.map_or(&self.translucent, |(m, _)| &m.translucent);
let split_solid = self.info.backend == wgpu::Backend::Metal && !warp_on;
let translucent_pipeline = match warped {
Some(w) => &w.translucent,
None => material.map_or(&self.translucent, |(m, _)| &m.translucent),
};
let pack_flat = material.and_then(|(m, _)| m.flat.as_ref());
// A material stage's translucent pass reads a copy of the depth the solid terrain
// left: it is stored across the pass that ends before the copy.
@@ -10384,6 +10567,7 @@ impl Renderer {
.filter(|n| {
!frame.title
&& !pack_draws_terrain
&& !warp_on
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
})
.map(|n| n.frame.surface_view.clone());
@@ -10686,6 +10870,9 @@ impl Renderer {
let pulled = material.is_none() && self.flat.is_some();
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
match cull {
Some(cull) => {
// Phase 1 and the boxes against its depth (in
@@ -10817,7 +11004,7 @@ impl Renderer {
if let Some(n) = self
.native
.as_ref()
.filter(|_| !frame.title && !pack_draws_terrain)
.filter(|_| !frame.title && !pack_draws_terrain && !warp_on)
&& let Some(d) = &n.displacement
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
{
@@ -10829,8 +11016,17 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
// A world warp's entity pipelines read its group 2.
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
let hdr_mark = warped.map_or(&self.hdr_mark_pipeline, |w| &w.hdr_mark);
let hdr_entity_mark =
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
if static_entity_quads > 0 {
pass.set_pipeline(&self.static_entity_pipeline);
pass.set_pipeline(
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.static_entity_group, &[]);
pass.set_index_buffer(
@@ -10853,18 +11049,18 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if light_marks(self.frame_format, self.hdr.is_some()) {
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.set_pipeline(hdr_entity_mark);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 && !oit_on {
pass.set_pipeline(&self.text_polygon_offset_pipeline);
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
@@ -10873,7 +11069,7 @@ impl Renderer {
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
// come before the translucent items, which then cover them (#7026).
if shadow_quads > 0 && !oit_on {
pass.set_pipeline(&self.shadow_blob_pipeline);
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
@@ -10888,7 +11084,9 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 && !oit_on {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_pipeline(
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
@@ -10898,7 +11096,7 @@ impl Renderer {
);
if light_marks(self.frame_format, self.hdr.is_some()) {
// A glowing item in the world (Item sheet quads), as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(hdr_mark);
pass.draw_indexed(
0..translucent_item_quads * 6,
((base_quads - translucent_item_quads) * 4) as i32,
@@ -11295,6 +11493,9 @@ impl Renderer {
}
None => {
pass.set_pipeline(translucent_pipeline);
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
@@ -11304,7 +11505,15 @@ impl Renderer {
solid_draws,
translucent_draws,
);
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
// (its HDR mark would fall where the water was before the warp moved it)
if !warp_on {
self.mark_translucent_terrain(
&mut pass,
None,
solid_draws,
translucent_draws,
);
}
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -11312,7 +11521,10 @@ impl Renderer {
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 && !oit_on {
pass.set_pipeline(&self.entity_pipeline);
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11323,7 +11535,7 @@ impl Renderer {
);
if light_marks(self.frame_format, self.hdr.is_some()) {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.set_pipeline(hdr_entity_mark);
pass.draw_indexed(
0..translucent_particle_quads * 6,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
@@ -11451,8 +11663,11 @@ impl Renderer {
let flat = self.flat.as_ref().filter(|_| flat_frame);
self.pack_pages.clear();
if flat.is_some() && (pack_draws_terrain || pack_draws_shadow) {
self.pack_pages
.extend(self.arena.page_buffers().map(|(p, b)| (p, b.clone())));
self.pack_pages.extend(
self.arena
.page_buffers()
.map(|(p, b)| (p, b.clone(), self.arena.sources(p).cloned())),
);
}
let pages = &self.pack_pages;
let mut terrain =
@@ -13778,7 +13993,7 @@ mod tests {
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow, wgpu::Dx12Compiler::Fxc)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
@@ -13845,6 +14060,14 @@ mod tests {
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "world_warp_fragment.wgsl" {
// A world warp's copy of terrain.wgsl (gfx/world_warp.rs), with the plainest warp.
let warp = super::world_warp::IDENTITY;
sources.push((path, super::world_warp::terrain_shader(warp).unwrap()));
} else if name == "world_warp.wgsl" {
// And of entity.wgsl with static_entity.wgsl.
let warp = super::world_warp::IDENTITY;
sources.push((path, super::world_warp::entity_shader(warp).unwrap()));
} else {
sources.push((path, text));
}
@@ -14607,7 +14830,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None, false)
super::instance_descriptor(backends, None, false, wgpu::Dx12Compiler::Fxc)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
+103 -1
View File
@@ -5,6 +5,9 @@
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::{BTreeMap, VecDeque};
#[cfg(test)]
#[path = "arena/source_tests.rs"]
mod source_tests;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -82,6 +85,8 @@ impl FreeList {
/// One vertex buffer of the arena; only the newest page grows.
struct Page {
buffer: wgpu::Buffer,
/// Optional shader-pack source records, indexed by the vertex index / 4.
sources: Option<wgpu::Buffer>,
/// Capacity in vertices.
capacity: u64,
free: FreeList,
@@ -116,6 +121,7 @@ pub struct Arena {
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
sources_enabled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
#[cfg(test)]
video_memory_full: bool,
@@ -200,6 +206,7 @@ impl Arena {
usage,
closing: None,
spilled: false,
sources_enabled: false,
#[cfg(test)]
video_memory_full: false,
}
@@ -249,7 +256,11 @@ impl Arena {
}
/// Bytes of every page's buffer.
pub fn bytes(&self) -> u64 {
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
self.pages
.iter()
.flatten()
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
.sum()
}
/// The most vertices a page holds.
pub fn page_vertices(&self) -> u64 {
@@ -281,6 +292,65 @@ impl Arena {
.expect("live arena page")
.buffer
}
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
self.pages[page as usize].as_ref()?.sources.as_ref()
}
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
return None;
}
try_create(
device,
"Terrain quad sources",
capacity / 4 * 8,
wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
)
}
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
/// unknown; a later source upload may retry its page.
pub fn write_sources(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
page: u32,
start: u64,
vertices: u64,
sources: &[crate::mesher::TerrainSource],
) {
if !sources.is_empty() && !self.sources_enabled {
self.sources_enabled = true;
for p in self.pages.iter_mut().flatten() {
p.sources = Self::source_buffer(device, p.capacity);
}
}
let p = self.pages[page as usize].as_mut().expect("live arena page");
if p.sources.is_none() && !sources.is_empty() {
p.sources = Self::source_buffer(device, p.capacity);
}
if let Some(buffer) = &p.sources {
let bytes = vertices / 4 * 8;
if bytes > 0 {
// Recycled ranges must never keep another mesh's identity, even for a
// missing or incomplete sidecar. Zero records have no valid bit.
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
if sources.len() as u64 == vertices / 4 {
uploads.buffer(
encoder,
buffer,
start / 4 * 8,
bytemuck::cast_slice(sources),
);
}
}
}
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
@@ -336,6 +406,10 @@ impl Arena {
let start = free.take(len).expect("a new page fits one section");
let page = Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -451,6 +525,10 @@ impl Arena {
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -483,6 +561,21 @@ impl Arena {
to.1 * self.stride,
vertices * self.stride,
);
if let Some(to_sources) = self.sources(to.0) {
let bytes = vertices / 4 * 8;
if bytes > 0 {
match self.sources(from.0) {
Some(from_sources) => encoder.copy_buffer_to_buffer(
from_sources,
from.1 / 4 * 8,
to_sources,
to.1 / 4 * 8,
bytes,
),
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
}
}
}
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
@@ -540,9 +633,17 @@ impl Arena {
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let sources = self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten();
let old = self.pages[page].as_mut().expect("live arena page");
// Copies recorded for the old buffer run before this one.
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
if let (Some(from), Some(to)) = (&old.sources, &sources) {
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
}
old.sources = sources;
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
@@ -1447,6 +1548,7 @@ mod tests {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
arena.pages = vec![Some(Page {
sources: None,
buffer,
capacity,
free,
@@ -0,0 +1,145 @@
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
use super::*;
use crate::mesher::{TerrainSource, TerrainVertex};
fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
buffer: &wgpu::Buffer,
offset: u64,
size: u64,
) -> Vec<u8> {
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
out.slice(..).get_mapped_range().unwrap().to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let mut arena = Arena::new(&device, 24, GRAIN, true);
arena.page_limit = GRAIN * 2;
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
assert!(
arena.sources(first.0).is_none(),
"default geometry allocates no source buffer"
);
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
uploads.buffer(
&mut encoder,
arena.buffer(first.0),
first.1 * 24,
bytemuck::cast_slice(&geometry),
);
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
first.0,
first.1,
4,
&[source],
);
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(second, (first.0, GRAIN), "grew the first page");
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_ne!(third.0, first.0, "move crosses pages");
arena.copy(&mut encoder, first, third, 4);
arena.release(first.0, first.1, 4);
let reused = arena.alloc_existing(4).unwrap();
assert_eq!(reused, first);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
reused.0,
reused.1,
4,
&[],
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(
&device,
&queue,
arena.sources(third.0).unwrap(),
third.1 / 4 * 8,
8
),
bytemuck::bytes_of(&source)
);
assert_eq!(
read(
&device,
&queue,
arena.sources(first.0).unwrap(),
first.1 / 4 * 8,
8
),
[0; 8]
);
assert_eq!(
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
// A real requested binding limit refuses metadata/table allocation. The native
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
let limits = wgpu::Limits {
max_storage_buffer_binding_size: 256,
..Default::default()
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
.unwrap();
let mut arena = Arena::translucent(&device, 24, GRAIN);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
uploads.buffer(
&mut encoder,
arena.buffer(at.0),
at.1 * 24,
bytemuck::cast_slice(&geometry),
);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
at.0,
at.1,
4,
&[source],
);
assert!(
arena.sources(at.0).is_none(),
"sidecar refused without losing vertices"
);
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
}
@@ -64,8 +64,10 @@ impl Copies {
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
solid_sources: mesh.solid_sources.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
translucent_sources: mesh.translucent_sources.clone(),
packed: mesh.packed.clone(),
packed_far: mesh.packed_far,
solid_tints: Vec::new(),
+11 -7
View File
@@ -27,11 +27,13 @@
//! 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
//! counts come from the GPU, which reads them from the draws themselves:
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
//! indirect draw per entry. Translucent draws are not culled.
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
//! which wgpu replays as one indirect draw per entry. Translucent draws are
//! not culled.
//!
//! Metal cannot force the depth test before a fragment shader that writes
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
//! storage (naga writes no `[[early_fragment_tests]]`), nor can DirectX 12
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
@@ -224,9 +226,9 @@ impl Cull {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
/// with the instance counts the GPU wrote, with fragment shaders that
/// write storage.
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan | wgpu::Backend::Gl
@@ -236,7 +238,9 @@ impl Cull {
{
DepthTest::Early
}
wgpu::Backend::Metal => DepthTest::InShader,
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
// shader there too.
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
_ => return None,
};
(device
@@ -0,0 +1,132 @@
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
//! GPUs, FXC on every other adapter (#8513).
//!
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
//! beside the game: `tools/ci/fetch_dxc.py`).
//!
//! wgpu chooses the compiler for a whole instance, before any adapter is
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
//! instance with DXC. That happens once, when the device is opened; nothing
//! here runs per frame. Without the DLL, or when it does not load, the game
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
use std::path::PathBuf;
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
pub(super) const DLL: &str = "dxcompiler.dll";
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
/// set, so its compiler is already the one every instance uses).
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
!forced
&& info.backend == wgpu::Backend::Dx12
&& info.device_type == wgpu::DeviceType::IntegratedGpu
}
/// Whether `WGPU_DX12_COMPILER` names the compiler.
pub(super) fn forced() -> bool {
std::env::var_os("WGPU_DX12_COMPILER").is_some()
}
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
/// one loaded.
pub(super) fn bundled_dll() -> Option<PathBuf> {
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
dll.is_file().then_some(dll)
}
/// The compiler setting that loads `dll`, and fails the instance (rather
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
/// then keeps the FXC instance it already has.
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
wgpu::Dx12Compiler::DynamicDxc {
dxc_path: dll.to_string_lossy().into_owned(),
}
}
/// Whether the DXC instance offered the adapter the FXC instance had: on a
/// laptop with two GPUs, the same request should pick the same one, and the
/// game must not switch GPUs because the compiler changed.
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
fxc.backend == dxc.backend
&& fxc.vendor == dxc.vendor
&& fxc.device == dxc.device
&& fxc.device_type == dxc.device_type
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics".into(),
vendor: 0x8086,
device: 0x9a49,
device_type,
device_pci_bus_id: "0000:00:02.0".into(),
driver: String::new(),
driver_info: String::new(),
backend,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
#[test]
fn only_integrated_gpus_on_directx_12_get_dxc() {
use wgpu::{Backend, DeviceType};
assert!(wants_dxc(
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
false
));
for kind in [
DeviceType::DiscreteGpu,
DeviceType::VirtualGpu,
DeviceType::Cpu,
DeviceType::Other,
] {
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
}
// Other backends have no shader compiler to choose.
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
assert!(
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
"{backend:?}"
);
}
}
#[test]
fn wgpu_dx12_compiler_overrides_the_choice() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(!wants_dxc(&igpu, true));
}
#[test]
fn the_dxc_instance_must_offer_the_same_gpu() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(same_adapter(&igpu, &igpu.clone()));
let dgpu = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
vendor: 0x10de,
device: 0x25a0,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
..igpu.clone()
};
assert!(!same_adapter(&igpu, &dgpu));
}
}
+36 -1
View File
@@ -174,11 +174,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
/// Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
|| is_adreno_dx12(info)
}
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
@@ -1044,6 +1055,30 @@ impl Flat {
mod tests {
use super::*;
#[test]
fn adreno_draws_flat_under_dx12_only() {
let mut info = wgpu::AdapterInfo {
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
vendor: 0x4d4f_4351,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "31.0.128.0".into(),
driver_info: String::new(),
backend: wgpu::Backend::Dx12,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_adreno_dx12(&info));
info.backend = wgpu::Backend::Vulkan;
assert!(!is_adreno_dx12(&info));
info.backend = wgpu::Backend::Dx12;
info.vendor = 0x10de;
assert!(!is_adreno_dx12(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -2735,8 +2735,9 @@ mod tests {
/// `test` on each device here that draws counts with first instances:
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
/// compiles with FXC, part of Windows: wgpu takes DXC only where
/// `dxcompiler.dll` loads, and the game ships none.
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
/// beside the game in the Windows x64 zip) on integrated GPUs
/// (`gfx/dx12_compiler.rs`).
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
let mut tested = false;
for (backends, compiler, name) in [
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
vec![
"terrain.wgsl",
"world_warp.wgsl",
"world_warp_fragment.wgsl",
],
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world warp entities",
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
@@ -7,14 +7,16 @@ use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
OnceLock,
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
needs
}
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct WorldWarp {
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
/// pixel of the terrain. `None`: no warp.
pub source: Option<Arc<str>>,
/// Whether this frame draws with the warp. A source given while this is false is still
/// built, so a mod can pay for its pipelines before the frame that needs them.
pub on: bool,
/// What the warp reads as `warp.params`: four `vec4<f32>`.
pub params: [f32; 16],
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
#[inline(never)]
pub fn native_world_warp(warp: &mut WorldWarp) {
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
}
/// This frame's world warp ([`native_world_warp`]).
pub fn world_warp() -> WorldWarp {
let mut warp = WorldWarp::default();
native_world_warp(&mut warp);
warp
}
#[cfg(test)]
mod tests {
use super::*;
@@ -378,6 +412,33 @@ mod tests {
assert_eq!(texels, expected);
}
struct Bend(&'static str);
impl RenderHooks for Bend {
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
event.warp.source = Some(self.0.into());
event.warp.on = true;
event.warp.params[0] += 1.0;
}
}
#[test]
fn the_last_mod_to_set_a_warp_draws() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
assert_eq!(warp.source.as_deref(), Some("b"));
assert!(warp.on);
// each saw what the one before left
assert_eq!(warp.params[0], 2.0);
}
#[test]
fn without_mods_there_is_no_warp() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[], &mut warp);
assert_eq!(warp, WorldWarp::default());
assert!(warp.source.is_none() && !warp.on);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -630,13 +630,12 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
// Source metadata follows each quad in its arena page. The immutable table
// holds sprite rectangles and the current pack's ids of source states.
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -649,7 +648,8 @@ impl PackAtlas {
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
"uvec2 hz_rect = uvec2(0u, 1u);\n \
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
@@ -701,20 +701,27 @@ impl PackAtlas {
let mut body = body;
if flat {
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
uvec2 hz_source = uvec2(0u);\n \
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
int hz_material = -1;\n \
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
);
}
declarations.push_str(
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
);
let entity = if flat {
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
} else {
"vec4(-1.0, 0.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
// w is the source's emitted light, independent of sprite and smooth light.
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
} else {
"vec4(0.0)"
};
@@ -1154,3 +1161,7 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "pack_atlas/source_ownership_test.rs"]
mod source_ownership_test;
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
// The initializer, varying and integer output are independent probe code. The program reads
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
float first = mc_chunkFade;\n\
void main() {\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
origin,
0x80ffffff,
);
let fade = if requested { expected } else { 1.0 };
// The fade reaches the program whether the pack requested it or not.
let fade = expected;
let expected = [
fade,
if geometry { 0.8125 } else { fade },
@@ -307,9 +308,12 @@ fn sample(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
@@ -406,13 +406,16 @@ fn sample_probe(
bytemuck::bytes_of(&[0., 0., 0., scale]),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
@@ -0,0 +1,424 @@
//! Original numeric controls for the production terrain attribute expressions.
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
use super::*;
use wgpu::util::DeviceExt;
fn dispatch(
device: &wgpu::Device,
queue: &wgpu::Queue,
pipeline: &wgpu::ComputePipeline,
groups: &[wgpu::BindGroup],
result: &wgpu::Buffer,
) -> Vec<[f32; 4]> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: result.size(),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(pipeline);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
}
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_ownership_gpu() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("executed GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
let inputs = atlas.terrain_inputs(true);
// Inset faces of two source states sharing sprite zero, an extended multipart face,
// and the second state's fluid face. Ownership is authored
// fixture data, not inferred from normal, sprite, position or diagonal.
let mut vertices = Vec::new();
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
vertices.push(crate::mesher::TerrainVertex::new(
[x + dx, y, 4.0 + dz],
[0.0; 2],
0,
u32::MAX,
0,
));
}
}
let declarations = inputs
.declarations
.lines()
.filter(|l| !l.starts_with("layout(location"))
.collect::<Vec<_>>()
.join("\n");
let attribute = |name: &str| {
inputs
.attributes
.iter()
.find(|(n, _)| n == name)
.unwrap()
.1
.as_str()
};
let source = format!(
"#version 450\n{declarations}\n\
layout(local_size_x=1) in;\n\
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
void main() {{\n\
uint hz_index=gl_GlobalInvocationID.x;\n\
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
inputs.body.replace("gl_VertexIndex", "hz_index"),
attribute("mc_Entity"),
attribute("at_midBlock")
);
let mut frontend = naga::front::glsl::Frontend::default();
let module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
&source,
)
.unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Original terrain numeric control"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let layouts: Vec<_> = [1, 2, 0, 3]
.into_iter()
.map(|count| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &(0..count)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: if count == 1 {
wgpu::ShaderStages::COMPUTE
} else {
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
},
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage {
read_only: binding != 0 || count != 1,
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let storage = |bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage: wgpu::BufferUsages::STORAGE,
})
};
let result = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 32,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let slots = storage(bytemuck::cast_slice(&[0u32]));
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
let ids = storage(bytemuck::cast_slice(&[
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
]));
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
let rects = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
));
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(set),
entries: &buffers
.iter()
.enumerate()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: i as u32,
resource: b.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
};
let groups = [
group(0, &[&result]),
group(1, &[&slots, &origins]),
group(2, &[]),
group(3, &[&ids, &vertex_storage, &rects]),
];
queue.submit([encoder.finish()]);
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
let mut mismatches = Vec::new();
for (i, vertex) in vertices.iter().enumerate() {
let q = i / 4;
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
let owner = [
[2.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
][q];
let expected_mid = [
(owner[0] - vertex.pos[0]) * 64.0,
(owner[1] - vertex.pos[1]) * 64.0,
(owner[2] - vertex.pos[2]) * 64.0,
expected_light,
];
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|| actual[i * 2 + 1] != expected_mid
{
mismatches.push((
i,
actual[i * 2],
actual[i * 2 + 1],
expected_id,
expected_mid,
));
}
}
assert!(
mismatches.is_empty(),
"terrain source mismatches: {mismatches:?}"
);
// Rasterize the same native vertices and production input body, with a diagnostic
// projection placing one face in each strip. The fragment reports id, emission and
// reconstructed owner Y/X after a nonzero section translation; no texture color
// can hide a wrong ownership value.
let vertex_source = format!(
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
{}\nvec4 entity={};vec4 mid={};\n\
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
inputs.declarations,
inputs.body,
attribute("mc_Entity"),
attribute("at_midBlock")
);
let vertex_module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
&vertex_source,
)
.unwrap();
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
});
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vertex_shader,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::super::TERRAIN_ATTRIBUTES,
})],
},
fragment: Some(wgpu::FragmentState {
module: &fragment_shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let indices: Vec<u32> = (0..4)
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
.collect();
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&raster);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..24, 0, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &pixels,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
target.size(),
);
queue.submit([encoder.finish()]);
pixels
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = pixels.slice(..).get_mapped_range().unwrap();
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
for (i, pixel) in pixels.iter().enumerate() {
assert_eq!(
*pixel,
[
[31.0, 0.0, -28.5, 130.5],
[47.0, 13.0, -28.5, 134.5],
[47.0, 13.0, -28.5, 134.5],
[59.0, 13.0, -28.5, 134.5]
][i / 4],
"diagnostic raster pixel {i}"
);
}
// Reload changes only the immutable mapping; the same raw state/owner records
// resolve to new material ids without baking a previous pack's ids into a mesh.
let changed = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
));
let changed_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&ids, &vertex_storage, &changed]),
];
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
for i in 0..vertices.len() {
assert_eq!(
reloaded[2 * i],
[
if i < 4 {
211.0
} else if i < 12 {
223.0
} else {
59.0
},
if i >= 12 { 1.0 } else { 0.0 },
0.0,
0.0
]
);
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
}
let missing = storage(&[0; 8]);
let missing_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&missing, &vertex_storage, &rects]),
];
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
for i in 0..vertices.len() {
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
}
}
@@ -247,7 +247,7 @@ impl Casters {
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
match program {
ENTITY => self.entities && (!self.only_player || span.player),
BLOCK => self.blocks && (!self.only_lit || span.emissive),
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
_ => false,
}
}
@@ -440,7 +440,7 @@ impl PackEntities {
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = program_of(&span).expect("tagged skin stage");
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
@@ -462,7 +462,7 @@ impl PackEntities {
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut without_fluids = Vec::new();
for (span, range) in runs(entities.len(), spans) {
for (span, range, material) in material_runs(entities, spans, drawn) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
@@ -490,7 +490,8 @@ impl PackEntities {
// 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"))
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|| span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
@@ -520,7 +521,7 @@ impl PackEntities {
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
Part::Plain => program_of(&span).filter(|&p| {
Part::Plain => material.filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
}),
@@ -635,7 +636,7 @@ impl PackEntities {
portal: bool,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
@@ -782,7 +783,76 @@ pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
pub const BEAM: usize = 5;
pub const PROGRAMS: usize = 6;
pub const BLOCK_TRANSLUCENT: usize = 6;
pub const PARTICLE_TRANSLUCENT: usize = 7;
pub const PROGRAMS: usize = 8;
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
pub fn shadow_program(program: usize) -> usize {
if program == BLOCK_TRANSLUCENT {
BLOCK
} else {
program
}
}
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
Some(match program_of(span)? {
BLOCK
if drawn[BLOCK_TRANSLUCENT]
&& (span.translucent
|| layer & super::OIT_ENTITY != 0
|| matches!(
layer & 0xffff,
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
)) =>
{
BLOCK_TRANSLUCENT
}
PARTICLE
if drawn[PARTICLE_TRANSLUCENT]
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
{
PARTICLE_TRANSLUCENT
}
p => p,
})
}
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
/// their vertex order and the original single draw when no material variant is usable.
fn material_runs<'a>(
vertices: &'a [Vertex],
spans: &'a [EntitySpan],
drawn: [bool; PROGRAMS],
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
runs(vertices.len(), spans).flat_map(move |(span, range)| {
let split = match program_of(&span) {
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
_ => false,
};
let mut start = range.start;
std::iter::from_fn(move || {
if start >= range.end {
return None;
}
let program = material_program(&span, vertices[start].layer, drawn);
let end = if split {
(start..range.end)
.step_by(4)
.skip(1)
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
.unwrap_or(range.end)
} else {
range.end
};
let run = start..end;
start = end;
Some((span, run, program))
})
})
}
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -940,11 +1010,17 @@ 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, true]),
drawn_by_pack(
&spans[1],
[true, true, true, false, true, true, false, false]
),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
drawn_by_pack(
&spans[2],
[true, false, true, true, true, true, false, false]
),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
@@ -1676,3 +1752,6 @@ mod tier_translate_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod material_route_tests;
@@ -0,0 +1,62 @@
use super::*;
#[test]
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
let mut vertices = Vec::new();
for layer in [
0,
super::super::OIT_ENTITY,
0,
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
0,
] {
vertices.extend(
[Vertex {
layer: layer | 3 << 16,
color: 0x40808080,
..bytemuck::Zeroable::zeroed()
}; 4],
);
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 71,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
let collect = |drawn| {
material_runs(&vertices, &spans, drawn)
.map(|(_, range, p)| (range, p))
.collect::<Vec<_>>()
};
let mut legacy = [true; PROGRAMS];
legacy[BLOCK_TRANSLUCENT] = false;
legacy[PARTICLE_TRANSLUCENT] = false;
assert_eq!(
collect(legacy),
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
);
assert_eq!(
collect([true; PROGRAMS]),
[
(0..4, Some(BLOCK)),
(4..8, Some(BLOCK_TRANSLUCENT)),
(8..12, Some(PARTICLE)),
(12..16, Some(PARTICLE_TRANSLUCENT)),
(16..20, Some(PARTICLE)),
]
);
let casters = Casters {
blocks: true,
..Default::default()
};
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
}
@@ -533,8 +533,7 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
/// (its block's light): three values a layer.
/// Per source block state: its model material id, then its fluid material id.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
@@ -667,10 +666,10 @@ pub struct PackPost {
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The per-draw uniforms of gbuffers programs.
draws: DrawUniforms,
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
/// for each arena page, with the page buffer it was made for.
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
@@ -1210,30 +1209,44 @@ pub(super) struct ShadowView {
pub depth_planes: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
/// atlas (`rects`; zeros for a grid, which doesn't read them).
fn block_ids(
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
/// Keeping immutable tables together leaves the existing three storage bindings intact.
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
[rects.len() as i32, (table.len() / 2) as i32]
.into_iter()
.chain(rects.iter().map(|&v| v as i32))
.chain(table.iter().copied())
.collect()
}
pub(super) fn block_ids(
device: &wgpu::Device,
queue: &wgpu::Queue,
table: &[i32],
rects: &[u32],
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
let buffer = |label, contents: &[u8]| {
wgpu::util::DeviceExt::create_buffer_init(
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
return None;
}
let buffer = super::arena::try_create(
device,
&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents,
usage: wgpu::BufferUsages::STORAGE,
},
)
label,
contents.len() as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
)?;
queue.write_buffer(&buffer, 0, contents);
Some(buffer)
};
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
let ids = buffer("Unknown terrain sources", &[0; 8])?;
let rects = buffer(
"Shader pack terrain tables",
bytemuck::cast_slice(&terrain_tables(table, rects)),
)?;
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -1248,7 +1261,7 @@ fn block_ids(
label: Some("Shader pack terrain"),
entries: &[storage(0), storage(1), storage(2)],
});
(layout, ids, rects)
Some((layout, ids, rects))
}
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
@@ -2583,6 +2596,20 @@ impl PackPost {
.fold(0, |m, n| m | 1 << n),
jobs: defer.then(Default::default),
};
// Flat terrain resolves each quad's source state against the current pack.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| {
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
Refused {
reason: "terrain source tables could not be allocated".into(),
notes: notes.clone(),
}
})
})
.transpose()?;
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
// Not wanted any more: the rest is skipped and the build refused below.
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
@@ -2694,12 +2721,6 @@ impl PackPost {
}
Some(pass)
};
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
@@ -2779,14 +2800,18 @@ impl PackPost {
post.particles,
post.damaged_block,
post.beacon_beam,
post.block_translucent,
post.particles_translucent,
];
let late = [
let mut late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
true,
post.translucent_late,
true,
];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
@@ -2828,6 +2853,11 @@ impl PackPost {
}
})
});
// Translation alone is insufficient: a refused GPU pipeline must retain the
// legacy single particle pass and its timing as well as its geometry grouping.
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
late[super::pack_entities::PARTICLE] = false;
}
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| paged(build(p, Some(g))))
@@ -2851,10 +2881,12 @@ impl PackPost {
cull: false,
});
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
let weather_geometry = world.weather.then(|| Geometry {
buffers: &entity_buffers,
table: None,
depth_write: post.rain_depth,
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
@@ -3938,7 +3970,7 @@ impl PackPost {
}
let (groups, ids) = (&self.page_groups, pass.ids);
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
rp.set_bind_group(3, group, &[]);
}
};
@@ -4156,7 +4188,7 @@ impl PackPost {
continue;
};
let mut draws = (geometry.draws.iter())
.filter(|d| d.shadow && d.program == program)
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
.peekable();
if draws.peek().is_none() {
continue;
@@ -4168,7 +4200,11 @@ impl PackPost {
encoder,
pass,
size,
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
(geometry.draws.iter()).any(|d| {
d.shadow
&& super::pack_entities::shadow_program(d.program) == program
&& d.portal
}),
);
let mut on_portal = false;
let mut rp = self.begin(encoder, pass);
@@ -5153,7 +5189,7 @@ impl PackPost {
inputs: &PackInputs,
size: [u32; 2],
window: [u32; 2],
pages: &[(u32, wgpu::Buffer)],
pages: &[TerrainPage],
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
@@ -5189,8 +5225,8 @@ impl PackPost {
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
for (page, buffer) in pages {
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
for (page, buffer, sources) in pages {
self.page_groups.entry(*page).or_insert_with(|| {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack terrain"),
@@ -5198,7 +5234,7 @@ impl PackPost {
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ids.as_entire_binding(),
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
@@ -5210,7 +5246,7 @@ impl PackPost {
},
],
});
(buffer.clone(), group)
(buffer.clone(), sources.clone(), group)
});
}
}
@@ -6413,3 +6449,6 @@ mod mipmap_box_tests;
#[cfg(test)]
mod colortex_mip_blend_tests;
#[cfg(test)]
mod material_route_tests;
@@ -0,0 +1,134 @@
//! Original end-to-end material routing, timing and blending control; no pack assets.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
use crate::hud::HudBatch;
use horizon_shaders::render_stage;
use wgpu::TextureFormat as T;
#[test]
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
let gpu = Gpu::new().expect("an executed adapter is required for this control");
let mut vertices = Vec::new();
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
.into_iter()
.enumerate()
{
let x = -1.0 + i as f32 * 0.5;
for pos in [
[x, -1.0, 0.0],
[x + 0.5, -1.0, 0.0],
[x + 0.5, 1.0, 0.0],
[x, 1.0, 0.0],
] {
vertices.push(Vertex {
pos,
layer: layer | 2 << 16,
color: 0xffff_ffff,
..bytemuck::Zeroable::zeroed()
});
}
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 17,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
for variants in [false, true] {
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
("deferred.vsh".into(), VERTEX.into()),
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
];
let programs = [
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
];
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
let expected_id = if name.starts_with("gbuffers_block") {
17
} else {
0
};
files.push((format!("{name}.vsh"), vertex.into()));
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut input = super::super::pack_entities::inputs();
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
entities: Some(input),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
for frame in 0..2 {
gpu.clear(&color, &depth, 0.0);
let mut rest = Vec::new();
post.prepare_entities(
&gpu.device,
&gpu.queue,
&vertices,
&spans,
[0.; 4],
[0.; 4],
&mut rest,
None,
(&[], &[]),
);
assert!(rest.is_empty());
let entities = post.entities.as_ref().unwrap();
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
let pixels = gpu.render(
&mut post,
&color,
&depth,
&fixture_frame(&hud),
&mut |_, _, _, _| {},
);
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
let expected = if variants {
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
} else {
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
};
for (i, rgb) in expected.into_iter().enumerate() {
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
eprintln!(
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
);
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
}
}
}
}
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
@@ -16,14 +16,10 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
}))
.unwrap();
let mut depth_failures = Vec::new();
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
for (property, writes) in [
("rain.depth=true", true),
("rain.depth=false", false),
// An evidence-limited compatibility choice: preserve accepted main here.
// The permitted packs do not establish the external omitted-value default.
("", true),
] {
for property in ["rain.depth=true", "rain.depth=false", ""] {
let mut post = pack(&device, &queue, &adapter, program, property);
// Retain the pipelines for another frame, then resize their targets.
for size in [[6, 1], [6, 1], [12, 2]] {
@@ -90,7 +86,7 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
}
let depth =
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
let expected_depth = 0.5;
if depth != expected_depth {
depth_failures.push(format!(
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
@@ -0,0 +1,697 @@
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
//! blocks, light and depth, seen from any side) rather than a picture of it.
//!
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
//! pipelines built from them read the warp's uniform at group 2.
//!
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
//! facing away may be brought into view. It also keeps to the per-section draws, which these
//! pipelines are built for.
use super::native_render::WorldWarp;
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
use glam::DVec3;
use std::sync::Arc;
/// The line in a warp's source after which the terrain's pixel stage begins.
const FRAGMENTS: &str = "// [fragments]";
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
}
";
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
const VARYINGS: &str = "warp_home: vec4<f32>,
warp_at: vec3<f32>,";
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
@location(6) warp_at: vec3<f32>,";
const TO_WIRE: &str = "w.warp_home = o.warp_home;
w.warp_at = o.warp_at;";
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
o.warp_at = w.warp_at;";
const VERTEX: &str = "let warp_home = position + origin;
let warped = world_warp(warp_home, origin);
let pos = warped.pos;";
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
out.warp_at = pos;";
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
/// entity's is its section's corner.
const ENTITY: &str = "let at = world_warp(position, position).pos;";
const STATIC_ENTITY: &str =
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
/// The plainest warp, for the tests of the shaders it makes.
#[cfg(test)]
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
let a = shader
.find(&from)
.ok_or_else(|| format!("no `{from}` in the shader"))?;
let b = shader[a..]
.find(&to)
.ok_or_else(|| format!("no `{to}` in the shader"))?
+ a;
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
}
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
fn split(source: &str) -> (&str, Option<&str>) {
match source.find(FRAGMENTS) {
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
None => (source, None),
}
}
/// The warp's copy of terrain.wgsl.
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
let (vertex, fragment) = split(source);
let fragment = fragment
.filter(|f| f.contains("fn world_fragment"))
.unwrap_or(DEFAULT_FRAGMENT);
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
let shader = fill(&shader, "world warp wire", WIRE)?;
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
let shader = fill(&shader, "world warp vertex", VERTEX)?;
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
Ok(format!(
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
include_str!("world_warp.wgsl"),
include_str!("world_warp_fragment.wgsl"),
))
}
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
let (vertex, _) = split(source);
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
Ok(format!(
"{shader}\n{}\n{vertex}",
include_str!("world_warp.wgsl")
))
}
fn validate(source: &str) -> Result<(), String> {
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| e.emit_to_string(source))?;
Ok(())
}
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
params: [[f32; 4]; 4],
camera: [f32; 4],
camera_rest: [f32; 4],
}
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
let (params, _) = warp.params.as_chunks::<4>();
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
let at = camera.as_vec3();
let rest = camera - at.as_dvec3();
Uniform {
params,
camera: [at.x, at.y, at.z, partial_tick],
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
}
}
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
pub(super) struct Kit {
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
/// (`vs_section` reads its vertices from it).
pub terrain: wgpu::BindGroupLayout,
pub pages: Option<wgpu::BindGroupLayout>,
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
pub entity: wgpu::BindGroupLayout,
pub static_entity: wgpu::BindGroupLayout,
pub format: wgpu::TextureFormat,
pub sprite_constants: [(&'static str, f64); 2],
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
pub hdr_output: bool,
}
/// The warp's pipelines, in place of the renderer's while it is on.
pub(super) struct Pipelines {
pub solid: SolidPipeline,
pub translucent: wgpu::RenderPipeline,
pub entities: wgpu::RenderPipeline,
pub static_entities: wgpu::RenderPipeline,
pub translucent_items: wgpu::RenderPipeline,
pub text: wgpu::RenderPipeline,
pub entity_shadows: wgpu::RenderPipeline,
pub hdr_mark: wgpu::RenderPipeline,
pub hdr_entity_mark: wgpu::RenderPipeline,
}
/// The warp's uniform, and what binds it.
struct Bound {
layout: wgpu::BindGroupLayout,
buffer: wgpu::Buffer,
group: wgpu::BindGroup,
}
/// The renderer's world warp: nothing made until a mod gives one.
pub(super) struct WorldWarps {
kit: Kit,
bound: Option<Bound>,
built: Option<(Arc<str>, Pipelines)>,
/// The last source that didn't compile, so it is tried once.
refused: Option<Arc<str>>,
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
told_undrawable: bool,
}
impl WorldWarps {
pub fn new(kit: Kit) -> Self {
Self {
kit,
bound: None,
built: None,
refused: None,
told_undrawable: false,
}
}
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
/// frame can't take it (`drawable`: a shader pack draws the terrain).
pub fn frame(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
warp: &WorldWarp,
camera: DVec3,
partial_tick: f32,
drawable: bool,
) -> bool {
let Some(source) = &warp.source else {
return false;
};
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
if self.refused.as_ref().is_some_and(same) {
return false;
}
match self.build(device, source) {
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
Err(e) => {
log::warn!("Horizon: a mod's world warp did not compile: {e}");
self.refused = Some(source.clone());
self.built = None;
return false;
}
}
}
if !warp.on {
return false;
}
if !drawable {
if !self.told_undrawable {
log::info!(
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
);
self.told_undrawable = true;
}
return false;
}
let bound = self.bound(device);
uploads.buffer(
encoder,
&bound.buffer,
0,
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
);
true
}
/// The pipelines of the warp `frame` readied.
pub fn pipelines(&self) -> Option<&Pipelines> {
self.built.as_ref().map(|(_, p)| p)
}
/// The warp's group 2, once a frame has drawn with it.
pub fn group(&self) -> Option<&wgpu::BindGroup> {
self.bound.as_ref().map(|b| &b.group)
}
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
self.bound.get_or_insert_with(|| bind(device))
}
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
let started = std::time::Instant::now();
let terrain = terrain_shader(source)?;
let entity = entity_shader(source)?;
validate(&terrain)?;
validate(&entity)?;
// Validation failures above are the mod's; past here wgpu's are the game's.
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
let kit = &self.kit;
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: groups,
..Default::default()
})
};
let terrain_layout = layout(
"Warped terrain layout",
&[Some(&kit.terrain), None, Some(warp)],
);
let pages_layout = kit.pages.as_ref().map(|pages| {
layout(
"Warped terrain layout (pulled)",
&[Some(&kit.terrain), Some(pages), Some(warp)],
)
});
let entity_layout = layout(
"Warped entity layout",
&[Some(&kit.entity), None, Some(warp)],
);
let static_layout = layout(
"Warped static block-entity layout",
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
);
let module = |label, source: String| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(source.into()),
})
};
let terrain = module("Warped terrain", terrain);
let entity = module("Warped entities", entity);
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let buffered = [Some(vertices), Some(instances.clone())];
let pulled = [None, Some(instances)];
// As gfx.rs's: a float frame takes light where others take marks.
let write_light = (
"HDR_WRITE_LIGHT",
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
);
let lit = [
kit.sprite_constants[0],
kit.sprite_constants[1],
write_light,
];
let plain = [
kit.sprite_constants[0],
kit.sprite_constants[1],
("HDR_LIGHTING", 0.0),
write_light,
];
let depth = |write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
};
// As gfx.rs's terrain pipelines, without back-face culling.
let terrain_pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment,
constants: &[(&str, f64)],
blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &terrain,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(depth(true)),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &terrain,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
// vertex buffers without.
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
Some(pages) => (pages, "vs_section", &pulled),
None => (&terrain_layout, "vs_terrain", &buffered),
};
let solid = |constants: &[(&str, f64)]| {
terrain_pipeline(
"Warped terrain solid",
solid_layout,
solid_vertex,
solid_buffers,
"fs_warp_solid",
constants,
None,
)
};
let solid = SolidPipeline {
plain: solid(&plain),
hdr: kit.hdr_output.then(|| solid(&lit)),
};
let translucent = terrain_pipeline(
"Warped terrain translucent",
&terrain_layout,
"vs_terrain",
&buffered,
"fs_warp_translucent",
&lit,
Some(wgpu::BlendState::ALPHA_BLENDING),
);
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let entity_vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
};
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
let static_buffers = [
Some(entity_vertices.clone()),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &origin_attributes,
}),
];
let entity_buffers = [Some(entity_vertices)];
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
let entity_pipeline = |look: &EntityLook| {
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
(&static_layout, "vs_static_entity", &static_buffers)
} else {
(&entity_layout, "vs_entity", &entity_buffers)
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(look.label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &entity,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: look.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
bias: look.bias,
..depth(look.depth_write)
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity,
entry_point: Some(look.fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[write_light],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend: look.blend,
write_mask: look.write_mask,
})],
}),
multiview_mask: None,
cache: None,
})
};
let pipelines = Pipelines {
solid,
translucent,
entities: entity_pipeline(&ENTITIES),
static_entities: entity_pipeline(&STATIC_ENTITIES),
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
text: entity_pipeline(&TEXT),
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
hdr_mark: entity_pipeline(&HDR_MARK),
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
};
log::info!(
"Horizon: a mod's world warp built in {} ms",
started.elapsed().as_millis()
);
Ok(pipelines)
}
}
fn bind(device: &wgpu::Device) -> Bound {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World warp"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World warp"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World warp"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Bound {
layout,
buffer,
group,
}
}
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
/// of the same name.
struct EntityLook {
label: &'static str,
static_mesh: bool,
cull: Option<wgpu::Face>,
depth_write: bool,
bias: wgpu::DepthBiasState,
blend: Option<wgpu::BlendState>,
fragment: &'static str,
write_mask: wgpu::ColorWrites,
}
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
const ENTITIES: EntityLook = EntityLook {
label: "Warped entities",
static_mesh: false,
cull: None,
depth_write: true,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
},
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
fragment: "fs_entity",
write_mask: wgpu::ColorWrites::ALL,
};
const STATIC_ENTITIES: EntityLook = EntityLook {
label: "Warped static block entities",
static_mesh: true,
bias: NO_BIAS,
blend: None,
fragment: "fs_entity_unmarked",
..ENTITIES
};
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
label: "Warped translucent items",
bias: NO_BIAS,
..ENTITIES
};
const TEXT: EntityLook = EntityLook {
label: "Warped polygon offset text",
cull: Some(wgpu::Face::Back),
bias: wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
},
fragment: "fs_entity_unmarked",
..ENTITIES
};
const ENTITY_SHADOWS: EntityLook = EntityLook {
label: "Warped entity shadows",
cull: Some(wgpu::Face::Back),
depth_write: false,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
},
fragment: "fs_entity_shadow",
..ENTITIES
};
const HDR_MARK: EntityLook = EntityLook {
label: "Warped HDR particle mark",
depth_write: false,
bias: NO_BIAS,
blend: Some(wgpu::BlendState::REPLACE),
fragment: "fs_hdr_mark",
write_mask: wgpu::ColorWrites::ALPHA,
..ENTITIES
};
/// `HDR_MARK` at the entities' depth bias, for what they drew.
const HDR_ENTITY_MARK: EntityLook = EntityLook {
label: "Warped HDR entity mark",
bias: ENTITIES.bias,
..HDR_MARK
};
#[cfg(test)]
mod tests {
use super::*;
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
}
";
#[test]
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
let terrain = terrain_shader(BEND).unwrap();
validate(&terrain).unwrap();
assert!(terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(BEND).unwrap();
validate(&entity).unwrap();
}
#[test]
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
let source = format!(
"{BEND}{FRAGMENTS}
fn world_fragment(f: Fragment) -> Painted {{
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
let far = length(f.home - f.at) + f.cut;
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
}}
"
);
let terrain = terrain_shader(&source).unwrap();
validate(&terrain).unwrap();
assert!(!terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(&source).unwrap();
validate(&entity).unwrap();
assert!(!entity.contains("world_fragment"));
}
#[test]
fn the_stock_shaders_hold_nothing_of_a_warp() {
for (name, shader) in [
("terrain", super::super::terrain_shader()),
("entity", super::super::static_entity_shader()),
] {
validate(&shader).unwrap();
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
}
}
}
#[test]
fn every_marker_is_found() {
let terrain = terrain_shader(BEND).unwrap();
let entity = entity_shader(BEND).unwrap();
for shader in [&terrain, &entity] {
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
}
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
assert!(entity.contains("let at = world_warp(position, position).pos;"));
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
assert_eq!(
fill("a // [x] b // [end x] c", "x", "B"),
Ok("a B c".to_owned())
);
assert!(fill("a // [x] b", "x", "B").is_err());
}
#[test]
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
let terrain =
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
let e = validate(&terrain).unwrap_err();
assert!(e.contains("world_warp"), "{e}");
}
#[test]
fn the_uniform_keeps_the_camera_exact_far_out() {
let mut warp = WorldWarp::default();
warp.params[5] = 2.0;
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
let u = uniform(&warp, camera, 0.25);
assert_eq!(u.params[1][1], 2.0);
assert_eq!(u.camera[3], 0.25);
for i in 0..3 {
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
}
assert_eq!(std::mem::size_of::<Uniform>(), 96);
}
}
@@ -0,0 +1,21 @@
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
// None of it is in the shaders a frame without a warp draws with.
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
struct Warped {
pos: vec3<f32>,
tint: vec3<f32>,
}
struct WorldWarpUniform {
// `WorldWarp::params`.
params: array<vec4<f32>, 4>,
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
// smoother than the ticks `params` are set in.
camera: vec4<f32>,
camera_rest: vec4<f32>,
}
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
@@ -0,0 +1,70 @@
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
// A pixel of the land as `world_fragment` gets it.
struct Fragment {
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
home: vec3<f32>,
// Where it is drawn, from the camera.
at: vec3<f32>,
// `sprite(uv, layer)` is the block's texture here.
uv: vec2<f32>,
layer: u32,
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
color: vec4<f32>,
// Sky and block light, 0 to 15.
light: vec2<f32>,
// Over 0 where a vertex of the face had a tint with red under 0.
cut: f32,
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
front: bool,
}
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
// of the game's fog it takes, 0 to 1.
struct Painted {
color: vec4<f32>,
fog: f32,
}
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
in.scene.yx, in.warp_home.w, front);
}
// As `solid_color`, with the mod's colour in place of the texture's.
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
// (far terrain's tiles and the sections replacing them still cross-fade)
if faded(in.fade, in.local, dx, dy) { discard; }
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
}
// As `fs_translucent`.
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
if color.a < 0.01 { discard; }
let fogged = apply_fog(color, in.spherical, in.cylindrical);
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
let fog = 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 mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return finished;
}
@@ -13,7 +13,7 @@ use crate::{
lang,
options_ui::{Event, Page as OptionsPage},
settings::{Opt, Settings},
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
text::Text,
};
@@ -1333,7 +1333,7 @@ fn shown(item: Item, s: &Settings) -> bool {
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings) -> (String, Option<String>) {
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Key`).
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
if item == Item::Horizon(H::HdrKey) {
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
return (lang::get("Shortcut"), Some(key));
@@ -1347,9 +1347,10 @@ fn row(setting: Setting, s: &Settings) -> Row<Item> {
let item = setting.item;
let (name, value) = parts(item, s);
let value = match (item, item.switch(s), item.slider(s)) {
(Item::Horizon(H::HdrKey), ..) => {
Value::Key(crate::graphics::HDR_KEY, value.unwrap_or_default())
}
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
crate::graphics::HDR_KEY,
value.unwrap_or_default(),
)),
(_, Some(on), _) => Value::Switch(on),
(_, None, Some(position)) => Value::Slider {
position,
@@ -1486,7 +1487,6 @@ impl Screen for GraphicsScreen {
Output::Push(OptionsPage::ShaderPacks),
]
}
Item::Horizon(H::HdrKey) => vec![Output::BindKey(crate::graphics::HDR_KEY)],
_ => {
item.cycle(s, back, self.framebuffer);
vec![Output::Event(Event::Changed)]
@@ -1564,7 +1564,7 @@ mod tests {
use super::*;
use crate::{
graphics_style as style,
settings_shell::{Columns, Hosted, Shell, Target},
settings_shell::{Capture, Columns, Hosted, Shell, Target},
};
use winit::keyboard::KeyCode as K;
@@ -1868,7 +1868,7 @@ mod tests {
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
assert_eq!(
press(&mut ui, &mut s, at(key)),
[CLICK, Output::BindKey(crate::graphics::HDR_KEY)]
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
);
assert!(ui.changed());
}
@@ -2092,7 +2092,8 @@ mod tests {
);
let key = find(&s, Item::Horizon(H::HdrKey));
assert_eq!(key.name, "Shortcut");
assert!(matches!(key.value, Value::Key(crate::graphics::HDR_KEY, _)));
let waits = Capture::Key(crate::graphics::HDR_KEY);
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
assert_eq!(calibrate.value, Value::Press);
}
+35 -1
View File
@@ -1355,7 +1355,15 @@ impl HudAtlas {
.filter(|&size| size != (w, h));
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
// banks from 1024-wide textures).
let particle = name.starts_with("particle/") || name.contains(":particle/");
let (gw, gh) = if particle {
(0, 0)
} else {
gui_size.unwrap_or((w, h))
};
let maps = read_maps
.then(|| {
let stem = path.file_stem()?.to_str()?;
@@ -3427,6 +3435,32 @@ mod tests {
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
#[test]
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let rays = root.join("assets/mypack/textures/particle/light_rays");
fs::create_dir_all(&rays).unwrap();
write_strip(
&rays.join("02.png"),
1024,
1024,
&[[250, 240, 200, 255]],
None,
);
let gui = root.join("assets/minecraft/textures/gui");
fs::create_dir_all(&gui).unwrap();
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["mypack:particle/light_rays/02"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
assert!(!atlas.sprites.contains_key("gui/huge"));
fs::remove_dir_all(&root).unwrap();
}
/// OptiFine random entity variants outside `textures/` pack under the names
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
#[test]
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
text(out, t, x, y, color, true);
}
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
/// matters when the comparator is not a total order. Longer arrays fall back
/// to a stable sort.
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
/// reversed in place when it is strictly descending, then insert each remaining
/// element by binary search after the elements equal to it, so the sort is stable.
/// The order of the comparisons matters because vanilla's comparator here is not a
/// total order. Longer arrays fall back to a stable sort.
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
let n = a.len();
if n < 2 {
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
a.sort_by(cmp);
return;
}
// countRunAndMakeAscending.
// The run at the start: strictly descending (then reversed) or non-descending.
let mut run = 2;
if cmp(&a[1], &a[0]) == Ordering::Less {
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
run += 1;
}
}
// binarySort.
// Binary insertion of the rest, each element after the last one not greater.
for start in run..n {
let (mut left, mut right) = (0, start);
while left < right {
+5 -5
View File
@@ -1283,7 +1283,7 @@ fn relight_layer(
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
@@ -1312,13 +1312,13 @@ mod tests {
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
pub(crate) const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
pub(crate) const TORCH: u32 = 2;
const SLAB: u32 = 3;
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
/// empty top), with a third face shape for the half sides.
fn tables() -> LightTables {
pub(crate) fn tables() -> LightTables {
let cell = |dampening, emission, faces: [u16; 6]| Cell {
dampening,
emission,
@@ -1337,7 +1337,7 @@ mod tests {
3,
)
}
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
}
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
+17 -2
View File
@@ -7,6 +7,7 @@ use crate::{
mesher::SectionMesh,
streaming::{LodSource, WorkerContext},
};
pub mod bake;
pub mod cache;
mod mesh;
pub mod packed;
@@ -2860,6 +2861,14 @@ impl Lod {
if self.outstanding_meshes >= self.mesh_workers * 4 {
return;
}
// As in `request_samples`: the far meshing threads run behind the render thread, so it
// never waits for this lock. Busy now, the dirty tiles stay dirty for the next frame.
let shared = self.shared.clone();
let mut queued = match shared.mesh_jobs.try_lock() {
Ok(queued) => queued,
Err(std::sync::TryLockError::WouldBlock) => return,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
};
let keys: Vec<(TileKey, u64)> = self
.dirty
.drain()
@@ -2878,7 +2887,6 @@ impl Lod {
split: self.plan.split.clone(),
});
let covered = Arc::new(self.covered.clone());
let mut queued = self.shared.mesh_jobs.lock().unwrap();
let count = keys.len();
for (key, version) in keys {
let finer = self.finer_of(key);
@@ -2989,7 +2997,14 @@ impl Lod {
self.wanted_stale = false;
}
let mut issued = 0;
let mut jobs = self.shared.jobs.lock().unwrap();
// The far sampling threads take this lock too, and they run behind the render thread
// (`far_terrain_current`): one preempted while holding it kept a frame waiting for over
// 100 ms on a busy machine. Busy now, the requests go out next frame.
let mut jobs = match self.shared.jobs.try_lock() {
Ok(jobs) => jobs,
Err(std::sync::TryLockError::WouldBlock) => return,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
};
while issued < free {
let Some(std::cmp::Reverse(wanted)) = self.wanted.pop() else {
break;
@@ -0,0 +1,534 @@
//! Baked far terrain: the generator's far samples (`LodSampler::grid`), kept
//! in the dimension's folder (`horizon_lod/`) so the next session draws them
//! without sampling the noise again. The detail chunks' columns are kept by
//! `lod::cache`.
//!
//! Only generator output is baked: saved chunks still override it
//! (`save::SavedLodSampler`), so edits show as before. A fingerprint of the
//! game build, the world's data packs, the block and biome registries and the
//! level's shape guards the folder; when any of them changes it is emptied.
//! Reduced far detail's approximate samples are kept apart from full ones.
use super::{Column, Span, Surface};
use crate::{chunk::LevelShape, fast_hash::FxHashMap};
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
use horizon_world::registry::BlockStateId;
use sha2::{Digest, Sha256};
use std::{
fs,
io::{BufReader, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
sync::Mutex,
};
const FORMAT: &str = "horizon-lod-bake 3";
const FOLDER: &str = "horizon_lod";
const FINGERPRINT: &str = "fingerprint.txt";
/// Tiles per bucket file side.
const GRID_BUCKET: i32 = 8;
/// The key written before each record: kind, x, z, stride, side.
const KEY_BYTES: usize = 15;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Key {
/// `LodSampler::grid(x, z, stride, side)`, sampled approximately or not.
Grid {
approximate: bool,
x: i32,
z: i32,
stride: i32,
side: u16,
},
}
impl Key {
fn file(self) -> String {
match self {
Key::Grid {
approximate,
x,
z,
stride,
side,
} => {
let span = stride * i32::from(side) * GRID_BUCKET;
format!(
"{}{stride}x{side}.{}.{}.bin",
if approximate { "a" } else { "g" },
x.div_euclid(span),
z.div_euclid(span)
)
}
}
}
fn bytes(self) -> [u8; KEY_BYTES] {
let (kind, x, z, stride, side) = match self {
Key::Grid {
approximate,
x,
z,
stride,
side,
} => (u8::from(approximate), x, z, stride, side),
};
let mut out = [0; KEY_BYTES];
out[0] = kind;
out[1..5].copy_from_slice(&x.to_le_bytes());
out[5..9].copy_from_slice(&z.to_le_bytes());
out[9..13].copy_from_slice(&stride.to_le_bytes());
out[13..15].copy_from_slice(&side.to_le_bytes());
out
}
fn from_bytes(b: &[u8; KEY_BYTES]) -> Option<Self> {
let int = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
match b[0] {
kind @ (0 | 1) => Some(Key::Grid {
approximate: kind == 1,
x: int(1),
z: int(5),
stride: int(9),
side: u16::from_le_bytes([b[13], b[14]]),
}),
_ => None,
}
}
}
/// One append-only file of records: `[u32 length][key][zlib columns]`.
struct Bucket {
file: fs::File,
/// Each record's compressed columns: offset and length.
index: FxHashMap<Key, (u64, u32)>,
end: u64,
}
impl Bucket {
fn open(path: &Path) -> std::io::Result<Self> {
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
let mut index = FxHashMap::default();
let mut end = 0u64;
let len = file.metadata()?.len();
{
let mut reader = BufReader::new(&mut file);
loop {
let mut length = [0; 4];
if reader.read_exact(&mut length).is_err() {
break;
}
let length = u32::from_le_bytes(length);
let mut key = [0; KEY_BYTES];
if (length as usize) < KEY_BYTES || reader.read_exact(&mut key).is_err() {
break;
}
let Some(key) = Key::from_bytes(&key) else {
break;
};
let body = length - KEY_BYTES as u32;
let start = end + 4 + KEY_BYTES as u64;
// A record cut short by a crash ends the file there.
if start + u64::from(body) > len || reader.seek_relative(i64::from(body)).is_err() {
break;
}
index.insert(key, (start, body));
end = start + u64::from(body);
}
}
if len != end {
file.set_len(end)?;
}
Ok(Self { file, index, end })
}
fn read(&mut self, key: Key) -> Option<Vec<u8>> {
let &(offset, length) = self.index.get(&key)?;
let mut body = vec![0; length as usize];
self.file.seek(SeekFrom::Start(offset)).ok()?;
self.file.read_exact(&mut body).ok()?;
Some(body)
}
fn append(&mut self, key: Key, body: &[u8]) -> std::io::Result<()> {
let length = (KEY_BYTES + body.len()) as u32;
let mut record = Vec::with_capacity(4 + length as usize);
record.extend_from_slice(&length.to_le_bytes());
record.extend_from_slice(&key.bytes());
record.extend_from_slice(body);
self.file.seek(SeekFrom::Start(self.end))?;
self.file.write_all(&record)?;
let start = self.end + 4 + KEY_BYTES as u64;
self.index.insert(key, (start, body.len() as u32));
self.end = start + body.len() as u64;
Ok(())
}
}
/// A dimension's baked far terrain, shared by the far sampling threads.
pub struct LodBake {
dir: PathBuf,
/// Open buckets by file name; `None` for one that failed to open.
buckets: Mutex<FxHashMap<String, Option<Bucket>>>,
}
impl LodBake {
/// Opens `dimension/horizon_lod`, emptying it when its fingerprint is not
/// this build's, world's, level's and registries'. `None` (no baking) on
/// an error.
pub fn open(
dimension: &Path,
shape: LevelShape,
block_states: usize,
biomes: usize,
) -> Option<Self> {
let dir = dimension.join(FOLDER);
let fingerprint = fingerprint(dimension, shape, block_states, biomes);
let current = fs::read_to_string(dir.join(FINGERPRINT)).ok();
if current.as_deref() != Some(fingerprint.as_str()) {
if current.is_some() {
log::info!("Horizon: baked far terrain is out of date; baking it again");
}
let reset = || -> std::io::Result<()> {
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
fs::create_dir_all(&dir)?;
fs::write(dir.join(FINGERPRINT), &fingerprint)
};
if let Err(e) = reset() {
log::warn!(
"Horizon: far terrain will not be baked ({}): {e}",
dir.display()
);
return None;
}
}
Some(Self {
dir,
buckets: Mutex::new(FxHashMap::default()),
})
}
fn with_bucket<T>(&self, key: Key, f: impl FnOnce(&mut Bucket) -> Option<T>) -> Option<T> {
let name = key.file();
let mut buckets = self.buckets.lock().unwrap();
let bucket = buckets
.entry(name)
.or_insert_with_key(|name| match Bucket::open(&self.dir.join(name)) {
Ok(bucket) => Some(bucket),
Err(e) => {
log::warn!("Horizon: baked far terrain {name}: {e}");
None
}
})
.as_mut()?;
f(bucket)
}
fn get(&self, key: Key, count: usize) -> Option<Vec<Column>> {
let body = self.with_bucket(key, |bucket| bucket.read(key))?;
let mut raw = Vec::new();
ZlibDecoder::new(&body[..]).read_to_end(&mut raw).ok()?;
decode(&raw, count)
}
fn put(&self, key: Key, columns: &[Column]) {
if self.with_bucket(key, |bucket| Some(bucket.index.contains_key(&key))) != Some(false) {
return;
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
let _ = encoder.write_all(&encode(columns));
let Ok(body) = encoder.finish() else { return };
self.with_bucket(key, |bucket| {
if !bucket.index.contains_key(&key)
&& let Err(e) = bucket.append(key, &body)
{
log::warn!("Horizon: baking far terrain: {e}");
}
Some(())
});
}
/// The baked `LodSampler::grid` result, if any.
pub fn grid(
&self,
approximate: bool,
[x, z]: [i32; 2],
stride: i32,
side: usize,
) -> Option<Vec<Column>> {
let side16 = u16::try_from(side).ok()?;
self.get(
Key::Grid {
approximate,
x,
z,
stride,
side: side16,
},
side * side,
)
}
pub fn put_grid(
&self,
approximate: bool,
[x, z]: [i32; 2],
stride: i32,
side: usize,
columns: &[Column],
) {
// A failed direct sample comes back as empty columns: never keep it.
let Ok(side) = u16::try_from(side) else {
return;
};
if columns.iter().all(|c| *c == Column::default()) {
return;
}
self.put(
Key::Grid {
approximate,
x,
z,
stride,
side,
},
columns,
);
}
}
/// What baked columns depend on besides the world's seed (fixed per world).
fn fingerprint(dimension: &Path, shape: LevelShape, block_states: usize, biomes: usize) -> String {
let build = super::cache::build_key(block_states, biomes);
let packs = world_packs_digest(dimension);
format!(
"{FORMAT}\nbuild {build:016x}\nlevel {} {}\ndatapacks {packs}\n",
shape.min_y, shape.height,
)
}
/// `files_digest` of the data packs of the world `dimension` is a folder of.
pub fn world_packs_digest(dimension: &Path) -> String {
// `dimensions/<namespace>/<path>` inside the world folder.
dimension
.ancestors()
.nth(3)
.map(|world| files_digest(&world.join("datapacks")))
.unwrap_or_default()
}
/// Every file under `root`: its path, size and change time.
pub fn files_digest(root: &Path) -> String {
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
} else {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos());
let relative = path.strip_prefix(root).unwrap_or(&path);
files.push(format!("{} {} {modified}", relative.display(), meta.len()));
}
}
}
files.sort_unstable();
let mut hash = Sha256::new();
for file in &files {
hash.update(file.as_bytes());
hash.update(b"\n");
}
hash.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
fn encode(columns: &[Column]) -> Vec<u8> {
let mut out = Vec::with_capacity(columns.len() * 24);
out.extend_from_slice(&(columns.len() as u32).to_le_bytes());
for column in columns {
match column.surface {
None => out.push(0),
Some(s) => {
out.push(if s.water.is_some() { 2 } else { 1 });
out.extend_from_slice(&s.height.to_le_bytes());
out.extend_from_slice(&s.state.0.to_le_bytes());
out.extend_from_slice(&s.biome.to_le_bytes());
if let Some((height, state)) = s.water {
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&state.0.to_le_bytes());
}
}
}
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
for span in &column.spans {
out.extend_from_slice(&span.min.to_le_bytes());
out.extend_from_slice(&span.max.to_le_bytes());
out.extend_from_slice(&span.state.0.to_le_bytes());
out.extend_from_slice(&span.biome.to_le_bytes());
out.extend_from_slice(&span.light.to_le_bytes());
}
}
out
}
fn decode(mut b: &[u8], count: usize) -> Option<Vec<Column>> {
fn take<const N: usize>(b: &mut &[u8]) -> Option<[u8; N]> {
let (head, rest) = b.split_first_chunk::<N>()?;
*b = rest;
Some(*head)
}
let i32_ = |b: &mut &[u8]| take::<4>(b).map(i32::from_le_bytes);
let u32_ = |b: &mut &[u8]| take::<4>(b).map(u32::from_le_bytes);
let u16_ = |b: &mut &[u8]| take::<2>(b).map(u16::from_le_bytes);
let i16_ = |b: &mut &[u8]| take::<2>(b).map(i16::from_le_bytes);
if u32_(&mut b)? as usize != count {
return None;
}
let mut columns = Vec::with_capacity(count);
for _ in 0..count {
let kind = take::<1>(&mut b)?[0];
let surface = match kind {
0 => None,
1 | 2 => {
let height = i32_(&mut b)?;
let state = BlockStateId(u32_(&mut b)?);
let biome = u16_(&mut b)?;
let water = if kind == 2 {
Some((i32_(&mut b)?, BlockStateId(u32_(&mut b)?)))
} else {
None
};
Some(Surface {
height,
state,
water,
biome,
})
}
_ => return None,
};
let spans = (0..u16_(&mut b)?)
.map(|_| {
Some(Span {
min: i16_(&mut b)?,
max: i16_(&mut b)?,
state: BlockStateId(u32_(&mut b)?),
biome: u16_(&mut b)?,
light: u16_(&mut b)?,
})
})
.collect::<Option<Vec<_>>>()?;
columns.push(Column {
surface,
spans: spans.into(),
});
}
b.is_empty().then_some(columns)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(seed: i32) -> Column {
Column {
surface: Some(Surface {
height: 70 + seed,
state: BlockStateId(9),
water: (seed % 2 == 0).then_some((80, BlockStateId(86))),
biome: 3,
}),
spans: vec![
Span {
min: -64,
max: (70 + seed) as i16,
state: BlockStateId(1),
biome: 3,
light: 0,
},
Span {
min: (70 + seed) as i16,
max: 80,
state: BlockStateId(86),
biome: 3,
light: 240,
},
]
.into(),
}
}
#[test]
fn columns_round_trip_and_survive_reopening() {
let world = std::env::temp_dir().join(format!("horizon-lod-bake-{}", std::process::id()));
let _ = fs::remove_dir_all(&world);
let dimension = world.join("dimensions").join("minecraft").join("overworld");
fs::create_dir_all(&dimension).unwrap();
let grid: Vec<Column> = (0..256).map(sample).collect();
let other: Vec<Column> = (1..257).map(sample).collect();
let shape = LevelShape::OVERWORLD;
{
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
bake.put_grid(false, [-64, 32], 4, 16, &grid);
// A second put of the same key is ignored, not appended.
bake.put_grid(false, [-64, 32], 4, 16, &[sample(-1)]);
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
// Reduced far detail's samples are kept apart from full ones.
assert_eq!(bake.grid(true, [-64, 32], 4, 16), None);
bake.put_grid(true, [-64, 32], 4, 16, &other);
assert_eq!(
bake.grid(true, [-64, 32], 4, 16).as_deref(),
Some(&other[..])
);
}
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
assert_eq!(bake.grid(false, [-64, 48], 4, 16), None);
drop(bake);
// A cut-off record (a crash mid-write) is dropped, the rest is kept:
// (-128, 32) shares (-64, 32)'s bucket file.
let key = Key::Grid {
approximate: false,
x: -64,
z: 32,
stride: 4,
side: 16,
};
let file = dimension.join(FOLDER).join(key.file());
let full = fs::metadata(&file).unwrap().len();
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
bake.put_grid(false, [-128, 32], 4, 16, &other);
drop(bake);
let f = fs::OpenOptions::new().write(true).open(&file).unwrap();
f.set_len(fs::metadata(&file).unwrap().len() - 5).unwrap();
drop(f);
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(bake.grid(false, [-128, 32], 4, 16), None);
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
assert_eq!(fs::metadata(&file).unwrap().len(), full);
drop(bake);
// Another registry (or data pack, build or level shape) empties the folder.
let bake = LodBake::open(&dimension, shape, 101, 10).unwrap();
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
drop(bake);
fs::remove_dir_all(&world).unwrap();
}
#[test]
fn empty_samples_are_not_baked() {
let world = std::env::temp_dir().join(format!("horizon-lod-empty-{}", std::process::id()));
let _ = fs::remove_dir_all(&world);
let dimension = world.join("dimensions").join("minecraft").join("overworld");
fs::create_dir_all(&dimension).unwrap();
let bake = LodBake::open(&dimension, LevelShape::OVERWORLD, 1, 1).unwrap();
bake.put_grid(false, [0, 0], 16, 16, &vec![Column::default(); 256]);
assert_eq!(bake.grid(false, [0, 0], 16, 16), None);
drop(bake);
fs::remove_dir_all(&world).unwrap();
}
}
+170 -6
View File
@@ -8,6 +8,9 @@ mod leaf_culling;
mod lighting;
pub mod packed;
mod sides;
#[cfg(test)]
#[path = "mesher/source_tests.rs"]
mod source_tests;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
@@ -340,13 +343,41 @@ mod section_mesh_tests {
);
}
}
/// A shader terrain quad's unmodified source state and owning section cell. Two words
/// per quad, kept beside (not in) the native 24-byte vertex format. Word 1 has cell XYZ
/// in three nibbles, a fluid flag, a valid flag, then the emitted light (0..15).
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct TerrainSource {
state: u32,
cell: u32,
}
impl TerrainSource {
pub(crate) fn new(state: u32, pos: [i32; 3], emission: u8, fluid: bool) -> Self {
debug_assert!(pos.iter().all(|&v| (0..16).contains(&v)));
Self {
state,
cell: pos[0] as u32
| (pos[1] as u32) << 4
| (pos[2] as u32) << 8
| u32::from(fluid) << 12
| 1 << 13
| u32::from(emission.min(15)) << 14,
}
}
}
pub struct SectionMesh {
pub solid: Vec<TerrainVertex>,
/// One source per quad when a shader pack meshes terrain; independent of its sprite
/// and geometry. Empty on native/default meshes and on distant terrain.
pub solid_sources: Vec<TerrainSource>,
/// The buckets `solid` is sorted in by the side its quads face: empty
/// until `sort_solid`, which the meshing workers call; `solid` is in
/// model order before.
pub sides: Sides,
pub translucent: Vec<TerrainVertex>,
pub translucent_sources: Vec<TerrainSource>,
/// Whether `packed` holds far tile records (`lod::packed`) rather than
/// section records (`mesher::packed`): the two are read apart, by the base
/// vertex their draws use.
@@ -380,8 +411,10 @@ impl Default for SectionMesh {
fn default() -> Self {
Self {
solid: Vec::new(),
solid_sources: Vec::new(),
sides: Sides::default(),
translucent: Vec::new(),
translucent_sources: Vec::new(),
packed: Vec::new(),
packed_far: false,
solid_tints: Vec::new(),
@@ -416,7 +449,9 @@ impl SectionMesh {
/// Retain geometry capacity after an upload, but none of its old contents.
pub fn clear(&mut self) {
self.solid.clear();
self.solid_sources.clear();
self.translucent.clear();
self.translucent_sources.clear();
self.packed.clear();
self.packed_far = false;
self.solid_tints.clear();
@@ -431,6 +466,8 @@ 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.solid_sources.capacity() + self.translucent_sources.capacity())
* std::mem::size_of::<TerrainSource>()
+ 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)>()
@@ -445,7 +482,11 @@ impl SectionMesh {
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.sides = sides::sort_sourced(
&mut self.solid,
&mut self.solid_tints,
&mut self.solid_sources,
);
self.bounds = self.compute_bounds();
self.packed.clear();
self.packed_far = false;
@@ -455,6 +496,20 @@ impl SectionMesh {
self.solid_tints.clear();
}
/// Every face emitted since `start` belongs to this block, including multipart,
/// connected-texture pieces, overlays and duplicated/back-facing fluid faces.
fn record_source(&mut self, start: [usize; 2], source: TerrainSource) {
for (vertices, sources, start) in [
(&self.solid, &mut self.solid_sources, start[0]),
(&self.translucent, &mut self.translucent_sources, start[1]),
] {
if vertices.len() > start {
sources.resize(start / 4, TerrainSource::default());
sources.resize(vertices.len() / 4, source);
}
}
}
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
if self.solid.is_empty() && self.translucent.is_empty() {
@@ -644,6 +699,8 @@ struct Info {
block_entity: bool,
tints: [Tint; 2],
tint_count: u8,
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
colormap: Option<u16>,
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
offset: Option<(f32, f32)>,
/// `isSolid()` (legacy), for fluid corner heights.
@@ -700,6 +757,9 @@ pub struct MeshTables {
lava_still: u16,
lava_flow: u16,
animated_layers: Vec<bool>,
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
colormaps: Vec<Arc<crate::environment::Colormap>>,
water_colormap: Option<u16>,
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
@@ -842,6 +902,7 @@ impl MeshTables {
block_entity: s.block_entity,
tints,
tint_count,
colormap: None,
offset,
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
@@ -875,8 +936,52 @@ impl MeshTables {
lava_still: layer("minecraft:block/lava_still")?,
lava_flow: layer("minecraft:block/lava_flow")?,
animated_layers,
colormaps: Vec::new(),
water_colormap: None,
})
}
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
/// from the colormap, and its own tint where the colormap's pixel is transparent.
#[must_use]
pub fn with_polytone_colormaps(
mut self,
pack_root: &std::path::Path,
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
if modifiers.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
*loaded.entry(id.to_owned()).or_insert_with(|| {
let colormap = crate::environment::Colormap::load(pack_root, id)?;
colormaps.push(Arc::new(colormap));
Some((colormaps.len() - 1) as u16)
})
};
for (fluid, targets, id) in &modifiers {
if *fluid {
if targets.matches("minecraft:water", |_| false) {
self.water_colormap = index(id, &mut self.colormaps);
}
continue;
}
let Some(i) = index(id, &mut self.colormaps) else {
continue;
};
for (state, info) in self.info.iter_mut().enumerate() {
let Some(block) = registry.state(BlockStateId(state as u32)) else {
continue;
};
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
info.colormap = Some(i);
}
}
}
self
}
/// Whether a terrain sprite layer has an animated clip.
pub fn is_animated(&self, layer: u16) -> bool {
self.animated_layers
@@ -971,7 +1076,7 @@ impl MeshTables {
}
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
}
/// `BlockColors` for each of the state's tint indices at `pos`, as the
@@ -989,7 +1094,8 @@ impl MeshTables {
let sources = self.info.get(state.0 as usize).map(|info| {
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let mut tints =
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
let local = [x & 15, y & 15, z & 15];
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
(colors, i32::from(info.tint_count))
@@ -1382,6 +1488,11 @@ struct Tints<'a> {
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
water_colormap: Option<u16>,
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1400,8 +1511,41 @@ impl<'a> Tints<'a> {
origin,
layers: Default::default(),
uniform: None,
colormaps: &[],
water_colormap: None,
sampled: std::collections::HashMap::new(),
}
}
/// With the packs' colormaps of `tables`.
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
self.colormaps = &tables.colormaps;
self.water_colormap = tables.water_colormap;
self
}
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
/// is transparent.
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
return known;
}
let colormap = self.colormaps.get(usize::from(index))?;
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
let [cx, cz, base_y] = self.origin;
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
self.sampled.insert((index, [x, y, z]), colour);
colour
}
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
if let Some(index) = self.water_colormap
&& let Some(colour) = self.colormap(index, x, y, z)
{
return colour;
}
self.get(Resolver::Water, x, y, z)
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
@@ -1645,7 +1789,8 @@ impl Mesher<'_> {
self.zoom,
self.blend_radius,
[center.x, center.z, base_y],
);
)
.with_colormaps(self.tables);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
@@ -1674,7 +1819,8 @@ impl Mesher<'_> {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let start = [out.solid.len(), out.translucent.len()];
let water = tints.water(x, y, z);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
@@ -1682,6 +1828,12 @@ impl Mesher<'_> {
self.shader_pack,
);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
if self.shader_pack {
out.record_source(
start,
TerrainSource::new(id, [x, y, z], state.emission, true),
);
}
}
if !state.model {
continue;
@@ -1754,6 +1906,7 @@ impl Mesher<'_> {
.unwrap_or(true);
}
let connected = &self.render.connected;
let start = [out.solid.len(), out.translucent.len()];
for model in &models {
for face in &model.faces {
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
@@ -1797,6 +1950,12 @@ impl Mesher<'_> {
);
}
}
if self.shader_pack {
out.record_source(
start,
TerrainSource::new(id, [x, y, z], state.emission, false),
);
}
}
}
}
@@ -1808,13 +1967,18 @@ impl Mesher<'_> {
if index < 0 || index >= i32::from(state.tint_count) {
return None;
}
if let Some(colormap) = state.colormap
&& let Some(colour) = tints.colormap(colormap, x, y, z)
{
return Some(colour);
}
Some(match state.tints[(index as usize).min(1)] {
Tint::None => return None,
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
Tint::Water => tints.get(Resolver::Water, x, y, z),
Tint::Water => tints.water(x, y, z),
Tint::Constant(c) => c,
Tint::LilyPad => 0x20_8030,
Tint::Redstone(p) => redstone_color(p),
@@ -335,6 +335,7 @@ mod tests {
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
colormap: None,
offset: None,
legacy_solid: false,
overlay: leaves,
@@ -25,7 +25,7 @@
//! cutout ones, so one pass over all of them draws the runs it drew before.
use std::sync::atomic::{AtomicBool, Ordering};
use super::{OPAQUE_LAYER, TerrainVertex};
use super::{OPAQUE_LAYER, TerrainSource, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -247,15 +247,29 @@ fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
}
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
#[cfg(test)]
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
sort_sourced(solid, tints, &mut Vec::new())
}
pub(super) fn sort_sourced(
solid: &mut Vec<TerrainVertex>,
tints: &mut Vec<u32>,
sources: &mut Vec<TerrainSource>,
) -> Sides {
if !sources.is_empty() {
sources.resize(solid.len() / 4, TerrainSource::default());
}
let grouped = GROUPED.load(Ordering::Relaxed);
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, sources, 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, &mut Vec::new(), grouped, scratch))
SORT_SCRATCH.with_borrow_mut(|scratch| {
sort_with(solid, &mut Vec::new(), &mut Vec::new(), grouped, scratch)
})
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
@@ -267,6 +281,7 @@ struct SortScratch {
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
tints: Vec<u32>,
sources: Vec<TerrainSource>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
@@ -281,6 +296,7 @@ fn total_order_key(v: f32) -> u32 {
fn sort_with(
solid: &mut Vec<TerrainVertex>,
tints: &mut Vec<u32>,
sources: &mut Vec<TerrainSource>,
grouped: bool,
scratch: &mut SortScratch,
) -> Sides {
@@ -354,6 +370,13 @@ fn sort_with(
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
std::mem::swap(tints, sorted_tints);
}
if sources.len() == quads_len {
scratch.sources.clear();
scratch
.sources
.extend(keys.iter().map(|&k| sources[(k & 0xFF_FFFF) as usize]));
std::mem::swap(sources, &mut scratch.sources);
}
}
Sides {
ends: slot_ends,
@@ -0,0 +1,304 @@
//! Original synthetic block/model/world controls; no prepared game resources.
use super::*;
use crate::assets::{Properties, SpritePixels};
use horizon_world::registry::BlockRegistry;
use serde_json::json;
use std::{collections::BTreeMap, path::PathBuf};
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
let path = std::env::temp_dir().join(format!(
"terrain-source-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
let write = |name: &str, value: serde_json::Value| {
let file = path.join(name);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, value.to_string()).unwrap();
};
let registry = json!({"defaults":{"minecraft:air":0,"fixture:signal":1},"states":[
["air",true,false,"", "minecraft:air"],
["signal",false,false,"",{"id":"fixture:signal","properties":{"active":"false","waterlogged":"false"}}],
["signal",false,true,"",{"id":"fixture:signal","properties":{"active":"true","waterlogged":"true"}}]
]});
let registry = Box::leak(Box::new(
BlockRegistry::from_facts_json(registry.to_string().as_bytes()).unwrap(),
));
write(
"assets/fixture/blockstates/signal.json",
json!({"multipart":[
{"apply":{"model":"fixture:block/low"}}, {"apply":{"model":"fixture:block/extended"}}
]}),
);
for (name, from, to) in [
("low", [0, 4, 0], [8, 4, 8]),
("extended", [20, 12, 0], [28, 12, 8]),
] {
write(
&format!("assets/fixture/models/block/{name}.json"),
json!({
"textures":{"all":"fixture:block/shared"},
"elements":[{"from":from,"to":to,"faces":{"up":{"texture":"#all"}}}]
}),
);
}
// The same authored sprite is used by every part of both states.
let png_path = path.join("assets/fixture/textures/block/shared.png");
std::fs::create_dir_all(png_path.parent().unwrap()).unwrap();
let encode = |file: PathBuf| {
let mut encoder = png::Encoder::new(std::fs::File::create(file).unwrap(), 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[63, 127, 191, 255])
.unwrap();
};
encode(png_path);
let facts = BTreeMap::from([(
"fixture:signal".into(),
json!({
"schema":{"active":{"named":["false","true"]},"waterlogged":{"named":["false","true"]}},
"states":[{"active":"false","waterlogged":"false"},{"active":"true","waterlogged":"true"}]
}),
)]);
let mut sprites = crate::sprites::Sprites::default();
let catalog = crate::models::StateModels::from_facts(
&path,
registry,
&["fixture:signal"],
&mut sprites,
&facts,
)
.unwrap();
let render = RenderAssets {
sprite_size: 1,
sprite_minimum_size: 1,
pixels: SpritePixels::new(vec![255; 4]),
texture_layers: 1,
model_catalog: catalog,
model_library: Err("unused synthetic item models".into()),
model_placements: vec![],
model_gallery: vec![],
animations: vec![],
sprite_layers: BTreeMap::new(),
emissive: vec![],
translucent_layers: vec![false],
sprite_mips: vec![],
pbr_normals: vec![],
pbr_specular: vec![],
pbr_size: 1,
connected: Default::default(),
fusion: Default::default(),
generation: 1,
};
let assets = Assets {
registry,
properties: Properties::for_tests(&[
(true, false, false, false),
(false, false, false, false),
(false, true, false, false),
]),
material_states: [BlockStateId(0); 6],
blocks_fluid_flow: Default::default(),
};
let air = Info {
solid: false,
full: false,
permeable: true,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: false,
block_entity: false,
tints: [Tint::None; 2],
tint_count: 0,
colormap: None,
offset: None,
legacy_solid: false,
overlay: false,
leaves: false,
pane: false,
opaque_cube: false,
blocks_flow: false,
ice: false,
opaque_ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
};
let info = vec![
air,
Info { model: true, ..air },
Info {
model: true,
emission: 13,
fluid: FluidState::WATER,
..air
},
];
let tables = MeshTables {
info,
solid: vec![false; 3],
full_faces: vec![0; 3],
visibility: FaceVisibility::empty(),
water_still: 0,
water_flow: 0,
water_overlay: 0,
lava_still: 0,
lava_flow: 0,
animated_layers: vec![false],
colormaps: Vec::new(),
water_colormap: None,
};
let maps = path.join("assets/minecraft/textures/colormap");
std::fs::create_dir_all(&maps).unwrap();
for name in ["grass", "foliage", "dry_foliage"] {
encode(maps.join(format!("{name}.png")));
}
write(
"data/minecraft/worldgen/biome/plains.json",
json!({"temperature":0.5,"downfall":0.5,"effects":{"water_color":4159204}}),
);
let biomes = Biomes::load(&path).unwrap();
(Scratch(path), assets, render, tables, biomes)
}
#[test]
fn terrain_source_cpu_multipart_and_waterlogged_faces_keep_their_owner() {
let (_scratch, assets, render, tables, biomes) = fixture();
let mut chunk = Chunk::with_layout(3, -2, crate::chunk::ColumnLayout::new(0, 16).unwrap());
chunk.set(2, 3, 4, 1);
chunk.set(6, 3, 4, 2);
let region = [
[None, None, None],
[None, Some(&chunk), None],
[None, None, None],
];
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
shader_pack: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: FaceVisibility::empty(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mut mesh = mesher.mesh(&region, 0);
assert_eq!(mesh.solid.len(), 16, "two parts per source state");
assert!(
!mesh.translucent.is_empty(),
"waterlogged fluid geometry was emitted"
);
assert_eq!(
mesh.solid_sources,
[
TerrainSource::new(1, [2, 3, 4], 0, false),
TerrainSource::new(1, [2, 3, 4], 0, false),
TerrainSource::new(2, [6, 3, 4], 13, false),
TerrainSource::new(2, [6, 3, 4], 13, false)
]
);
assert_eq!(mesh.translucent_sources.len(), mesh.translucent.len() / 4);
assert!(
mesh.translucent_sources
.iter()
.all(|s| *s == TerrainSource::new(2, [6, 3, 4], 13, true))
);
let before: Vec<_> = mesh
.solid
.as_chunks::<4>()
.0
.iter()
.zip(&mesh.solid_sources)
.map(|(q, s)| (bytemuck::cast_slice::<_, u8>(q).to_vec(), *s))
.collect();
mesh.sort_solid();
for (q, s) in mesh
.solid
.as_chunks::<4>()
.0
.iter()
.zip(&mesh.solid_sources)
{
assert!(
before.contains(&(bytemuck::cast_slice::<_, u8>(q).to_vec(), *s)),
"sort changed face ownership"
);
}
// Reusing the same mesh for native/default rendering clears metadata and keeps
// native model vertices byte-for-byte (fluid splitting already differs with packs).
mesher.shader_pack = false;
let native = mesher.mesh_reusing(&region, 0, mesh, &mut MeshScratch::default());
assert!(native.solid_sources.is_empty() && native.translucent_sources.is_empty());
for q in native.solid.as_chunks::<4>().0.iter() {
assert!(
before
.iter()
.any(|(bytes, _)| bytes == bytemuck::cast_slice::<_, u8>(q)),
"source tracking altered native vertices"
);
}
}
#[test]
fn terrain_source_cpu_partial_metadata_never_inherits_a_previous_face() {
let mut mesh = SectionMesh::default();
let quad = |y| {
[[0.0, y, 0.0], [0.0, y, 1.0], [1.0, y, 1.0], [1.0, y, 0.0]]
.map(|pos| TerrainVertex::new(pos, [0.0; 2], 0, 0, 0))
};
mesh.solid.extend(quad(1.0));
let start = [mesh.solid.len(), 0];
mesh.solid.extend(quad(3.0));
mesh.solid.extend(quad(2.0));
mesh.record_source(start, TerrainSource::new(71, [15, 0, 8], 7, false));
assert_eq!(mesh.solid_sources[0], TerrainSource::default());
assert_eq!(
bytemuck::cast_slice::<_, u32>(&mesh.solid_sources[1..]),
[71, 124943, 71, 124943]
);
mesh.sort_solid();
assert_eq!(
mesh.solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].pos[1])
.collect::<Vec<_>>(),
[3.0, 2.0, 1.0]
);
assert_eq!(
mesh.solid_sources,
[
TerrainSource::new(71, [15, 0, 8], 7, false),
TerrainSource::new(71, [15, 0, 8], 7, false),
TerrainSource::default()
]
);
mesh.clear();
assert!(mesh.solid_sources.is_empty());
}
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
}
}
}
/// A pack root holding `files` (path, bytes), for colormap tests.
fn pack(files: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"horizon-mesher-colormap-{}-{n}",
std::process::id()
));
for (path, bytes) in files {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
root
}
/// A one-row PNG of RGBA pixels.
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels.concat()).unwrap();
writer.finish().unwrap();
out
}
#[test]
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
let biomes = Biomes::from_jsons(&[
(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
),
(
"minecraft:desert",
serde_json::json!({"effects": {"water_color": 0x32A598}}),
),
]);
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
let root = pack(&[
(
"assets/mypack/polytone/colormaps/water.png",
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
),
(
"assets/mypack/polytone/colormaps/water.json",
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
)];
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
tints.colormaps = &colormaps;
tints.water_colormap = Some(0);
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
}
std::fs::remove_dir_all(root).unwrap();
}
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
block_entity: false,
tints: [Tint::None; 2],
tint_count: 0,
colormap: None,
offset: None,
legacy_solid: false,
overlay: false,
+74 -79
View File
@@ -1612,11 +1612,9 @@ pub struct OptionsUi {
selected_native_key: Option<(usize, usize)>,
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
/// The glyphs the binds list shows (the setting, else the pad's family).
pub pad_style: GlyphStyle,
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
/// Nintendo's: each part shows the letter of the button playing it.
pub pad_by_letter: bool,
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
/// pad's letters when it goes by them). The game sets it each frame.
pub pad_glyphs: crate::pad_glyph::Glyphs,
/// The pad's focus drives the page: its hidden pointer sits mid-widget, so a tooltip goes
/// under the widget instead of beside the pointer.
pub pad_driving: bool,
@@ -1660,7 +1658,8 @@ pub struct OptionsUi {
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
pub controller: crate::controller_ui::ControllerUi,
/// Controller Settings, on the settings shell.
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
pub shader: crate::shader_ui::ShaderUi,
@@ -1715,8 +1714,7 @@ impl Default for OptionsUi {
selected_key: None,
selected_native_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
pad_by_letter: false,
pad_glyphs: crate::pad_glyph::Glyphs::default(),
pad_driving: false,
languages: Vec::new(),
language_choice: None,
@@ -2100,7 +2098,6 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::Controller | Page::PadBinds => self.controller.typing(),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
@@ -2212,9 +2209,6 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if let Some(at) = Self::controller_page(page) {
self.controller.open(at);
}
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
@@ -2224,6 +2218,9 @@ impl OptionsUi {
if let Some(at) = Self::graphics_page(page) {
self.graphics.show(at);
}
if let Some(at) = Self::controller_page(page) {
self.controller.show(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2241,6 +2238,7 @@ impl OptionsUi {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
_ => None,
}
}
@@ -2248,6 +2246,7 @@ impl OptionsUi {
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
_ => None,
}
}
@@ -2256,10 +2255,20 @@ impl OptionsUi {
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics screen's vanilla rows that step through its
/// resolutions: before each input that can press one.
fn graphics_framebuffer(&mut self) {
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
@@ -2267,7 +2276,10 @@ impl OptionsUi {
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::BindKey(name) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
@@ -2279,25 +2291,6 @@ impl OptionsUi {
}
events
}
/// What the controller screen's actions do to these options.
fn controller_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::controller_ui::Action>,
) -> Vec<Event> {
use crate::controller_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindPad(action) => self.selected_pad_action = Some(action),
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// What the shader screen's actions do to these options.
fn shader_events(
&mut self,
@@ -3687,16 +3680,11 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.binding = self.selected_pad_action;
self.controller.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.selected_key;
let waiting = self.waiting();
let in_world = self.in_world;
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
@@ -3713,7 +3701,7 @@ impl OptionsUi {
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
.draw(batch, atlas, cursor, self.in_world, self.pad_glyphs);
return;
}
if self.page() == Page::Studio {
@@ -4050,6 +4038,10 @@ impl OptionsUi {
return self.calibration_outcome(s, outcome);
}
if self.shell().is_some() {
// Waiting on a controller button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
return Vec::new();
}
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
@@ -4061,22 +4053,12 @@ impl OptionsUi {
}
let shift = self.shift;
self.framebuffer = framebuffer;
self.graphics_framebuffer();
self.shell_framebuffers();
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
shell.click(s, (x, y), size, drag, right, shift)
});
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
// Waiting on a button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
return Vec::new();
}
let actions =
self.controller
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.controller_events(s, actions);
}
if Self::shader_page(self.page()).is_some() {
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
return self.shader_events(s, actions);
@@ -4349,7 +4331,7 @@ impl OptionsUi {
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider = self.controller.release() | shader_slider | shell_slider;
let graphics_slider = shader_slider | shell_slider;
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4429,7 +4411,7 @@ impl OptionsUi {
if self.resource_error.is_some() {
return Vec::new();
}
self.graphics_framebuffer();
self.shell_framebuffers();
if let Some(shell) = self.shell_mut() {
let outputs = shell.wheel(s, size, cursor, amount);
return self.shell_events(s, outputs);
@@ -4438,10 +4420,6 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if Self::controller_page(self.page()).is_some() {
let actions = self.controller.wheel(s, size, cursor, framebuffer, amount);
return self.controller_events(s, actions);
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
return Vec::new();
@@ -4529,10 +4507,6 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.scroll(s, size, amount);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
return;
@@ -4721,8 +4695,6 @@ impl OptionsUi {
if shell.typing() {
shell.type_text(text);
}
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
self.controller.type_text(text);
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
self.shader.type_text(text);
} else if self.settings_search_active() {
@@ -4759,9 +4731,6 @@ impl OptionsUi {
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::controller_page(self.page()).is_some() {
return self.controller.shortcut(shortcut, clipboard);
}
if Self::shader_page(self.page()).is_some() {
return self.shader.shortcut(shortcut, clipboard);
}
@@ -4881,21 +4850,11 @@ impl OptionsUi {
} else {
MIN_KEY_GUI
};
self.graphics_framebuffer();
self.shell_framebuffers();
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, _) = self.controller.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.controller_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
}
return events;
}
if Self::shader_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
@@ -6644,6 +6603,42 @@ mod tests {
assert_eq!(ui.page(), Page::Horizon);
}
/// A Bindings row pressed waits for a controller button: the next click or key cancels the
/// wait, Escape unbinds the action as on Key Binds, and none of them leaves the screen.
#[test]
fn a_controller_binding_waits_and_a_click_or_a_key_ends_the_wait() {
use crate::controller_ui::Item;
use winit::keyboard::KeyCode as K;
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::PadBinds);
let size = (854.0, 480.0);
ui.size = size;
let jump = Item::Bind("key.jump");
let (x, y) = ui.controller.point_at(&s, size, jump);
let press = |ui: &mut OptionsUi, s: &mut Settings| {
ui.click(s, &atlas, x, y, size, [1708, 960], false, false)
};
assert_eq!(press(&mut ui, &mut s), vec![Event::ButtonClick]);
assert_eq!(ui.selected_pad_action, Some("key.jump"));
assert!(press(&mut ui, &mut s).is_empty());
assert_eq!(ui.selected_pad_action, None);
press(&mut ui, &mut s);
assert_eq!(ui.key(&mut s, &atlas, K::KeyQ, None), vec![Event::Changed]);
assert_eq!(
(ui.selected_pad_action, s.controller.is_default("key.jump")),
(None, true)
);
press(&mut ui, &mut s);
assert_eq!(
ui.key(&mut s, &atlas, K::Escape, None),
vec![Event::Changed]
);
assert_eq!(s.controller.binding("key.jump"), None);
assert_eq!(ui.page(), Page::PadBinds);
}
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
#[test]
+301 -50
View File
@@ -1,8 +1,10 @@
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
//! here; `Button::label` stays for plain text and narration.
use crate::gamepad::{Button, GlyphStyle};
//! here, from a [`Glyph`]: what the button does (`GlyphOf`), in the player's [`Glyphs`]. A Nintendo
//! pad going by its letters is part of `Glyphs`, which only the settings make, so no caller handles
//! the letters, and nothing outside this module can turn a bare button into art or a label.
use crate::gamepad::{Button, GlyphOf, GlyphStyle, MenuJob, PadSettings};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::pad_cursor;
@@ -94,23 +96,167 @@ static ART: &[Art] = art![
Nintendo DPadRight "nx_right",
];
/// The glyphs a player sees: a pad family's art, and whether a Nintendo pad goes by its letters.
/// Opaque: it is only made from the settings and the pad (`of`), or by the one named exception
/// (`unheld`), so nothing can drop the letter setting. Letter mode is part of it, and callers
/// never handle it: the game already reads such a pad as parts (`PadState::by_letter`), and a
/// glyph shows the button that plays its part.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Glyphs {
family: GlyphStyle,
letters: bool,
}
impl Default for Glyphs {
/// The glyphs of the default settings for a pad of no known maker, before any pad is read.
fn default() -> Self {
Self::of(&PadSettings::default(), GlyphStyle::Auto)
}
}
impl Glyphs {
/// The glyphs for a pad of `family` under the player's settings: the family they chose, else
/// the pad's, going by letters when the pad does (`PadSettings::by_letter`) and the glyphs
/// are Nintendo's.
pub fn of(settings: &PadSettings, family: GlyphStyle) -> Self {
let shown = settings.glyphs.resolve(family);
Self {
family: shown,
letters: shown == GlyphStyle::Nintendo && settings.by_letter(family),
}
}
/// The glyphs for a pad of `family` by the buttons' places, whatever the letter setting. Only
/// for a question a controller nobody holds yet answers (splitscreen's join and
/// lost-controller prompts): it isn't playing, so its letters aren't read yet
/// (docs/controllers.md, Nintendo pads).
pub fn unheld(settings: &PadSettings, family: GlyphStyle) -> Self {
Self {
family: settings.glyphs.resolve(family),
letters: false,
}
}
/// The family the art comes from, for naming it ("Glyphs in use").
pub fn family(self) -> GlyphStyle {
self.family
}
/// The family whose print shows `part`, and the button on the pad that plays it: the one
/// place letter mode turns a part into a physical button (`Button::by_letter`, as input is
/// read).
fn on_pad(self, part: Button) -> (GlyphStyle, Button) {
let button = if self.letters { part.by_letter() } else { part };
(self.family, button)
}
}
/// A glyph ready to draw: the part a button plays, in a player's [`Glyphs`]. It is only built from
/// what the button does (`GlyphOf`), so a rebinding or a pad's letters reach every glyph; there is
/// no way to draw or name a bare button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Glyph {
part: Button,
glyphs: Glyphs,
}
impl Glyph {
/// The glyph for `of` on these settings, in `glyphs`. None while a gameplay action is unbound.
pub fn of(of: GlyphOf, settings: &PadSettings, glyphs: Glyphs) -> Option<Self> {
let part = of.button(settings)?;
Some(Self { part, glyphs })
}
/// A menu job's glyph: menus aren't rebindable, so it is always there.
pub fn menu(job: MenuJob, glyphs: Glyphs) -> Self {
Self {
part: job.button(),
glyphs,
}
}
/// The part this glyph is for (what the pad reads, after any letters), for `pad status`.
pub fn part(self) -> Button {
self.part
}
/// What is printed on the button that plays the part: in letter mode, that button's letter.
pub fn label(self) -> &'static str {
let (family, button) = self.glyphs.on_pad(self.part);
printed(button, family)
}
/// The colour (RGB) of the button that plays the part.
pub fn color(self) -> [u8; 3] {
let (family, button) = self.glyphs.on_pad(self.part);
colour(button, family)
}
/// Its art, if the family has a picture of the button.
fn art(self) -> Option<&'static Art> {
let (family, button) = self.glyphs.on_pad(self.part);
find(button, family)
}
}
/// What is printed on a button of a pad family (`Auto` reads as Xbox).
fn printed(button: Button, style: GlyphStyle) -> &'static str {
let i = button as usize;
match style {
GlyphStyle::PlayStation => [
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑", "↓",
"←", "→",
][i],
GlyphStyle::Nintendo => [
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
][i],
GlyphStyle::Xbox | GlyphStyle::Auto => [
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓", "←",
"→",
][i],
}
}
/// The glyph's colour (RGB): face buttons in their family's colours,
/// the rest white.
fn colour(button: Button, style: GlyphStyle) -> [u8; 3] {
let face = match button {
Button::South => 0,
Button::East => 1,
Button::West => 2,
Button::North => 3,
_ => return [255, 255, 255],
};
match style {
GlyphStyle::PlayStation => [
[124, 178, 232],
[235, 110, 110],
[222, 145, 205],
[92, 205, 170],
][face],
GlyphStyle::Nintendo => [255, 255, 255],
GlyphStyle::Xbox | GlyphStyle::Auto => [
[110, 200, 90],
[235, 90, 80],
[80, 150, 240],
[240, 200, 60],
][face],
}
}
/// The picture of a button in a family.
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
let family = match style {
GlyphStyle::Auto => GlyphStyle::Xbox,
style => style,
family => family,
};
ART.iter()
.find(|art| art.family == family && art.button == button)
}
/// The sprite of a button in a family, if it has one.
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
/// The sprite of a button in a family, if it has one: the tests name the files by it.
#[cfg(test)]
fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
find(button, style).map(|art| art.sprite)
}
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
/// The sprite this atlas can draw for the glyph; none (the boot screen's atlas) means text.
pub fn art(atlas: &HudAtlas, glyph: Glyph) -> Option<&'static str> {
glyph
.art()
.map(|art| art.sprite)
.filter(|name| atlas.sprites.contains_key(*name))
}
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
@@ -120,31 +266,24 @@ fn art_width(art: &Art) -> f32 {
texels as f32 / 2.0 - 2.0 * MARGIN
}
/// How wide the glyph shows.
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
/// How wide the glyph shows, in GUI pixels.
pub fn width(atlas: &HudAtlas, glyph: Glyph) -> f32 {
match glyph.art().filter(|a| atlas.sprites.contains_key(a.sprite)) {
Some(art) => art_width(art),
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
None => HudBatch::text_width(atlas, glyph.label()).ceil() + 4.0,
}
}
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]` in GUI pixels.
pub fn rect(atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) -> [f32; 4] {
[x - 1.0, y - 2.0, width(atlas, glyph), HEIGHT]
}
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
/// only: the art brings its own.
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
button: Button,
style: GlyphStyle,
x: f32,
y: f32,
) {
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
if let Some(name) = art(atlas, button, style) {
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) {
let [left, top, wide, tall] = rect(atlas, glyph, x, y);
if let Some(name) = art(atlas, glyph) {
batch.sprite_scaled(
atlas,
name,
@@ -155,16 +294,9 @@ pub fn draw(
u32::MAX,
);
} else {
let [r, g, b] = button.color(style);
let [r, g, b] = glyph.color();
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
batch.text(
atlas,
button.label(style),
x + 1.0,
y,
rgba(r, g, b, 255),
false,
);
batch.text(atlas, glyph.label(), x + 1.0, y, rgba(r, g, b, 255), false);
}
}
@@ -208,6 +340,20 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// Settings as `horizon-controller.txt` has them, with Nintendo Buttons set as `letters` says.
fn settings(letters: bool) -> PadSettings {
PadSettings::parse(&format!("glyphs:auto\nnintendoLetters:{letters}\n"))
}
/// A glyph for any part on a pad of `family` going by places: the tests reach the table
/// directly.
fn g(part: Button, family: GlyphStyle) -> Glyph {
Glyph {
part,
glyphs: Glyphs::of(&settings(false), family),
}
}
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
}
@@ -271,7 +417,9 @@ mod tests {
assert_eq!(file(Button::East, style), Some("nx_a"));
assert_eq!(file(Button::West, style), Some("nx_y"));
assert_eq!(file(Button::North, style), Some("nx_x"));
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
assert_eq!(file(Button::LeftBumper, style), Some("nx_l"));
assert_eq!(file(Button::RightTrigger, style), Some("nx_zr"));
assert_eq!(file(Button::Select, style), Some("nx_minus"));
assert_eq!(file(Button::Start, style), Some("nx_plus"));
}
@@ -283,8 +431,34 @@ mod tests {
(GlyphStyle::Nintendo, "nx_"),
] {
for button in Button::ALL {
let name = file(button, style).unwrap();
assert!(name.starts_with(prefix), "{button:?} {name}");
let name = file(button, style).unwrap_or_else(|| panic!("{button:?}"));
assert!(name.starts_with(prefix), "{name}");
}
}
}
/// Each family's art is its own drawing: Nintendo's once was Xbox's files copied (LB for L, a red
/// B), and View, Create and − were one plain minus.
#[test]
fn no_two_buttons_share_a_picture_across_families() {
let differs = |a: &Art, b: &Art| a.button == b.button && a.family != b.family;
// The sticks and the d-pad are the same part on every pad, so they may share a picture.
let shared = [
Button::LeftStick,
Button::RightStick,
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
];
for (i, art) in ART.iter().enumerate() {
for other in ART[..i].iter().filter(|o| o.png == art.png) {
assert!(
differs(art, other) && shared.contains(&art.button),
"{} is {}",
art.sprite,
other.sprite
);
}
}
}
@@ -319,20 +493,24 @@ mod tests {
#[test]
fn art_measures_from_its_shape_and_text_from_its_label() {
let atlas = atlas();
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), 12.0);
assert_eq!(width(&atlas, g(Button::LeftBumper, GlyphStyle::Xbox)), 20.0);
assert_eq!(
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
width(&atlas, g(Button::LeftBumper, GlyphStyle::PlayStation)),
20.0
);
assert_eq!(width(&atlas, g(Button::DPadUp, GlyphStyle::Nintendo)), 12.0);
assert_eq!(
width(&atlas, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
20.0
);
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
let mut no_art = atlas;
no_art
.sprites
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
assert_eq!(
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
width(&no_art, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
label
);
}
@@ -343,9 +521,9 @@ mod tests {
atlas
.sprites
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
assert_eq!(art(&atlas, g(Button::South, GlyphStyle::Xbox)), None);
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), label);
}
#[test]
@@ -355,8 +533,7 @@ mod tests {
draw(
&mut batch,
&atlas,
Button::South,
GlyphStyle::Xbox,
g(Button::South, GlyphStyle::Xbox),
10.0,
20.0,
);
@@ -365,8 +542,7 @@ mod tests {
draw(
&mut batch,
&atlas,
Button::LeftBumper,
GlyphStyle::Xbox,
g(Button::LeftBumper, GlyphStyle::Xbox),
10.0,
20.0,
);
@@ -379,8 +555,83 @@ mod tests {
let style = GlyphStyle::Nintendo;
atlas.sprites.remove(sprite(Button::South, style).unwrap());
let mut batch = HudBatch::new([640, 480]);
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
draw(&mut batch, &atlas, g(Button::South, style), 10.0, 20.0);
let [x, y, w, h] = rect(&atlas, g(Button::South, style), 10.0, 20.0);
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
}
/// The part a pad going by its letters reads when `button` is pressed (`PadState::by_letter`,
/// as `Pads` reads the pad), so the expectation doesn't come from the glyph code.
fn part_read_from(button: Button) -> Button {
let mut pad = crate::gamepad::PadState::default();
pad.set(button, true);
let read = pad.by_letter();
Button::ALL.into_iter().find(|b| read.held(*b)).unwrap()
}
#[test]
fn letter_mode_shows_the_button_that_plays_each_part() {
let atlas = atlas();
let letters = Glyphs::of(&settings(true), GlyphStyle::Nintendo);
for button in Button::ALL {
let shown = Glyph {
part: part_read_from(button),
glyphs: letters,
};
let printed = g(button, GlyphStyle::Nintendo);
assert_eq!(shown.label(), printed.label(), "{button:?}");
assert_eq!(art(&atlas, shown), art(&atlas, printed), "{button:?}");
}
// As a Switch: the A on the right confirms and jumps, B goes back, X and Y swap.
let label = |job| Glyph::menu(job, letters).label();
assert_eq!(label(MenuJob::Confirm), "A");
assert_eq!(label(MenuJob::Back), "B");
assert_eq!(label(MenuJob::Alternate), "X");
assert_eq!(label(MenuJob::Extra), "Y");
let confirm = art(&atlas, Glyph::menu(MenuJob::Confirm, letters));
assert_eq!(confirm, Some("horizon:gui/controller/nx_a"));
}
#[test]
fn only_a_nintendo_pad_with_nintendo_glyphs_goes_by_letters() {
let label = |settings: &PadSettings, family| {
Glyph::menu(MenuJob::Confirm, Glyphs::of(settings, family)).label()
};
// By places, a Nintendo pad's confirm is the B at the bottom.
assert_eq!(label(&settings(false), GlyphStyle::Nintendo), "B");
// An Xbox pad has no letters to swap.
assert_eq!(label(&settings(true), GlyphStyle::Xbox), "A");
// A player who chose Xbox glyphs sees them, though the Nintendo pad is read by letters.
let chosen = PadSettings::parse("glyphs:xbox\nnintendoLetters:true\n");
assert_eq!(label(&chosen, GlyphStyle::Nintendo), "A");
// A controller nobody holds yet is asked by places.
let unheld = Glyphs::unheld(&settings(true), GlyphStyle::Nintendo);
assert_eq!(Glyph::menu(MenuJob::Confirm, unheld).label(), "B");
}
#[test]
fn position_mode_names_each_part_by_its_place() {
assert_eq!(g(Button::South, GlyphStyle::Nintendo).label(), "B");
assert_eq!(g(Button::South, GlyphStyle::Xbox).label(), "A");
assert_eq!(g(Button::South, GlyphStyle::PlayStation).label(), "×");
assert_eq!(
g(Button::RightTrigger, GlyphStyle::PlayStation).label(),
"R2"
);
assert_eq!(g(Button::DPadLeft, GlyphStyle::Xbox).label(), "←");
}
#[test]
fn rebinding_an_action_changes_its_glyph() {
let mut settings = settings(false);
let xbox = Glyphs::of(&settings, GlyphStyle::Xbox);
let jump = GlyphOf::Bound("key.jump");
let before = Glyph::of(jump, &settings, xbox).unwrap();
settings.bind("key.jump", Some(Button::North));
let after = Glyph::of(jump, &settings, xbox).unwrap();
assert_ne!(before.label(), after.label());
assert_eq!(after.label(), "Y");
settings.bind("key.jump", None);
assert_eq!(Glyph::of(jump, &settings, xbox), None);
}
}
+84 -4
View File
@@ -29,6 +29,7 @@ pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub mod polytone;
mod polytone_gpu;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -528,6 +529,10 @@ pub struct Particle {
controller: Option<Box<dyn native::Controller>>,
/// A resource pack's Polytone custom particle type, for its `limit`.
polytone_type: Option<std::sync::Arc<str>>,
/// How a Polytone custom particle's quad turns (`rotation_mode`).
orientation: polytone_gpu::Orientation,
/// Blended additively (a Polytone `additive_translucent` particle).
additive: bool,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -890,6 +895,8 @@ impl Particle {
random: LegacyRandom::new(0),
controller: None,
polytone_type: None,
orientation: polytone_gpu::Orientation::Camera,
additive: false,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
@@ -1286,6 +1293,11 @@ pub struct ParticleEngine {
set_sprites: Vec<Vec<u32>>,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
polytone_gpu: Vec<polytone_gpu::Store>,
polytone_ticks: u64,
/// The last frame's additively blended quads (`take_additive`).
additive: std::sync::Mutex<Vec<Vertex>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
/// Camera position for the 32-block spawn limiter.
@@ -1479,6 +1491,9 @@ impl ParticleEngine {
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
polytone: None,
polytone_gpu: Vec::new(),
polytone_ticks: 0,
additive: std::sync::Mutex::default(),
emitters: Vec::new(),
starters: Vec::new(),
tracked_explosions: Vec::new(),
@@ -1701,7 +1716,25 @@ impl ParticleEngine {
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
self.polytone = registry.filter(|r| !r.is_empty());
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
}
self.polytone = registry;
}
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
/// one), for the frame's additive pass.
pub fn take_additive(&self) -> Vec<Vertex> {
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
}
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
pub fn polytone_gpu_records(&self) -> usize {
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
@@ -2110,7 +2143,22 @@ impl ParticleEngine {
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
return polytone::make(self, &registry.clone(), name, &def.clone(), o, p, v, seed);
let (registry, def) = (registry.clone(), def.clone());
let particle = polytone::make(self, &registry, name, &def, o, p, v, seed)?;
if def.gpu.is_some() {
// A GPU particle is only a record: it never becomes an engine particle.
let now = self.polytone_ticks;
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
Some(i) => &mut self.polytone_gpu[i],
None => {
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
self.polytone_gpu.last_mut()?
}
};
store.spawn(&particle, now, seed as u64);
return None;
}
return Some(particle);
}
if let Some(name) = &o.mod_particle {
let (set, behaviour) = *self.mod_sets.get(name)?;
@@ -3746,6 +3794,14 @@ impl ParticleEngine {
}
}
pub fn tick(&mut self, level: &ParticleLevel) {
self.polytone_ticks += 1;
for store in &mut self.polytone_gpu {
store.expire(self.polytone_ticks);
}
// The sounds the packs' custom particles played last tick.
if let Some(registry) = &self.polytone {
self.sounds.extend(registry.take_sounds());
}
self.tick_starters();
self.tick_explosions(level);
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
@@ -4738,6 +4794,8 @@ impl ParticleEngine {
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 additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
for p in &self.particles {
@@ -4809,7 +4867,15 @@ impl ParticleEngine {
) - camera;
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.roll != 0.0 {
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
let velocity = FVec3::new(
p.velocity[0] as f32,
p.velocity[1] as f32,
p.velocity[2] as f32,
);
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
} else if p.roll != 0.0 {
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
} else {
base
@@ -4891,6 +4957,7 @@ impl ParticleEngine {
r.u0 + du * p.uv[2],
r.v0 + dv * p.uv[3],
);
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
for rotation in rotations.into_iter().flatten() {
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
@@ -4899,7 +4966,7 @@ impl ParticleEngine {
((-1.0, -1.0), [u0, v1]),
] {
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
out.push(Vertex {
target.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
@@ -4916,6 +4983,19 @@ impl ParticleEngine {
}
}
}
for store in &self.polytone_gpu {
store.render(
&self.atlas,
camera,
base,
self.polytone_ticks,
partial,
&mut light_cache,
light_at,
out,
&mut additive,
);
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
@@ -8,11 +8,11 @@ use super::{
ParticleUpdate, Sprite,
};
use crate::environment::Frame;
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_polytone::expr::{Host, Program, Root, Value as V};
use horizon_polytone::particles::{
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
SoundEmitter,
};
use horizon_world::registry::BlockRegistry;
use serde_json::Value as Json;
@@ -35,7 +35,82 @@ pub struct Registry {
pub frame: Arc<RwLock<Frame>>,
configs: horizon_polytone::pack::Configs,
blocks: &'static BlockRegistry,
noise: SimplexNoise,
/// Each custom particle type's colormap (`colormap`), by type id.
tints: HashMap<String, Arc<Tint>>,
/// Sound ids as the engine's sound queue holds them, interned once.
sound_names: Mutex<HashMap<String, &'static str>>,
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
sounds: Mutex<Vec<Sound>>,
}
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
type Sound = (&'static str, [f64; 3], f32, f32, bool);
/// Where a particle's colormap colour comes from.
enum TintSource {
/// A vanilla biome colour (`grass_color`, `foliage_color`, `dry_foliage_color`,
/// `water_color`): the biome's own, as blocks get it.
Biome(fn(&crate::biome::BiomeInfo) -> u32),
/// A pack's colormap (`polytone/colormaps`), its axes run against the particle.
Pack(crate::environment::Colormap),
}
/// When a particle's colormap is sampled again (`cache`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum TintCache {
EveryTick,
PerPosition,
OnSpawn,
}
/// A custom particle type's colormap: multiplied into its colour.
struct Tint {
source: TintSource,
cache: TintCache,
}
impl Tint {
/// `colormap`: an id, or `{"colormap": id, "cache": ...}`. Inline colormaps aren't read.
fn read(pack_root: &Path, json: &Json) -> Option<Self> {
let (id, cache) = match json {
Json::String(id) => (id.as_str(), TintCache::EveryTick),
Json::Object(o) => (
o.get("colormap")?.as_str()?,
match o.get("cache").and_then(Json::as_str) {
Some("per_position") => TintCache::PerPosition,
Some("on_spawn") => TintCache::OnSpawn,
_ => TintCache::EveryTick,
},
),
_ => return None,
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let source = match id.as_str() {
"minecraft:grass_color" => TintSource::Biome(|b| b.grass),
"minecraft:foliage_color" => TintSource::Biome(|b| b.foliage),
"minecraft:dry_foliage_color" => TintSource::Biome(|b| b.dry_foliage),
"minecraft:water_color" => TintSource::Biome(|b| b.water),
_ => TintSource::Pack(crate::environment::Colormap::load(pack_root, &id)?),
};
Some(Self { source, cache })
}
/// The colour (0 to 1 per channel) at the particle in `ctx`; `None` keeps its own.
fn sample(&self, ctx: &mut Ctx) -> Option<[f32; 3]> {
let rgb = match &self.source {
TintSource::Biome(colour) => {
let at = ParticleLevel::block_at(ctx.position());
let info = ctx.biome_info(at)?;
colour(&info)
}
TintSource::Pack(colormap) => colormap.sample(ctx)?,
};
Some([16, 8, 0].map(|shift| ((rgb >> shift) & 0xff) as f32 / 255.0))
}
}
/// Every `.json` under `dir`, with its path relative to `root`.
@@ -104,7 +179,7 @@ impl Registry {
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
match BlockEmitters::parse(&json, implicit.as_deref()) {
Ok(Some(m)) if !m.particle_emitters.is_empty() => modifiers.push(Arc::new(m)),
Ok(Some(m)) => modifiers.push(Arc::new(m)),
Ok(_) => {}
Err(e) => log::warn!("{relative}: {e}"),
}
@@ -137,6 +212,19 @@ impl Registry {
.clone()
})
.collect();
let tints = types
.iter()
.filter_map(|(id, def)| {
let json = def.render.get("colormap")?;
match Tint::read(pack_root, json) {
Some(tint) => Some((id.clone(), Arc::new(tint))),
None => {
log::warn!("custom particle {id}: colormap {json} not found");
None
}
}
})
.collect();
Self {
types,
block_emitters,
@@ -146,7 +234,9 @@ impl Registry {
frame: Arc::new(RwLock::new(Frame::default())),
configs,
blocks,
noise: SimplexNoise::new(&mut LegacyRandom::new(0)),
tints,
sound_names: Mutex::default(),
sounds: Mutex::default(),
}
}
@@ -156,6 +246,22 @@ impl Registry {
&& self.entity_modifiers.is_empty()
}
/// A sound id as the engine's sound queue holds it.
fn sound_name(&self, id: &str) -> &'static str {
let mut names = self.sound_names.lock().unwrap_or_else(|e| e.into_inner());
if let Some(name) = names.get(id) {
return name;
}
let name: &'static str = Box::leak(id.to_owned().into_boxed_str());
names.insert(id.to_owned(), name);
name
}
/// The sounds custom particles played since the last call.
pub(super) fn take_sounds(&self) -> Vec<Sound> {
std::mem::take(&mut *self.sounds.lock().unwrap_or_else(|e| e.into_inner()))
}
pub fn get(&self, id: &str) -> Option<&Arc<CustomParticle>> {
self.types.get(id)
}
@@ -219,6 +325,54 @@ struct Controller {
ticks: u32,
/// Sprites in its set, for the age-driven frame.
frames: usize,
/// Its colormap, and what the last sample left.
tint: Option<Arc<Tint>>,
tinting: Tinting,
}
/// A particle's colormap state: its colour before the colormap, the block it was last
/// sampled at, and that sample.
#[derive(Clone, Copy, Debug)]
struct Tinting {
base: Option<[f32; 3]>,
at: Option<[i32; 3]>,
rgb: [f32; 3],
}
impl Default for Tinting {
fn default() -> Self {
Self {
base: None,
at: None,
rgb: [1.0; 3],
}
}
}
impl Tinting {
/// Samples the colormap as its `cache` says and multiplies it into the colour.
fn apply(&mut self, tint: &Tint, ctx: &mut Ctx) {
let Subject::Particle(p) = &ctx.subject else {
return;
};
let base = [p.rgba[0], p.rgba[1], p.rgba[2]];
let at = ParticleLevel::block_at(p.position);
let resample = match tint.cache {
TintCache::EveryTick => true,
TintCache::PerPosition => self.at != Some(at),
TintCache::OnSpawn => self.at.is_none(),
};
if resample {
self.at = Some(at);
self.rgb = tint.sample(ctx).unwrap_or([1.0; 3]);
}
self.base = Some(base);
if let Subject::Particle(p) = &mut ctx.subject {
for i in 0..3 {
p.rgba[i] = base[i] * self.rgb[i];
}
}
}
}
impl std::fmt::Debug for Controller {
@@ -280,6 +434,13 @@ impl Ctx<'_> {
Subject::Entity(at) => *at,
}
}
/// The biome at a block, when the level and biomes are known.
fn biome_info(&self, at: [i32; 3]) -> Option<crate::biome::BiomeInfo> {
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
let id = chunk.biome(at[0], at[1], at[2]);
let biomes = self.registry.biomes.read().ok()?;
Some(biomes.as_ref()?.get(id).clone())
}
/// The biome at a block (`minecraft:plains`), when the level and biomes are known.
fn biome(&self, at: [i32; 3]) -> Option<String> {
let chunk = self.level?.world.chunks.get(&(at[0] >> 4, at[2] >> 4))?;
@@ -300,11 +461,18 @@ impl Ctx<'_> {
if name == "biome" {
return Ok(V::Str(self.biome(at).unwrap_or_default().into()));
}
// The biome's climate where it is known, else the camera's.
if name == "temperature" {
return num(f64::from(self.frame.temperature));
let t = self
.biome_info(at)
.map_or(self.frame.temperature, |b| b.temperature);
return num(f64::from(t));
}
if name == "downfall" {
return num(f64::from(self.frame.downfall));
let d = self
.biome_info(at)
.map_or(self.frame.downfall, |b| b.downfall);
return num(f64::from(d));
}
let arg = |i: usize| args.get(i).map_or(0.0, V::num) as i32;
let neighbour = match name {
@@ -517,7 +685,7 @@ impl Host for Ctx<'_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(self.registry.noise.get2(x, y)))
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -709,6 +877,45 @@ fn emit(emitters: &[Emitter], ctx: &mut Ctx, out: &mut Vec<(ParticleOptions, [f6
}
}
/// Runs sound emitters from the subject: each plays when its `chance` beats a random draw, at
/// its position relative to the subject (a block's centre).
fn emit_sounds(emitters: &[SoundEmitter], ctx: &mut Ctx, out: &mut Vec<Sound>) {
for emitter in emitters {
let chance = emitter
.fields
.get("chance")
.map_or(Some(1.0), |c| eval(c, ctx))
.unwrap_or(0.0);
if ctx.rand() >= chance {
continue;
}
let field = |ctx: &mut Ctx, key: &str| emitter.fields.get(key).and_then(|p| eval(p, ctx));
let base = match &ctx.subject {
Subject::Block(b) => b.map(|c| f64::from(c) + 0.5),
_ => ctx.position(),
};
if let Some(biomes) = &emitter.biomes
&& !biome_matches(biomes, ctx, ParticleLevel::block_at(base))
{
continue;
}
let at = [
base[0] + field(ctx, "x").unwrap_or(0.0),
base[1] + field(ctx, "y").unwrap_or(0.0),
base[2] + field(ctx, "z").unwrap_or(0.0),
];
if let Some(predicate) = &emitter.state_predicate
&& !state_matches(predicate, ctx, ParticleLevel::block_at(at))
{
continue;
}
let volume = field(ctx, "volume").unwrap_or(1.0) as f32;
let pitch = field(ctx, "pitch").unwrap_or(1.0) as f32;
let name = ctx.registry.sound_name(&emitter.sound);
out.push((name, at, volume, pitch, emitter.distance_delay));
}
}
/// Sets each `keys` field the program map holds on `p` through `apply`.
fn run_fields(
programs: &BTreeMap<String, Program>,
@@ -782,6 +989,10 @@ impl super::ParticleController for Controller {
let Ok(frame) = registry.frame.read() else {
return update;
};
// Expressions see the colour before the colormap.
if let Some(base) = self.tinting.base {
p.rgba[..3].copy_from_slice(&base);
}
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
let mut ctx = Ctx {
subject: Subject::Particle(p),
@@ -811,6 +1022,18 @@ impl super::ParticleController for Controller {
}
}
emit(&def.emitters, &mut ctx, &mut update.spawned);
if !def.sound_emitters.is_empty() {
let mut sounds = Vec::new();
emit_sounds(&def.sound_emitters, &mut ctx, &mut sounds);
registry
.sounds
.lock()
.unwrap_or_else(|e| e.into_inner())
.extend(sounds);
}
if let Some(tint) = self.tint.clone() {
self.tinting.apply(&tint, &mut ctx);
}
}
drop(ctx);
p.random = random;
@@ -867,12 +1090,11 @@ pub(super) fn make(
velocity: [f64; 3],
seed: i64,
) -> Option<Particle> {
// GPU particles (`"type": "gpu"`) are a renderer of their own, not ported yet.
if def.kind == horizon_polytone::particles::ParticleKind::Gpu {
return None;
}
// `limit`: at most that many of the type alive at once.
if let Some(limit) = def.limit {
// (A GPU particle's `limit` is its ring of records instead.)
if let Some(limit) = def.limit
&& def.gpu.is_none()
{
let alive = engine
.particles
.iter()
@@ -889,8 +1111,11 @@ pub(super) fn make(
particle.gravity = 0.0;
particle.has_physics = def.has_physics;
let mut frames = 0;
// A model particle (`model`) isn't drawn yet: it still ticks and emits.
let hidden = def.render_type == RenderType::Invisible || def.render.contains_key("model");
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
// drawn from its record instead (`polytone_gpu`).
let hidden = def.render_type == RenderType::Invisible
|| def.render.contains_key("model")
|| def.gpu.is_some();
match engine.mod_sets.get(name) {
Some(&(set, _)) => {
let sprites = engine.set_sprites.get(set as usize)?;
@@ -907,6 +1132,8 @@ pub(super) fn make(
None => return None,
}
particle.no_render = hidden;
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
particle.behaviour = Behaviour::Controlled {
translucent: def.render_type != RenderType::Opaque,
};
@@ -952,6 +1179,8 @@ pub(super) fn make(
customs,
ticks: 0,
frames,
tint: registry.tints.get(name).cloned(),
tinting: Tinting::default(),
}));
Some(particle)
}
@@ -1025,14 +1254,17 @@ impl ParticleEngine {
level: Some(level),
blocks: Vec::new(),
};
let mut sounds = Vec::new();
for modifier in modifiers.iter() {
emit(&modifier.particle_emitters, &mut ctx, &mut out);
emit_sounds(&modifier.sound_emitters, &mut ctx, &mut sounds);
}
drop(ctx);
drop(frame);
for (options, position, velocity) in out {
self.add(&options, position, velocity);
}
self.sounds.extend(sounds);
}
}
@@ -1041,6 +1273,7 @@ mod tests {
use super::*;
use crate::chunk::Chunk;
use crate::world::World;
use glam::DVec3;
use horizon_world::DATA_VERSION;
use horizon_world::block_collision::BlockCollisionRegistry;
use horizon_world::collision::CachedCollisionRegistry;
@@ -1263,4 +1496,135 @@ mod tests {
assert_eq!(engine.pending.len(), 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/rain.json",
serde_json::json!({
"type": "gpu", "texture": "test:textures/particle/rain.png",
"render_type": "translucent", "rotation_mode": "movement_aligned",
"initializer": {"lifetime": "3", "size": "0.5"},
"gravity": 0.1, "area": {"size": [4, 4, 4], "count": 3}
}),
),
(
// Additive: drawn in the frame's additive pass instead.
"assets/test/polytone/custom_particles/glow.json",
serde_json::json!({
"type": "gpu", "texture": "test:textures/particle/rain.png",
"render_type": "additive_translucent", "initializer": {"lifetime": "3"}
}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:rain"}, {"particle": "test:glow"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine
.atlas
.insert("test:particle/rain".into(), (rect, true));
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
// No engine particle: a record each; the rain drawn as its area's three quads.
assert!(engine.pending.is_empty());
assert_eq!(engine.polytone_gpu_records(), 2);
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 70.0, 1.0),
0.0,
0.0,
0.0,
&|_| 0xF0,
&mut out,
);
assert_eq!(out.len(), 3 * 4);
assert_eq!(engine.take_additive().len(), 4);
assert!(
out.iter()
.all(|v| v.layer == crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY)
);
for _ in 0..3 {
engine.tick(&level);
}
assert_eq!(
engine.polytone_gpu_records(),
0,
"gone once its 3 ticks are over"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_colormap_tints_the_particle_and_sound_emitters_play() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/leaf.json",
serde_json::json!({
"has_physics": false, "colormap": "minecraft:foliage_color",
"initializer": {"red": "1", "green": "0.5", "blue": "1"},
"sound_emitters": [{"sound": "block.note_block.harp", "chance": "1", "volume": "0.5"}]
}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:leaf"}]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
registry.set_biomes(&Arc::new(crate::biome::Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"foliage_color": 0x336699}}),
)])));
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.mod_sets.insert("test:leaf".into(), (0, None));
engine.set_sprites.push(vec![10]);
engine.camera = [1.0, 65.0, 1.0];
engine.set_polytone(Some(registry.clone()));
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut p = engine.pending.pop().unwrap();
let tinted = [
0x33 as f32 / 255.0,
0.5 * 0x66 as f32 / 255.0,
0x99 as f32 / 255.0,
];
for _ in 0..2 {
engine.tick_particle(&mut p, &level);
// Multiplied into its own colour, every tick, without compounding.
assert_eq!(&p.rgba[..3], &tinted);
}
let sounds = registry.take_sounds();
assert_eq!(sounds.len(), 2);
assert_eq!(
(sounds[0].0, sounds[0].2, sounds[0].3),
("minecraft:block.note_block.harp", 0.5, 1.0)
);
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -0,0 +1,408 @@
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
//! how big, what colour and which frame are a function of that record and the time. Here the
//! function runs when the frame's particle quads are built, beside the engine's own particles.
use super::{ParticleLevel, ParticleLightCache};
use crate::hud::Rect;
use crate::mesher::Vertex;
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
use std::collections::HashMap;
use std::sync::Arc;
/// How a particle quad turns (`rotation_mode`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum Orientation {
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
#[default]
Camera,
/// `look_at_y`: turns around Y only.
AroundY,
/// `look_up`: lies flat, facing up.
Flat,
/// `movement_aligned`: stands along its velocity, facing the camera around it.
Movement,
}
impl Orientation {
pub(super) fn parse(mode: &str) -> Self {
match mode {
"look_at_y" => Self::AroundY,
"look_up" => Self::Flat,
"movement_aligned" => Self::Movement,
_ => Self::Camera,
}
}
}
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
pub(super) fn orient(
mode: Orientation,
center: FVec3,
velocity: FVec3,
roll: f32,
camera: Quat,
) -> Quat {
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
match mode {
Orientation::Camera => rolled(camera),
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
Orientation::Movement => {
let up = velocity.normalize_or_zero();
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
if right == FVec3::ZERO {
return rolled(camera);
}
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
}
}
}
/// One GPU particle spawn.
#[derive(Clone, Copy, Debug)]
struct Record {
position: [f64; 3],
velocity: [f64; 3],
/// The engine's tick it spawned on, and its life in ticks.
spawn: u64,
lifetime: f32,
seed: u64,
rgba: [f32; 4],
size: f32,
roll: f32,
}
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
pub(super) struct Store {
def: Arc<CustomParticle>,
gpu: Gpu,
orientation: Orientation,
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
sprite: String,
records: Vec<Record>,
next: usize,
limit: usize,
}
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
/// (`particle/a/b` in `minecraft`).
fn sprite_name(texture: &str) -> String {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let path = path
.trim_start_matches("textures/")
.trim_end_matches(".png");
if namespace == "minecraft" {
path.to_owned()
} else {
format!("{namespace}:{path}")
}
}
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
fn mix(seed: u64, i: u64) -> u64 {
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number in 0..1 from `mix`.
fn unit(seed: u64, i: u64) -> f64 {
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
}
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
-gpu.friction.ln()
} else {
0.0
};
let gravity = [0.0, -gpu.gravity, 0.0];
let mut at = [0.0; 3];
let mut moving = [0.0; 3];
for i in 0..3 {
let (v, g) = (velocity[i], gravity[i]);
if drag > 1.0e-9 {
let e = (-drag * t).exp();
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
moving[i] = v * e + g * (1.0 - e) / drag;
} else {
at[i] = position[i] + v * t + 0.5 * g * t * t;
moving[i] = v + g * t;
}
}
(at, moving)
}
impl Store {
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
let gpu = def.gpu.clone()?;
Some(Self {
def: def.clone(),
orientation: Orientation::parse(&def.rotation_mode),
sprite: sprite_name(&gpu.texture),
gpu,
records: Vec::new(),
next: 0,
// The wiki's default ring capacity.
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
})
}
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
Arc::ptr_eq(&self.def, def)
}
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
if p.lifetime <= 0 {
return;
}
let record = Record {
position: p.position,
velocity: p.velocity,
spawn: now,
lifetime: p.lifetime as f32,
seed,
rgba: p.rgba,
size: p.size,
roll: p.roll,
};
if self.records.len() < self.limit {
self.records.push(record);
} else {
self.records[self.next] = record;
self.next = (self.next + 1) % self.limit;
}
}
/// Forgets the records whose life is over.
pub(super) fn expire(&mut self, now: u64) {
let before = self.records.len();
self.records
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
if self.records.len() != before {
self.next = 0;
}
}
pub(super) fn len(&self) -> usize {
self.records.len()
}
/// The quads of every live record at `now + partial` ticks.
#[allow(clippy::too_many_arguments)]
pub(super) fn render(
&self,
atlas: &HashMap<String, (Rect, bool)>,
camera: DVec3,
facing: Quat,
now: u64,
partial: f32,
light_cache: &mut ParticleLightCache,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
additive: &mut Vec<Vertex>,
) {
if self.def.render_type == RenderType::Invisible {
return;
}
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
return;
};
let gpu = &self.gpu;
let translucent = matches!(
self.def.render_type,
RenderType::Translucent | RenderType::AdditiveTranslucent
);
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
let target = if blend_add { additive } else { out };
let layer = if translucent && !blend_add {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
};
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
let frames = gpu.frames.max(1);
for r in &self.records {
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
if t >= r.lifetime {
continue;
}
let life = (t / r.lifetime).clamp(0.0, 1.0);
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
// Fading in and out, and the colour and size toward their ends.
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
let mut alpha = r.rgba[3];
if fade_in > 0.0 {
alpha *= (life / fade_in).min(1.0);
}
if fade_out > 0.0 {
alpha *= ((1.0 - life) / fade_out).min(1.0);
}
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
if let Some(end) = gpu.color_end {
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
let to = [channel(16), channel(8), channel(0)];
for i in 0..3 {
rgb[i] += (to[i] - rgb[i]) * life;
}
alpha += (alpha * channel(24) - alpha) * life;
}
let size = gpu
.size_end
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
let frame = if self.def.random_sprite {
(mix(r.seed, 0) % u64::from(frames)) as u32
} else {
((life * frames as f32) as u32).min(frames - 1)
};
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
let (u0, u1) = (rect.u0, rect.u1);
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
for quad in 0..u64::from(count) {
// Each quad of an area has its own place in the box, sway phase and roll.
let offset: [f64; 3] = if count > 1 {
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
} else {
[0.0; 3]
};
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
let world = [
at[0] + offset[0] + sway,
at[1] + offset[1],
at[2] + offset[2] + sway_z,
];
let block = ParticleLevel::block_at(world);
let packed = light_cache.get(block, light_at);
// kill_below_heightmap: under the surface, where the sky doesn't reach.
if gpu.kill_below_heightmap && packed >> 4 < 15 {
continue;
}
let block_light = u32::from((packed & 15).max(self.def.light_level));
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
let center = (DVec3::from_array(world) - camera).as_vec3();
let roll = r.roll
+ gpu.spin as f32 * t
+ if count > 1 {
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
} else {
0.0
};
let rotation = orient(self.orientation, center, velocity, roll, facing);
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
((1.0, 1.0), [u1, v0]),
((-1.0, 1.0), [u0, v0]),
((-1.0, -1.0), [u0, v1]),
] {
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
target.push(Vertex {
normal: [0; 4],
pos: (center + rotation * corner).to_array(),
uv,
layer,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gpu(gravity: f64, friction: f64) -> Gpu {
Gpu {
texture: String::new(),
frames: 1,
gravity,
friction,
size_end: None,
color_end: None,
fade: [0.0; 2],
sway: 0.0,
spin: 0.0,
aspect: 1.0,
area: None,
kill_below_heightmap: false,
}
}
#[test]
fn texture_paths_name_their_atlas_sprites() {
assert_eq!(
sprite_name("davidparticles:textures/particle/weather/rain.png"),
"davidparticles:particle/weather/rain"
);
assert_eq!(
sprite_name("minecraft:textures/particle/generic_7.png"),
"particle/generic_7"
);
}
#[test]
fn motion_follows_gravity_and_drag() {
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
assert_eq!(at, [10.0, -5.0, 0.0]);
assert!((v[1] + 1.0).abs() < 1e-12);
// Drag: the fall speed tends to g / drag (terminal velocity).
let friction = 0.9;
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
}
#[test]
fn orientations_turn_the_quad_normal() {
let normal = |q: Quat| q * FVec3::Z;
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
// Lying flat: facing up.
assert!(close(
normal(orient(
Orientation::Flat,
FVec3::ONE,
FVec3::ZERO,
0.0,
Quat::IDENTITY
)),
FVec3::Y
));
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
assert!(close(
normal(orient(
Orientation::AroundY,
FVec3::new(5.0, 3.0, 0.0),
FVec3::ZERO,
0.0,
Quat::IDENTITY
)),
-FVec3::X
));
// Along its motion: its up is the velocity, its face toward the camera.
let q = orient(
Orientation::Movement,
FVec3::new(0.0, 0.0, -4.0),
FVec3::new(0.0, -2.0, 0.0),
0.0,
Quat::IDENTITY,
);
assert!(close(q * FVec3::Y, -FVec3::Y));
assert!(close(normal(q), FVec3::Z));
}
}
+95 -3
View File
@@ -13,7 +13,10 @@ use crate::{
text::{Style, Text},
ui::nine_slice,
};
use std::{sync::Mutex, time::Instant};
use std::{
sync::Mutex,
time::{Duration, Instant},
};
/// `PreeditEvent`: the composition, its caret and the blocks of text the
/// input method set apart (the one being converted is `focused_block`).
@@ -122,16 +125,23 @@ struct Caret {
input_height: f32,
}
/// Grace window used to swallow a trailing `KeyboardInput` (such as `Enter` or
/// `Escape`) emitted by the OS/windowing backend in the same key press right
/// after `Ime::Preedit("", None)` or `Ime::Commit(...)` finishes composition.
const POST_COMPOSITION_KEY_GRACE: Duration = Duration::from_millis(50);
#[derive(Default)]
struct State {
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
preedit: Option<(PreeditEvent, Instant)>,
last_ended: Option<Instant>,
caret: Option<Caret>,
area: Option<[f32; 4]>,
}
static STATE: Mutex<State> = Mutex::new(State {
preedit: None,
last_ended: None,
caret: None,
area: None,
});
@@ -141,10 +151,32 @@ fn state() -> std::sync::MutexGuard<'static, State> {
}
/// `KeyboardHandler.textEditing`: the input method's composition changed
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
/// (`None` or an empty string: it ended). A new `IMEPreeditOverlay` restarts
/// the caret blink.
pub fn set_preedit(event: Option<PreeditEvent>) {
let mut s = state();
s.preedit = event.map(|e| (e, Instant::now()));
let now = Instant::now();
let event = event.filter(|e| !e.full_text.is_empty());
if s.preedit.is_some() && event.is_none() {
s.last_ended = Some(now);
}
s.preedit = event.map(|e| (e, now));
}
/// Record that an IME commit just occurred, clearing any active preedit and
/// arming the post-composition key suppression window.
pub fn mark_committed() {
let mut s = state();
s.preedit = None;
s.last_ended = Some(Instant::now());
}
/// Clear both active preedit and any pending post-composition suppression window
/// (used when the text field itself loses focus or IME is disabled).
pub fn reset() {
let mut s = state();
s.preedit = None;
s.last_ended = None;
}
/// Whether text is being composed.
@@ -152,6 +184,22 @@ pub fn composing() -> bool {
state().preedit.is_some()
}
/// Returns `true` if a physical key press should be suppressed because IME
/// composition is currently active or just finished in the same key press event
/// sequence (consuming the post-composition grace token on first match).
pub fn should_suppress_key() -> bool {
let mut s = state();
if s.preedit.is_some() {
return true;
}
if let Some(ended) = s.last_ended.take()
&& ended.elapsed() <= POST_COMPOSITION_KEY_GRACE
{
return true;
}
false
}
/// What the focused field's `extractRenderState` ends with: its caret is at
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
/// Without a composition that is the area for the candidate window; with one
@@ -301,4 +349,48 @@ mod tests {
.all(|(_, s)| s.underlined == Some(true))
);
}
#[test]
fn set_and_clear_preedit() {
reset();
set_preedit(PreeditEvent::from_winit("nihongo", Some((0, 7))));
assert!(composing());
set_preedit(None);
assert!(!composing());
reset();
}
#[test]
fn empty_preedit_clears_composing_and_suppresses_trailing_enter_once() {
reset();
set_preedit(PreeditEvent::from_winit("こんにちは", Some((0, 15))));
assert!(composing());
assert!(should_suppress_key());
// OS sends Ime::Preedit("", None) when user presses Enter to confirm conversion.
set_preedit(Some(PreeditEvent {
full_text: String::new(),
caret_position: 0,
blocks: Vec::new(),
focused_block: 0,
}));
assert!(!composing());
// The first trailing KeyboardInput(Enter) in the same key press is suppressed,
// while a subsequent Enter press is allowed through to submit chat.
assert!(should_suppress_key());
assert!(!should_suppress_key());
reset();
}
#[test]
fn mark_committed_suppresses_single_trailing_key() {
reset();
set_preedit(PreeditEvent::from_winit("漢字", Some((0, 6))));
mark_committed();
assert!(!composing());
assert!(should_suppress_key());
assert!(!should_suppress_key());
reset();
}
}
@@ -5,12 +5,13 @@
//! A new screen implements [`Screen`], describing its rows from the settings, and [`Shell`] does
//! everything else: drawing, clicks, one scroll path, a controller's focus and its declared
//! buttons, keys and each row's description (`extras_ui` is the smallest; `graphics_ui` has
//! pages, sliders, costs and Undo). `controller_ui` and `shader_ui` still drive the parts below
//! by hand.
//! pages, sliders, costs and Undo; `controller_ui` has bindings, [`Value::Binding`], whose wait
//! for a button or a key the shell runs). `shader_ui` still drives the parts below by hand.
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
pad_glyph::{self, Glyph},
settings::Settings,
text::Text,
ui,
@@ -573,6 +574,225 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
}
}
/// A binding's reset button, at the end of its row at `y`.
fn reset_rect(cols: &Columns, y: f32) -> [f32; 4] {
let right = cols.list_x + cols.list_w - RESET_INSET;
[right - RESET_W, y + RESET_INSET, RESET_W, style::CONTROL_H]
}
/// What a row takes the focus and a press on: all of it but a binding's reset.
fn row_rect<R>(row: &Row<R>, cols: &Columns, y: f32) -> [f32; 4] {
let resets = row.binding().is_some_and(|b| b.reset.shown());
short_of_reset(cols, y, resets)
}
/// The row at `y`, short of its reset button where it has one.
fn short_of_reset(cols: &Columns, y: f32, resets: bool) -> [f32; 4] {
let [x, y, w, h] = cols.row(y);
let w = if resets { w - RESET_W - RESET_GAP } else { w };
[x, y, w, h]
}
/// A row's lit background while it is pointed at or focused.
fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
style::fill(
batch,
atlas,
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
style::HOVER,
);
}
/// A value to read ([`Value::Read`]) at `y`: its name, and the value at the right, cut short to
/// fit. `(label, name, value)`; `(hover, focused)`.
fn read_row(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
y: f32,
(label, name, value): (&str, &str, &str),
(hover, focused): (bool, bool),
) {
let rect = cols.row(y);
crate::widgets::record(label, rect, true);
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(label.to_owned(), rect, true)
.hovered(hover)
.focused(focused)
});
crate::widgets::target(rect);
if hover || focused {
lit(batch, atlas, rect);
}
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let left = rect[0] + style::INSET;
let name_w = atlas.font.width(name);
let shown = Text::literal(name.to_owned());
batch.text_styled(atlas, &shown, left, ty, style::WHITE, true);
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
let value = style::fit(atlas, value, room);
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
if focused {
style::ring(
batch,
atlas,
[rect[0] + 1.0, y, rect[2] - 2.0, style::ROW],
style::WHITE,
);
}
}
/// A controller binding's row as the shell reads it ([`Shown::Pad`]).
struct PadRow<'a> {
/// The whole caption, for the narrator and the widget tree.
label: &'a str,
name: &'a str,
/// What the strip says, for the narrator.
hint: &'a str,
glyph: Option<Glyph>,
waits: bool,
shared: bool,
reset: Reset,
live: bool,
/// The row and its reset: pointed at, focused.
hover: (bool, bool),
focus: (bool, bool),
}
/// A controller binding's row at `y`: its name, a chip with the bound button's art ("Not Bound"
/// while nothing is), marked "> <" in yellow while it waits and "[ ]" while its button does
/// something else too, and its reset at the end.
fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view: &PadRow) {
let &PadRow {
label,
name,
hint,
glyph,
waits,
shared,
reset,
live,
hover: (hover, reset_hover),
focus: (focused, reset_focused),
} = view;
let rect = short_of_reset(cols, y, reset.shown());
if live && (hover || focused) {
lit(batch, atlas, rect);
}
crate::widgets::widget_cursor(hover, live);
crate::widgets::record(label, rect, live);
crate::widgets::narratable(|| {
let mut n = crate::widgets::Narratable::button(label.to_owned(), rect, live)
.hovered(hover)
.focused(focused);
n.hint = Some(hint.to_owned());
n
});
if live {
crate::widgets::target(rect);
}
// The chip: the button's art, or its name where there is no art or nothing is bound.
let art = glyph.filter(|g| pad_glyph::art(atlas, *g).is_some());
let text = match glyph {
Some(g) => g.label().to_owned(),
None => lang::get(NOT_BOUND),
};
let inner = match art {
Some(g) => pad_glyph::width(atlas, g),
None => atlas.font.width(&text),
};
let marks = match (waits, shared) {
(true, _) => Some((">", "<")),
(false, true) => Some(("[", "]")),
(false, false) => None,
};
let mark_w = marks.map_or(0.0, |(open, close)| {
atlas.font.width(open) + atlas.font.width(close) + 2.0 * PAD_MARK_GAP
});
let chip_w = (inner + mark_w + PAD_CHIP_PAD).max(PAD_CHIP_W);
let chip = [
rect[0] + rect[2] - chip_w - 2.0,
y + (style::ROW - style::CONTROL_H) / 2.0,
chip_w,
style::CONTROL_H,
];
// Text in the chip only where there is no art, yellow with its marks.
let face = match (art, marks) {
(Some(_), _) => Text::literal(String::new()),
(None, Some(_)) => Text::literal(text).with_color(crate::text::YELLOW),
(None, None) => Text::literal(text),
};
style::button(batch, atlas, chip, &face, (live && hover, live, false));
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let middle = chip[0] + chip[2] / 2.0;
if let Some(g) = art {
pad_glyph::draw(batch, atlas, g, (middle - inner / 2.0 + 1.0).floor(), ty);
}
if let Some((open, close)) = marks {
let yellow = |mark: &str| Text::literal(mark.to_owned()).with_color(crate::text::YELLOW);
let left = middle - inner / 2.0 - PAD_MARK_GAP - atlas.font.width(open);
batch.text_styled(atlas, &yellow(open), left.floor(), ty, style::WHITE, true);
let right = middle + inner / 2.0 + PAD_MARK_GAP;
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
}
let left = rect[0] + style::INSET;
let color = if live { style::WHITE } else { style::MUTED };
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
if focused {
style::ring(batch, atlas, rect, style::WHITE);
}
if !reset.shown() {
return;
}
// The reset: a target only where it would do something.
let resets = live && reset == Reset::Changed;
let reset = reset_rect(cols, y);
let reset_label = lang::get("controls.reset");
let reset_hover = resets && reset_hover;
crate::widgets::widget_cursor(reset_hover, resets);
crate::widgets::record(&reset_label, reset, resets);
crate::widgets::narratable(|| {
let what = lang::tr("narrator.controls.reset", &[&name]);
crate::widgets::Narratable::button(what, reset, resets)
.hovered(reset_hover)
.focused(reset_focused)
});
if resets {
crate::widgets::target(reset);
}
let shown = Text::literal(reset_label);
style::button(batch, atlas, reset, &shown, (reset_hover, resets, false));
if reset_focused {
style::ring(batch, atlas, reset, style::WHITE);
}
}
/// Lines a screen shows under its page list (`Screen::side`), from the place of the `below`th page.
fn side_lines(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
below: usize,
lines: &[String],
) {
let [x, y, w, _] = cols.page(below);
for (i, line) in lines.iter().enumerate() {
let rect = [
x + SIDE_INSET,
y + SIDE_INSET + i as f32 * SIDE_LINE_H,
w - 2.0 * SIDE_INSET,
8.0,
];
crate::widgets::record(line, rect, false);
let shown = Text::literal(style::fit(atlas, line, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
}
}
/// A line under the page list: its inset, and its height.
const SIDE_INSET: f32 = 2.0;
const SIDE_LINE_H: f32 = 10.0;
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
pub(crate) fn scroller(
batch: &mut HudBatch,
@@ -761,14 +981,122 @@ pub(crate) enum Value {
steps: Option<u32>,
text: String,
},
/// A key binding (`.0`: what the host waits on), as it reads now.
Key(&'static str, String),
/// A binding: a press makes the host wait for a key or a controller button
/// ([`Output::Bind`]), and the row shows what it is bound to now ([`Binding`]).
Binding(Binding),
/// A value to read beside the name, which the focus reaches (unlike `Note`), so the strip and
/// the narrator say what it is; nothing presses it.
Read(String),
/// A button across the row.
Press,
/// A line to read: nothing presses it and no focus lands on it.
/// A line to read, which no focus reaches (unlike `Read`) and nothing presses.
Note,
}
/// What a binding row waits for once pressed, by the name the host binds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Capture {
/// A key or a mouse button, as on Key Binds (`OptionsUi::selected_key`): Escape unbinds.
Key(&'static str),
/// A controller button for a gameplay action (`OptionsUi::selected_pad_action`): the game binds
/// the next button pressed, Start cancels, and the keyboard's Escape unbinds, as on Key Binds.
Pad(&'static str),
}
/// What a binding row is bound to now.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Shown {
/// The key's name, as Key Binds shows it.
Key(String),
/// The controller button's glyph, drawn as its art; `None` while nothing is bound. `cancel`
/// is the button that cancels the wait (`MenuJob::Start`), named in the strip meanwhile.
Pad { glyph: Option<Glyph>, cancel: Glyph },
}
/// A binding row's value ([`Value::Binding`]). The screen says what it binds and what it is bound
/// to; the shell starts the wait, shows it ("> A <"), draws the button, says in the strip that
/// it isn't bound or what else its button does, and draws the row's reset.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Binding {
pub(crate) capture: Capture,
pub(crate) shown: Shown,
/// What else the same button does, by name: shown in the strip, and the button in yellow.
pub(crate) shared: Vec<String>,
/// Its reset button at the row's end (`Screen::reset`).
pub(crate) reset: Reset,
}
/// A binding row's reset button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Reset {
/// The row has none (a key, as Key Binds lists it).
None,
/// Greyed: the binding is its default already.
Default,
/// Live: the binding differs from its default.
Changed,
}
impl Reset {
/// The row has a reset button, live or not.
fn shown(self) -> bool {
self != Reset::None
}
}
impl Binding {
/// A key's binding, as Key Binds lists it: no reset of its own.
pub(crate) fn key(name: &'static str, shown: String) -> Self {
Self {
capture: Capture::Key(name),
shown: Shown::Key(shown),
shared: Vec::new(),
reset: Reset::None,
}
}
/// What it reads as, for the narrator and the widget tree: a pad's shared button in brackets,
/// as the row draws it in yellow.
fn text(&self) -> String {
match &self.shown {
Shown::Key(text) => text.clone(),
Shown::Pad { glyph, .. } => {
let name = glyph.map_or_else(|| lang::get(NOT_BOUND), |g| g.label().to_owned());
if self.shared.is_empty() {
name
} else {
format!("[ {name} ]")
}
}
}
}
/// What the strip says about it while `waiting`, else when it isn't bound or shares its
/// button; `None` leaves the row's own description.
fn note(&self, waiting: bool) -> Option<String> {
match (&self.shown, waiting) {
(Shown::Pad { cancel, .. }, true) => Some(lang::tr(WAITING_PAD, &[&cancel.label()])),
(Shown::Pad { glyph: None, .. }, false) => Some(lang::get(UNBOUND)),
_ if !self.shared.is_empty() => Some(lang::tr(SHARED, &[&self.shared.join(", ")])),
_ => None,
}
}
}
/// Vanilla's word for a key bound to nothing.
const NOT_BOUND: &str = "key.keyboard.unknown";
/// The strip while a controller button is awaited, naming the one that cancels.
const WAITING_PAD: &str = "Press the button for this. %s cancels.";
/// The strip over a binding with no button.
const UNBOUND: &str = "Not bound: press this row, then the button that should do it.";
/// The strip over a binding whose button does other things too.
const SHARED: &str = "This button also does: %s";
/// A pad binding's chip, at least: wide enough for most labels in vanilla's font.
const PAD_CHIP_W: f32 = 56.0;
/// A pad binding's chip around its art or text, and the room for the marks ("> <", "[ ]").
const PAD_CHIP_PAD: f32 = 8.0;
/// Clear of the art's edge, which its sprite draws past (`pad_glyph::rect`).
const PAD_MARK_GAP: f32 = 5.0;
/// A binding's reset button at the row's end, and the gap left before it.
const RESET_W: f32 = 36.0;
const RESET_GAP: f32 = 4.0;
/// The reset button's inset from the list's right edge.
const RESET_INSET: f32 = 2.0;
/// A row as a screen describes it, built afresh from the settings each time it is read.
#[derive(Clone, Debug)]
pub(crate) struct Row<R> {
@@ -841,12 +1169,20 @@ impl<R> Row<R> {
} else {
lang::get_or("Off", "Off")
}),
Value::Choice(text) | Value::Slider { text, .. } | Value::Key(_, text) => {
Value::Choice(text) | Value::Slider { text, .. } | Value::Read(text) => {
Some(text.clone())
}
Value::Binding(binding) => Some(binding.text()),
Value::Press | Value::Note => None,
}
}
/// Its binding, for a binding row.
fn binding(&self) -> Option<&Binding> {
match &self.value {
Value::Binding(binding) => Some(binding),
_ => None,
}
}
}
/// Rows under a group's name (a language key).
@@ -869,8 +1205,8 @@ pub(crate) struct Sheet<P, R> {
pub(crate) enum Output {
/// Passed on to the game as it is.
Event(crate::options_ui::Event),
/// Wait for a key or a mouse button to bind to this name.
BindKey(&'static str),
/// Wait for what a binding row binds: a key or a mouse button, or a controller button.
Bind(Capture),
/// Open another options page over this one.
Push(crate::options_ui::Page),
/// Leave the screen.
@@ -911,6 +1247,14 @@ pub(crate) trait Screen {
) -> Option<Vec<Output>> {
None
}
/// A binding row's reset was pressed: its binding goes back to its default.
fn reset(&mut self, _s: &mut Settings, _row: Self::Row) -> Vec<Output> {
Vec::new()
}
/// Lines under the page list: whose settings these are, say.
fn side(&self) -> Vec<String> {
Vec::new()
}
/// Whether the screen has Undo, which puts back what it changed (`undo`) since it opened.
fn undoes(&self) -> bool {
false
@@ -921,6 +1265,13 @@ pub(crate) trait Screen {
fn changes(&self, outputs: &[Output]) -> bool {
changes_settings(outputs)
}
/// Whether what the screen changes differs now from `before`, for a screen that can tell:
/// Undo then shows exactly while it would do something, a binding the host made (the game
/// binds a controller button) included, and a value stepped back isn't a change. `None`
/// leaves it to `changes`.
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
None
}
}
/// Whether `outputs` say a setting changed, or a key is being bound to one.
@@ -929,7 +1280,7 @@ pub(crate) fn changes_settings(outputs: &[Output]) -> bool {
let changed = |output: &Output| {
matches!(
output,
Output::BindKey(_) | Output::Event(Event::Changed | Event::SliderDrag)
Output::Bind(_) | Output::Event(Event::Changed | Event::SliderDrag)
)
};
outputs.iter().any(changed)
@@ -956,7 +1307,7 @@ pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
pub(crate) trait Hosted {
/// The screen opens, afresh.
fn open(&mut self);
/// `waiting`: the key binding the host waits on.
/// `waiting`: the binding the host waits on.
fn draw(
&mut self,
s: &Settings,
@@ -964,7 +1315,7 @@ pub(crate) trait Hosted {
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
waiting: Option<&'static str>,
waiting: Option<Capture>,
);
/// A press (or a drag with `drag`) at `at`.
fn click(
@@ -1023,6 +1374,8 @@ pub(crate) trait Hosted {
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Target<P, R> {
Row(R),
/// A binding row's reset button.
Reset(R),
Page(P),
Search,
Undo,
@@ -1071,7 +1424,7 @@ pub(crate) struct Shell<S: Screen> {
page: Option<S::Page>,
/// While searching, the page last picked in the page list: current until the list scrolls.
picked: Option<S::Page>,
waiting: Option<&'static str>,
waiting: Option<Capture>,
/// The rows as last built, and what for.
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
/// The settings as the screen found them, for Undo (`Screen::undoes`), and whether it
@@ -1150,10 +1503,15 @@ impl<S: Screen> Shell<S> {
self.changed = true;
}
}
/// Remember the settings before the first change, for Undo.
/// Remember the settings before the first change, for Undo, and ask a screen that can tell
/// whether they differ now (`Screen::differs`).
fn keep(&mut self, s: &Settings) {
if self.screen.undoes() && self.before.is_none() {
self.before = Some(Box::new(s.clone()));
if !self.screen.undoes() {
return;
}
let before = self.before.get_or_insert_with(|| Box::new(s.clone()));
if let Some(differs) = self.screen.differs(s, before) {
self.changed = differs;
}
}
/// Undo, gone once it has put the settings back.
@@ -1263,22 +1621,21 @@ impl<S: Screen> Shell<S> {
let lines = view.lines.iter();
lines.filter_map(move |l| Some((&view.rows[l.setting?], top + l.y - scroll)))
}
/// The row under the pointer, inside the list's view.
/// The row under the pointer, inside the list's view, and where it is.
fn row_at<'a>(
&self,
view: &'a View<S::Page, S::Row>,
(x, y): (f32, f32),
) -> Option<&'a Row<S::Row>> {
) -> Option<(&'a Row<S::Row>, f32)> {
let cols = &view.cols;
if y < cols.top || y >= cols.bottom {
return None;
}
let mut placed = self.placed(view);
let hit = placed.find(|&(_, ry)| {
placed.find(|&(_, ry)| {
let [rx, ry, rw, rh] = cols.row(ry);
ui::hovered((x, y), rx, ry, rw, rh)
});
hit.map(|(row, _)| row)
})
}
/// Press `row`: `track` is where on its slider's track (`None` from the keyboard, or off it).
fn activate(
@@ -1289,7 +1646,14 @@ impl<S: Screen> Shell<S> {
track: Option<f32>,
) -> Vec<Output> {
match row.value {
Value::Note => Vec::new(),
Value::Note | Value::Read(_) => Vec::new(),
// The shell, not the screen, starts the wait.
Value::Binding(ref binding) => {
self.can_change_value = false;
let outputs = vec![CLICK, Output::Bind(binding.capture)];
self.did(&outputs);
outputs
}
Value::Slider { .. } => {
self.can_change_value = true;
let Some(position) = track else {
@@ -1322,13 +1686,18 @@ impl<S: Screen> Shell<S> {
out.push((Target::Page(page), cols.page(i)));
}
}
let rows: Vec<(S::Row, f32, bool)> = self
let rows: Vec<(&Row<S::Row>, f32, bool)> = self
.placed(view)
.map(|(row, y)| (row.id, y, row.live))
.map(|(row, y)| (row, y, row.live))
.collect();
for &(id, y, live) in &rows[reach(&rows, cols, near)] {
if live {
out.push((Target::Row(id), cols.row(y)));
for &(row, y, live) in &rows[reach(&rows, cols, near)] {
if !live {
continue;
}
out.push((Target::Row(row.id), row_rect(row, cols, y)));
// A binding's reset, right of it, only while it would do something.
if row.binding().is_some_and(|b| b.reset == Reset::Changed) {
out.push((Target::Reset(row.id), reset_rect(cols, y)));
}
}
if self.changed {
@@ -1363,17 +1732,37 @@ impl<S: Screen> Shell<S> {
let row = |id| view.rows.iter().find(|r| r.id == id);
self.can_change_value = matches!(target, Target::Row(id)
if row(id).is_some_and(|r| matches!(r.value, Value::Slider { .. })));
if matches!(target, Target::Row(_)) {
if matches!(target, Target::Row(_) | Target::Reset(_)) {
scroll_into_view(&mut self.scroll, rect, &view.cols, view.content);
self.picked = None;
}
}
/// A binding row's reset: the binding goes back to its default and, as the reset goes away
/// with it, the focus to its row.
fn reset(&mut self, s: &mut Settings, row: S::Row) -> Vec<Output> {
let mut outputs = vec![CLICK];
outputs.extend(self.screen.reset(s, row));
self.did(&outputs);
self.focus = Some(Target::Row(row));
outputs
}
/// The search changed: the list starts over at its top.
fn searched(&mut self) {
self.scroll.amount = 0.0;
self.picked = None;
}
/// A row of the list at `y`.
/// What the strip and the narrator say about `row`: a binding's wait, its missing button or
/// its shared one, else what the row does.
fn strip_text(&self, row: &Row<S::Row>) -> String {
let note = row.binding().and_then(|b| b.note(self.waits(row)));
note.unwrap_or_else(|| row.described().to_owned())
}
/// Whether the host waits on `row`'s binding.
fn waits(&self, row: &Row<S::Row>) -> bool {
row.binding()
.is_some_and(|b| self.waiting == Some(b.capture))
}
/// A row of the list at `y`; `pointer`: the cursor, while it is over the list.
fn draw_row(
&self,
batch: &mut HudBatch,
@@ -1381,7 +1770,7 @@ impl<S: Screen> Shell<S> {
cols: &Columns,
row: &Row<S::Row>,
y: f32,
hover: bool,
pointer: Option<(f32, f32)>,
) {
if row.value == Value::Note {
let rect = cols.row(y);
@@ -1392,11 +1781,39 @@ impl<S: Screen> Shell<S> {
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
return;
}
let waits = matches!(row.value, Value::Key(key, _) if self.waiting == Some(key));
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
let hover = over(row_rect(row, cols, y));
let waits = self.waits(row);
let value = row
.value_text()
.map(|text| if waits { format!("> {text} <") } else { text });
let label = row.label(value.as_deref());
let focused = self.shows_focus(Target::Row(row.id));
let hint = self.strip_text(row);
if let Value::Read(value) = &row.value {
let view = (label.as_str(), row.name.as_str(), value.as_str());
read_row(batch, atlas, cols, y, view, (hover, focused));
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
return;
}
if let Some(binding) = row.binding()
&& let Shown::Pad { glyph, .. } = binding.shown
{
let view = PadRow {
label: &label,
name: &row.name,
hint: &hint,
glyph,
waits,
shared: !binding.shared.is_empty(),
reset: binding.reset,
live: row.live,
hover: (hover, over(reset_rect(cols, y))),
focus: (focused, self.shows_focus(Target::Reset(row.id))),
};
pad_row(batch, atlas, cols, y, &view);
return;
}
let view = RowView {
label: &label,
name: &row.name,
@@ -1411,15 +1828,15 @@ impl<S: Screen> Shell<S> {
},
live: row.live,
hover,
focused: self.shows_focus(Target::Row(row.id)),
focused,
held: self.dragging == Some(row.id),
key: matches!(row.value, Value::Key(..)).then_some(waits),
key: row.binding().map(|_| waits),
};
self::row(batch, atlas, cols, y, &view);
if row.live && matches!(row.value, Value::Choice(_)) {
crate::widgets::steps_value(cols.row(y));
}
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
}
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
/// what the open page holds.
@@ -1438,9 +1855,10 @@ impl<S: Screen> Shell<S> {
style::panel(batch, atlas, cols.dock);
let row = described.map(|i| &view.rows[i]);
let why = row.and_then(|row| row.why.as_deref().filter(|_| !row.live));
let (name, about) = match row {
Some(row) => (row.name.as_str(), why.unwrap_or(row.described())),
None => {
let said = row.map(|row| self.strip_text(row));
let (name, about) = match (row, &said) {
(Some(row), Some(said)) => (row.name.as_str(), why.unwrap_or(said)),
_ => {
let Some(page) = view.pages.iter().find(|p| Some(p.0) == current) else {
return;
};
@@ -1484,7 +1902,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
waiting: Option<&'static str>,
waiting: Option<Capture>,
) {
self.waiting = waiting;
self.keep(s);
@@ -1502,6 +1920,8 @@ impl<S: Screen + Default> Hosted for Shell<S> {
let name = &view.pages[i].1;
page_tab(batch, atlas, cols.page(i), name, flags, cursor);
}
let below = self.tabs(&view).len();
side_lines(batch, atlas, &cols, below, &self.screen.side());
let focused = self.focus == Some(Target::Search);
search_field(&mut self.search, batch, atlas, &cols, cursor, focused);
let name_of = |page| view.pages.iter().find(|p| p.0 == page).map(|p| p.1.clone());
@@ -1514,6 +1934,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self::plate(batch, atlas, &cols, &heading);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
let mut waiting = None;
self::list(
batch,
atlas,
@@ -1525,19 +1946,23 @@ impl<S: Screen + Default> Hosted for Shell<S> {
let row = &view.rows[i];
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
let focused = self.shows_focus(Target::Row(row.id));
// The pointer's row wins over the keyboard's.
let focused = self.shows_focus(Target::Row(row.id))
|| self.shows_focus(Target::Reset(row.id));
// A wait is what the strip says; then the pointer's row wins over the keyboard's.
if self.waits(row) {
waiting = Some(i);
}
if hover || self.dragging == Some(row.id) || (focused && described.is_none()) {
described = Some(i);
}
self.draw_row(batch, atlas, &cols, row, y, hover);
self.draw_row(batch, atlas, &cols, row, y, over_list.then_some(cursor));
},
);
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
if let Some((_, row)) = self.first_row(&view) {
crate::widgets::focus_link(cols.search(), true, row);
}
self.draw_dock(batch, atlas, &view, described, current);
self.draw_dock(batch, atlas, &view, waiting.or(described), current);
// Undo, over Done, only once there is something to undo.
if self.changed {
let undo = cols.button(1);
@@ -1624,10 +2049,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
return vec![CLICK];
}
}
let Some(row) = self.row_at(&view, (x, y)).filter(|row| row.live) else {
let Some((row, row_y)) = self.row_at(&view, (x, y)).filter(|(row, _)| row.live) else {
return Vec::new();
};
self.focus = Some(Target::Row(row.id));
if row.binding().is_some_and(|b| b.reset == Reset::Changed) && at(reset_rect(&cols, row_y))
{
return self.reset(s, row.id);
}
// A press on the track sets the value there; on the name, only focuses.
let track = (x >= cols.track().0).then(|| cols.slider_at(x));
self.activate(s, row, shift, track)
@@ -1661,7 +2090,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
) -> Vec<Output> {
if amount != 0.0 && s.extras.wheel_cycles_settings {
let view = self.view(s, size);
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
if let Some((row, _)) = self.row_at(&view, at).filter(|(row, _)| row.live)
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
{
self.did(&outputs);
@@ -1783,6 +2212,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
Some(row) => self.activate(s, row, shift, None),
None => Vec::new(),
},
Some(Target::Reset(row)) => self.reset(s, row),
Some(Target::Undo) => {
// Undo goes away with the change it undid: Done has the focus.
self.focus = Some(Target::Done);
@@ -1919,6 +2349,11 @@ impl<S: Screen> Shell<S> {
_ => None,
}
}
/// The names of the rows the list has now.
pub(crate) fn row_names(&mut self, s: &Settings) -> Vec<String> {
let view = self.view(s, (854.0, 480.0));
view.rows.iter().map(|r| r.name.clone()).collect()
}
/// The captions of the rows the list has now, as the widget tree reads them.
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
let view = self.view(s, (854.0, 480.0));
@@ -5,10 +5,11 @@
//! 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::{
gamepad::{Button as PadButton, GlyphStyle},
gamepad::MenuJob,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang, pad_glyph,
lang,
pad_glyph::{self, Glyph, Glyphs},
text::Text,
ui,
};
@@ -243,7 +244,7 @@ impl SplitProfiles {
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
style: GlyphStyle,
glyphs: Glyphs,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
@@ -306,7 +307,7 @@ impl SplitProfiles {
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
graphics_style::title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
self.draw_bumpers(batch, atlas, glyphs);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
line(
@@ -400,7 +401,7 @@ impl SplitProfiles {
/// Under the title: the bumpers as keys, as the prompts over a player's view draw them (the
/// controller's own symbols, in its colours), then where they put the view.
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, glyphs: Glyphs) {
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let words = match &self.place {
Some(place) => tr(
@@ -410,14 +411,14 @@ impl SplitProfiles {
.replacen("%s", place, 1),
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| pad_glyph::width(atlas, b, style);
let keys = [MenuJob::PagePrev, MenuJob::PageNext].map(|job| Glyph::menu(job, glyphs));
let key_width = |b: Glyph| pad_glyph::width(atlas, b);
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
for b in keys {
let kw = key_width(b);
// A light edge around a dark key: it stands out on the menu's dark background.
if pad_glyph::art(atlas, b, style).is_none() {
if pad_glyph::art(atlas, b).is_none() {
batch.fill(
atlas,
x - 2.0,
@@ -427,7 +428,7 @@ impl SplitProfiles {
rgba(170, 170, 170, 255),
);
}
pad_glyph::draw(batch, atlas, b, style, x, y);
pad_glyph::draw(batch, atlas, b, x, y);
x += kw + 4.0;
}
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
@@ -12,7 +12,10 @@ struct StaticEntityUniform {
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
@location(7) section_origin: vec3<i32>) -> Out {
let block_delta = section_origin - static_entity.camera_block.xyz;
// [world warp static entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
// [end world warp static entity]
var out = entity_vertex(camera_relative,
uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
+39 -3
View File
@@ -2258,10 +2258,14 @@ impl Streaming {
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
/// read light from neighbouring blocks). Every column whose stored light
/// changed is saved again (`ChunkHolder.sectionLightChanged` marks the
/// chunk unsaved): an edit's light reaches up to 15 blocks into columns
/// the edit itself did not mark, which may already have been saved.
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
if let Some(log) = &mut world.light_log {
for (x, sy, z) in changed.sections() {
for (x, sy, z) in changed.sections() {
world.modified.insert((x, z));
if let Some(log) = &mut world.light_log {
log.entry((x, z)).or_default().insert(sy as usize);
}
}
@@ -4363,6 +4367,38 @@ mod tests {
}
}
/// An edit's light change in a neighbouring column marks that column
/// unsaved too, or a column saved before the edit keeps its old light on
/// disk.
#[test]
fn light_spilling_into_a_saved_neighbour_marks_it_unsaved() {
use crate::light::tests::{AIR, TORCH, tables, world};
let tables = tables();
let mut w = World::new(horizon_world::registry::BlockStateId(0), 0);
for (key, chunk) in world(|c| {
c.set(14, 0, 8, TORCH);
}) {
w.chunks.insert(key, chunk);
}
assert!(w.modified.is_empty());
Arc::make_mut(w.chunks.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = crate::light::LightChanges::default();
crate::light::relight(
&mut w.chunks,
&tables,
true,
&[(14, 0, 8)],
false,
&mut changed,
);
assert_eq!(w.chunks[&(1, 0)].light(0, 0, 8) & 15, 0); // was 12
Streaming::mark_light_changes(&mut w, &changed);
assert!(w.modified.contains(&(1, 0)));
assert!(w.modified.contains(&(0, 0)));
// The torch never reached x -1: that column's light is unchanged.
assert!(!w.modified.contains(&(-1, 0)));
}
fn resident_column(world: &mut World, light: u8) -> Chunk {
let mut chunk = Chunk::empty(2, -3);
chunk.set(1, 10, 1, 1);
@@ -224,6 +224,10 @@ struct TerrainOut {
// The camera-relative height, for the water mirror's cut at the plane.
height: f32,
emissive_layer: u32,
// [world warp varyings]
// (a native mod's world warp adds its own here, and in `TerrainWire`, `to_wire` and
// `from_wire`: gfx/world_warp.rs)
// [end world warp varyings]
}
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
@@ -242,6 +246,8 @@ struct TerrainWire {
@location(3) d: vec4<f32>,
// `layer`, `emission`, `fade` and `emissive_layer`.
@location(4) @interpolate(flat) e: vec4<u32>,
// [world warp wire]
// [end world warp wire]
}
fn to_wire(o: TerrainOut) -> TerrainWire {
var w: TerrainWire;
@@ -251,6 +257,8 @@ fn to_wire(o: TerrainOut) -> TerrainWire {
w.c = vec4(o.uv, o.scene);
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
// [world warp to wire]
// [end world warp to wire]
return w;
}
fn from_wire(w: TerrainWire) -> TerrainOut {
@@ -268,6 +276,8 @@ fn from_wire(w: TerrainWire) -> TerrainOut {
o.emission = w.e.y;
o.fade = w.e.z;
o.emissive_layer = w.e.w;
// [world warp from wire]
// [end world warp from wire]
return o;
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
@@ -662,7 +672,10 @@ fn hdr_facing() -> f32 {
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
// [world warp vertex]
// (a world warp's pipelines put the point through the mod's `world_warp`: gfx/world_warp.rs)
let pos = position + origin;
// [end world warp vertex]
var out: TerrainOut;
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
out.local = position;
@@ -695,6 +708,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
out.visibility = mix(1.0, instance_visibility(w),
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
// [world warp out]
// [end world warp out]
return out;
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
+5 -2
View File
@@ -9,6 +9,9 @@ use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod modified;
pub use modified::ModifiedChunks;
/// Sections (x, section index, z) waiting to be remeshed, kept by column
/// (a bit per section index), so the mesh scheduler looks at each column once
/// rather than at every section of it.
@@ -87,7 +90,7 @@ pub struct World {
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
pub modified: ModifiedChunks,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
@@ -224,7 +227,7 @@ impl World {
loaded_entities: Vec::new(),
entities_pending: FxHashSet::default(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
modified: ModifiedChunks::default(),
attachments: Default::default(),
attachment_syncs: FxHashSet::default(),
block_entities: Default::default(),
@@ -0,0 +1,101 @@
//! `LevelChunk.markUnsaved` and `ChunkMap`'s insertion-ordered eager queue.
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::{
collections::BTreeMap,
ops::Deref,
time::{Duration, Instant},
};
type Key = (i32, i32);
const COOLDOWN: Duration = Duration::from_millis(10_000);
#[derive(Default)]
pub struct ModifiedChunks {
dirty: FxHashSet<Key>,
// Ordered independently of the dirty set: an autosave leaves queue entries
// in place until the eager loop visits them, including cooled-down ones.
order: BTreeMap<u64, Key>,
queued: FxHashMap<Key, u64>,
sequence: u64,
next_save: FxHashMap<Key, Instant>,
}
impl Deref for ModifiedChunks {
type Target = FxHashSet<Key>;
fn deref(&self) -> &Self::Target {
&self.dirty
}
}
impl IntoIterator for ModifiedChunks {
type Item = Key;
type IntoIter = std::collections::hash_set::IntoIter<Key>;
fn into_iter(self) -> Self::IntoIter {
self.dirty.into_iter()
}
}
impl ModifiedChunks {
pub fn insert(&mut self, key: Key) -> bool {
let changed = self.dirty.insert(key);
if changed && !self.queued.contains_key(&key) {
self.sequence += 1;
self.order.insert(self.sequence, key);
self.queued.insert(key, self.sequence);
}
changed
}
pub fn extend(&mut self, keys: impl IntoIterator<Item = Key>) {
for key in keys {
self.insert(key);
}
}
pub fn remove(&mut self, key: &Key) -> bool {
self.dirty.remove(key)
}
pub fn drain(&mut self) -> impl Iterator<Item = Key> + '_ {
self.dirty.drain()
}
/// Like `drain`: queue entries of chunks no longer dirty go at the next eager pass.
pub fn clear(&mut self) {
self.dirty.clear();
}
/// `ChunkMap.scheduleUnload`: forget the cooldown, including clean chunks.
pub fn unloaded(&mut self, key: &Key) {
self.next_save.remove(key);
}
/// `ChunkMap.saveAllChunks(false)`: clear cooldowns, then save all dirty
/// full chunks without a limit, starting a fresh cooldown for each write.
pub fn autosaved(&mut self, keys: &[Key], now: Instant) {
self.next_save.clear();
self.next_save
.extend(keys.iter().map(|&key| (key, now + COOLDOWN)));
}
/// `ChunkMap.saveChunksEagerly` / `saveChunkIfNeeded`. `available` checks
/// both haveTime and activeChunkWrites before EACH candidate; `save` takes
/// an immutable snapshot and queues it. Failed submissions stay dirty.
pub fn save_eagerly(
&mut self,
now: Instant,
mut available: impl FnMut() -> bool,
mut loaded: impl FnMut(Key) -> bool,
mut save: impl FnMut(Key) -> bool,
) {
let mut saved = 0;
let mut remove = Vec::new();
for (&sequence, &key) in &self.order {
if saved == 20 || !available() {
break;
}
if !loaded(key) || !self.dirty.contains(&key) {
remove.push((sequence, key));
} else if self.next_save.get(&key).is_none_or(|&at| now >= at) && save(key) {
self.dirty.remove(&key);
self.next_save.insert(key, now + COOLDOWN);
remove.push((sequence, key));
saved += 1;
}
}
for (sequence, key) in remove {
self.order.remove(&sequence);
self.queued.remove(&key);
}
}
}
+3 -3
View File
@@ -92,8 +92,8 @@ dunce = "1.0.5"
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
@@ -116,7 +116,7 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
@@ -1008,6 +1008,7 @@
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
@@ -7,10 +7,64 @@
"horizon.options.vibration": "Vibración",
"horizon.options.play_in_background": "Jugar en segundo plano",
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
"horizon.options.gameplay_guide": "Guía de juego",
"horizon.options.gameplay_guide.basic": "Básica",
"horizon.options.gameplay_guide.full": "Completa",
"horizon.options.gameplay_guide.off": "Desactivada",
"horizon.controller.guide.use": "Usar",
"horizon.controller.guide.open": "Abrir",
"horizon.controller.guide.ride": "Montar",
"horizon.controller.guide.trade": "Comerciar",
"horizon.controller.guide.feed": "Alimentar",
"horizon.controller.guide.place": "Colocar",
"horizon.controller.guide.eat": "Comer",
"horizon.controller.guide.drink": "Beber",
"horizon.controller.guide.shoot": "Disparar",
"horizon.controller.guide.load": "Cargar",
"horizon.controller.guide.throw": "Lanzar",
"horizon.controller.guide.raise": "Alzar",
"horizon.controller.guide.equip": "Equipar",
"horizon.controller.guide.fish": "Pescar",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Acelerar",
"horizon.controller.guide.break": "Romper",
"horizon.controller.guide.attack": "Atacar",
"horizon.controller.guide.spectate": "Espectar",
"horizon.controller.guide.spectator_menu": "Menú de espectador",
"horizon.controller.guide.release_shoot": "Suelta para disparar",
"horizon.controller.guide.release_throw": "Suelta para lanzar",
"horizon.controller.guide.release_stop": "Suelta para parar",
"horizon.controller.guide.fly": "Volar",
"horizon.controller.guide.fly_up": "Volar arriba",
"horizon.controller.guide.fly_down": "Volar abajo",
"horizon.controller.guide.swim_up": "Nadar arriba",
"horizon.controller.guide.swim_down": "Nadar abajo",
"horizon.controller.guide.glide": "Planear",
"horizon.controller.guide.dismount": "Desmontar",
"horizon.controller.guide.stand": "Levantarse",
"horizon.controller.guide.drop": "Soltar objeto",
"horizon.controller.guide.swap": "Cambiar de mano",
"horizon.controller.guide.take": "Coger",
"horizon.controller.guide.take_half": "Coger la mitad",
"horizon.controller.guide.put": "Dejar",
"horizon.controller.guide.put_one": "Dejar uno",
"horizon.controller.guide.swap_stack": "Intercambiar",
"horizon.controller.guide.quick_move": "Mover rápido",
"horizon.controller.guide.drop_stack": "Soltar pila",
"horizon.controller.guide.place_recipe": "Colocar receta",
"horizon.controller.guide.place_recipe_all": "Colocar todo",
"horizon.controller.guide.alternatives": "Alternativas",
"horizon.controller.guide.select": "Seleccionar",
"horizon.controller.guide.close": "Cerrar",
"horizon.controller.guide.pages": "Páginas",
"horizon.controller.guide.categories": "Categorías",
"horizon.controller.guide.tabs": "Pestañas",
"horizon.options.lightbar": "Barra de luz",
"horizon.options.look_sensitivity": "Sensibilidad de vista",
"horizon.options.invert_x": "Invertir vista X",
"horizon.options.invert_y": "Invertir vista Y",
"horizon.options.craft_on_servers": "Fabricación rápida en servidores",
"horizon.controller.direct_craft": "Fabricación rápida",
"horizon.options.sneak_button": "Botón de agacharse",
"horizon.options.move_deadzone": "Zona muerta de movimiento",
"horizon.options.look_deadzone": "Zona muerta de vista",
@@ -24,6 +78,9 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Botones de Nintendo",
"horizon.options.nintendo_buttons.position": "Por posición",
"horizon.options.nintendo_buttons.letter": "Por letra",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -52,6 +109,21 @@
"horizon.studio.replay.seek.invalid": "Posición de replay no válida",
"horizon.studio.replay.status": "Replay de Studio: %s / %s ms (%s%%), velocidad %s×, en pausa %s, finalizado %s",
"horizon.studio.replay.closed": "Replay de Studio cerrado",
"horizon.studio.freecam.enabled": "Cámara libre de Studio activada. Usa WASD para moverte, Espacio y Mayús para subir o bajar, y Control para ir más rápido.",
"horizon.studio.freecam.disabled": "Cámara libre de Studio desactivada",
"horizon.studio.freecam.unavailable": "La cámara libre de Studio no encontró la cámara del jugador",
"horizon.studio.freecam.replay_required": "La cámara libre de Studio está disponible durante la reproducción de un replay",
"horizon.studio.freecam.usage": "Uso: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Fotograma clave de cámara añadido en %s ms. La ruta se guarda al cerrar el replay.",
"horizon.studio.keyframe.cleared": "Ruta de cámara borrada. El cambio se guarda al cerrar el replay.",
"horizon.studio.keyframe.removed": "Fotograma clave de cámara %s eliminado. El cambio se guarda al cerrar el replay.",
"horizon.studio.keyframe.save_error": "No se pudo guardar la ruta de cámara: %s",
"horizon.studio.keyframe.remove_usage": "Uso: /studio keyframe remove <ÍNDICE>",
"horizon.studio.keyframe.index_invalid": "El índice del fotograma clave de cámara está fuera de rango",
"horizon.studio.keyframe.usage": "Uso: /studio keyframe <add|remove ÍNDICE|clear>",
"horizon.studio.path.locked": "Reproducción de la ruta de cámara de Studio activada",
"horizon.studio.path.unlocked": "Reproducción de la ruta de cámara de Studio detenida",
"horizon.studio.path.usage": "Uso: /studio path <lock|unlock> (añade al menos un fotograma clave antes)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.shader_options": "Opciones de shaders",
@@ -164,6 +236,7 @@
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"Upscaling Sharpness": "Nitidez del reescalado",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
@@ -242,6 +315,7 @@
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Descarta el terreno cercano en la GPU: menos CPU con mucha distancia de renderizado, más GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automático: lo prueba donde tu CPU limita los fotogramas y lo mantiene si van más rápido.",
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
"No entity occlusion culling.": "Sin oclusión de entidades.",
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
@@ -265,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxeles del mundo por píxel de pantalla. Por debajo del 100 % es más rápido en la GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Aumenta la nitidez de la imagen reescalada. Más alto: bordes más nítidos y más parpadeo.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
"How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
@@ -503,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "Reutiliza la geometría de la estatua hasta que cambia el estado de su bloque.",
"Leaf Culling": "Descarte de hojas",
"Every leaf face. Vanilla's fancy leaves.": "Todas las caras de las hojas. Las hojas detalladas de vanilla.",
"Opaque Ice": "Hielo opaco",
"Draws ice in the solid pass, without its see-through texture.": "Dibuja el hielo en la pasada sólida, sin su textura transparente.",
"Distant Plant Dither": "Tramado de plantas lejanas",
"Keeps fine plants in high-res packs from fading far away.": "Evita que las plantas finas de los paquetes de alta resolución se difuminen a lo lejos.",
"Screenshot Quality": "Calidad de las capturas",
@@ -888,6 +965,23 @@
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botón para agacharte, o pulsa una vez para empezar y otra para parar.",
"How far the left stick moves before you walk. Raise it if you drift.": "Cuánto se mueve la palanca izquierda antes de que camines. Súbelo si te desplazas solo.",
"How far a trigger is pulled before it counts as pressed.": "Cuánto hay que apretar un gatillo para que cuente como pulsado.",
"Shows what your buttons do right now, in play and in inventories.": "Muestra lo que hacen tus botones ahora mismo, al jugar y en los inventarios.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Muestra solo lo que puedes hacer con lo que estás mirando y los botones principales del inventario.",
"Doesn't show what your buttons do.": "No muestra lo que hacen tus botones.",
"Shows the buttons of the controller you're using.": "Muestra los botones del mando que estás usando.",
"Always shows Xbox buttons.": "Muestra siempre los botones de Xbox.",
"Always shows PlayStation buttons.": "Muestra siempre los botones de PlayStation.",
"Always shows Nintendo buttons.": "Muestra siempre los botones de Nintendo.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, a la derecha, confirma y salta, y B vuelve atrás, como en una Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, abajo, confirma y salta, y A vuelve atrás, como en otros mandos.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Abre el teclado de Steam en una Steam Deck bajo Steam y, si no, el de Horizon.",
"Always opens Horizon's keyboard.": "Abre siempre el teclado de Horizon.",
"Always opens Steam's keyboard.": "Abre siempre el teclado de Steam.",
"A DualSense's light shows your health, from green through amber to red.": "La luz de un DualSense muestra tu salud, de verde a ámbar y a rojo.",
"A DualSense's light changes with the dimension you're in.": "La luz de un DualSense cambia con la dimensión en la que estás.",
"A DualSense keeps its own light.": "Un DualSense mantiene su propia luz.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permite fabricar con LT + A en servidores que no son tus mundos. Algunos servidores lo cuentan como macro.",
"Show the buttons for what you can do, while you play with a controller.": "Muestra los botones de lo que puedes hacer mientras juegas con mando.",
"How fast the stick moves the crosshair in inventories.": "La rapidez con la que la palanca mueve el cursor en los inventarios.",
"How fast the right stick scrolls a list.": "La rapidez con la que la palanca derecha desplaza una lista.",
"How long a held direction waits before it repeats.": "Cuánto espera una dirección mantenida antes de repetirse.",
@@ -899,6 +993,7 @@
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El fabricante y el producto USB que informa el sistema. Útil al reportar un mando que falla.",
"The glyph family the game picked from the controller's maker.": "La familia de símbolos que el juego eligió según el fabricante del mando.",
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si los botones A y B de un mando de Nintendo funcionan por su posición o por su letra.",
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El mando sigue funcionando aunque esta ventana no tenga el foco, y perder el foco no pone el juego en pausa.",
@@ -917,5 +1012,38 @@
"horizon.recipe_transfer.missing": "Faltan objetos",
"horizon.recipe_transfer.no_room": "Inventario lleno",
"horizon.recipe_transfer.unavailable": "La receta no cabe en este menú",
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida"
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida",
"horizon.studio.timeline.freecam_on": "Cámara libre: SÍ",
"horizon.studio.timeline.freecam_off": "Cámara libre: NO",
"horizon.studio.timeline.freecam_short": "Cám.",
"horizon.studio.timeline.freecam_active_short": "Cám. ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Clave",
"horizon.studio.timeline.delete_keyframe_short": "− Borrar",
"horizon.studio.timeline.split": "Dividir",
"horizon.studio.timeline.marker": "Marcador %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s escenas",
"horizon.studio.timeline.scene_title": "Escena %s",
"horizon.studio.timeline.sequence": "Secuencia",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Archivo",
"horizon.studio.topbar.camera": "Cámara",
"horizon.studio.topbar.view": "Vista",
"horizon.studio.topbar.file_short": "Arch.",
"horizon.studio.topbar.camera_short": "Cám.",
"horizon.studio.topbar.view_short": "Vista",
"horizon.studio.topbar.game_pause_menu": "⏸ Menú de pausa",
"horizon.studio.topbar.pause_short": "Pausa",
"horizon.studio.topbar.open_recordings": "📁 Abrir carpeta de grabaciones",
"horizon.studio.topbar.close_replay": "✕ Cerrar replay",
"horizon.studio.topbar.exit": "Salir",
"horizon.studio.topbar.toggle_freecam": "Alternar cámara libre",
"horizon.studio.topbar.reset_to_player": "👤 Volver al jugador",
"horizon.studio.topbar.unlock_freecam": "🔓 Liberar cámara libre",
"horizon.studio.topbar.lock_to_spline": "🔒 Fijar a la curva",
"horizon.studio.topbar.add_keyframe": "➕ Añadir fotograma clave",
"horizon.studio.topbar.add_marker": "Añadir marcador",
"horizon.studio.topbar.clear_keyframes": "🗑 Borrar fotogramas clave",
"horizon.studio.topbar.timeline_hud": "⏱ Línea de tiempo",
"horizon.studio.topbar.split_scene": "Dividir escena"
}
@@ -1,29 +1,155 @@
{
"horizon.extras.header.world": "Monde",
"horizon.extras.pregen": "Prégénération de chunks",
"horizon.extras.pregen.tooltip": "Active /pregen dans vos mondes solo et en LAN. Avec les triches activées, utilisez /pregen start pour générer une zone avant de l'explorer. N'active pas la commande sur les serveurs que vous rejoignez.",
"horizon.options.controller": "Manette",
"horizon.options.three_d_skin_layers": "Couches de skin 3D",
"horizon.options.vibration": "Vibrations",
"horizon.options.play_in_background": "Jouer en arrière-plan",
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
"horizon.options.gameplay_guide": "Guide de jeu",
"horizon.options.gameplay_guide.basic": "Basique",
"horizon.options.gameplay_guide.full": "Complet",
"horizon.options.gameplay_guide.off": "Désactivé",
"horizon.controller.guide.use": "Utiliser",
"horizon.controller.guide.open": "Ouvrir",
"horizon.controller.guide.ride": "Monter",
"horizon.controller.guide.trade": "Échanger",
"horizon.controller.guide.feed": "Nourrir",
"horizon.controller.guide.place": "Placer",
"horizon.controller.guide.eat": "Manger",
"horizon.controller.guide.drink": "Boire",
"horizon.controller.guide.shoot": "Tirer",
"horizon.controller.guide.load": "Charger",
"horizon.controller.guide.throw": "Lancer",
"horizon.controller.guide.raise": "Lever",
"horizon.controller.guide.equip": "Équiper",
"horizon.controller.guide.fish": "Pêcher",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Accélérer",
"horizon.controller.guide.break": "Casser",
"horizon.controller.guide.attack": "Attaquer",
"horizon.controller.guide.spectate": "Observer",
"horizon.controller.guide.spectator_menu": "Menu spectateur",
"horizon.controller.guide.release_shoot": "Relâchez pour tirer",
"horizon.controller.guide.release_throw": "Relâchez pour lancer",
"horizon.controller.guide.release_stop": "Relâchez pour arrêter",
"horizon.controller.guide.fly": "Voler",
"horizon.controller.guide.fly_up": "Monter",
"horizon.controller.guide.fly_down": "Descendre",
"horizon.controller.guide.swim_up": "Nager vers le haut",
"horizon.controller.guide.swim_down": "Nager vers le bas",
"horizon.controller.guide.glide": "Planer",
"horizon.controller.guide.dismount": "Descendre",
"horizon.controller.guide.stand": "Se lever",
"horizon.controller.guide.drop": "Lâcher l'objet",
"horizon.controller.guide.swap": "Changer de main",
"horizon.controller.guide.take": "Prendre",
"horizon.controller.guide.take_half": "Prendre la moitié",
"horizon.controller.guide.put": "Poser",
"horizon.controller.guide.put_one": "Poser un",
"horizon.controller.guide.swap_stack": "Échanger",
"horizon.controller.guide.quick_move": "Déplacement rapide",
"horizon.controller.guide.drop_stack": "Lâcher la pile",
"horizon.controller.guide.place_recipe": "Placer la recette",
"horizon.controller.guide.place_recipe_all": "Tout placer",
"horizon.controller.guide.alternatives": "Alternatives",
"horizon.controller.guide.select": "Sélectionner",
"horizon.controller.guide.close": "Fermer",
"horizon.controller.guide.pages": "Pages",
"horizon.controller.guide.categories": "Catégories",
"horizon.controller.guide.tabs": "Onglets",
"horizon.options.lightbar": "Barre lumineuse",
"horizon.options.look_sensitivity": "Sensibilité de la vue",
"horizon.options.invert_x": "Inverser l'axe X de la vue",
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
"horizon.options.craft_on_servers": "Artisanat rapide sur les serveurs",
"horizon.controller.direct_craft": "Artisanat rapide",
"horizon.options.sneak_button": "Bouton pour s'accroupir",
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.trigger_threshold": "Seuil de la gâchette",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Boutons Nintendo",
"horizon.options.nintendo_buttons.position": "Par position",
"horizon.options.nintendo_buttons.letter": "Par lettre",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.studio.record.usage": "Utilisation : /studio record <start [nom]|stop|status>",
"horizon.studio.record.started": "Enregistrement Studio démarré : %s",
"horizon.studio.record.saved": "Enregistrement Studio sauvegardé : %s",
"horizon.studio.record.error": "Erreur d'enregistrement Studio : %s",
"horizon.studio.record.status.active": "Enregistrement Studio actif — %s s, %s paquets, %s Ko (%s)",
"horizon.studio.record.status.inactive": "Enregistrement Studio inactif",
"horizon.studio.replay.usage": "Utilisation : /studio replay <open CHEMIN|pause|resume|speed MULTIPLICATEUR|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Utilisation : /studio replay open <CHEMIN>",
"horizon.studio.replay.opened": "Replay Studio ouvert : %s (%s s, %s paquets)",
"horizon.studio.replay.open_error": "Impossible d'ouvrir le replay : %s",
"horizon.studio.replay.paused": "Replay Studio en pause",
"horizon.studio.replay.resumed": "Replay Studio repris",
"horizon.studio.replay.inactive": "Aucun replay Studio actif",
"horizon.studio.replay.speed.usage": "Utilisation : /studio replay speed <MULTIPLICATEUR>",
"horizon.studio.replay.speed.set": "Vitesse du replay Studio réglée sur %s×",
"horizon.studio.replay.speed.invalid": "Vitesse de replay invalide",
"horizon.studio.replay.seek.usage": "Utilisation : /studio replay seek <MILLISECONDES>",
"horizon.studio.replay.seeked": "Replay Studio avancé à %s ms (%s s)",
"horizon.studio.replay.seek.error": "Impossible de déplacer le replay : %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Le retour en arrière n'est disponible que pour un replay autonome.",
"horizon.studio.replay.seek.invalid": "Position de replay invalide",
"horizon.studio.replay.status": "Replay Studio : %s / %s ms (%s%%), vitesse %s×, en pause %s, terminé %s",
"horizon.studio.replay.closed": "Replay Studio fermé",
"horizon.studio.freecam.enabled": "Caméra libre Studio activée. WASD pour vous déplacer, Espace et Maj pour monter ou descendre, Ctrl pour aller plus vite.",
"horizon.studio.freecam.disabled": "Caméra libre Studio désactivée",
"horizon.studio.freecam.unavailable": "La caméra libre Studio n'a pas trouvé de caméra de joueur",
"horizon.studio.freecam.replay_required": "La caméra libre Studio est disponible pendant la lecture d'un replay",
"horizon.studio.freecam.usage": "Utilisation : /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Image clé de caméra ajoutée à %s ms. Le tracé est sauvegardé à la fermeture du replay.",
"horizon.studio.keyframe.cleared": "Tracé de caméra effacé. La modification est sauvegardée à la fermeture du replay.",
"horizon.studio.keyframe.removed": "Image clé de caméra %s supprimée. La modification est sauvegardée à la fermeture du replay.",
"horizon.studio.keyframe.save_error": "Impossible d'enregistrer le tracé de caméra : %s",
"horizon.studio.keyframe.remove_usage": "Utilisation : /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "L'indice de l'image clé de caméra est hors limites",
"horizon.studio.keyframe.usage": "Utilisation : /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Lecture du tracé de caméra Studio activée",
"horizon.studio.path.unlocked": "Lecture du tracé de caméra Studio arrêtée",
"horizon.studio.path.usage": "Utilisation : /studio path <lock|unlock> (ajoutez d'abord au moins une image clé)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Packs de shaders",
"horizon.pages.shader_options": "Options des shaders",
"horizon.pages.controller": "Options de la manette",
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
"horizon.pages.extras": "Extras Horizon",
"horizon.pages.settings": "Options Horizon",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "Agent IA",
"horizon.pages.split_profiles": "Qui joue ?",
"horizon.pages.music": "Musique",
"horizon.pages.music.menu": "Menu",
"horizon.pages.music.overworld": "Surface",
"horizon.pages.music.creative": "Créatif",
"horizon.pages.music.underwater": "Sous l'eau",
"horizon.pages.music.end": "L'End",
"horizon.pages.music.boss": "Boss",
"horizon.pages.music.credits": "Générique",
"horizon.pages.music.overworld_biomes": "Biomes de la Surface",
"horizon.pages.music.nether_biomes": "Biomes du Nether",
"horizon.pages.music.end_biomes": "Biomes de l'End",
"horizon.pages.music.other": "Autre musique",
"horizon.pages.music.empty": "Aucune piste musicale : les sons du jeu ne sont pas chargés.",
"horizon.pages.music.tooltip": "Les pistes intégrées, par situation : lancez-en une maintenant, ou désactivez-en une pour que le jeu arrête de la choisir.",
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
"horizon.pages.studio.tooltip": "Parcourez les enregistrements sauvegardés et leurs métadonnées.",
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
@@ -36,15 +162,27 @@
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
"horizon.pages.open.shader_packs": "Packs de shaders...",
"horizon.pages.open.controller": "Options de la manette...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent IA...",
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
"horizon.pages.open.controller_binds": "Boutons manette...",
"horizon.pages.open.horizon_settings": "Options Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Enregistrements sauvegardés",
"horizon.studio.empty": "Aucun enregistrement dans le dossier des enregistrements.",
"horizon.studio.empty_hint": "Utilisez /studio record start <nom> dans un monde, ou déposez-y des fichiers .mcpr.",
"horizon.studio.open_folder": "Ouvrir les enregistrements",
"horizon.studio.open_replay": "Lire le replay",
"horizon.studio.refresh": "Actualiser",
"horizon.studio.unknown_server": "Inconnu",
"horizon.pages.open.music": "Musique...",
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
"horizon.mods.stopped": " (arrêté)",
"horizon.mods.not_built": " (non compilé)",
"horizon.mods.dev": " (dév.)",
"horizon.mods.settings": "Options",
"horizon.mods.in_world": " (quittez le monde pour changer)",
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
"horizon.mods.browse": "Parcourir les mods...",
"horizon.mods.browse.title": "Parcourir les mods",
@@ -79,6 +217,7 @@
"LOD Distance": "Distance LOD",
"Far Detail": "Détails lointains",
"Cave Culling": "Occlusion des grottes",
"GPU Culling": "Culling GPU",
"Entity Culling": "Occlusion des entités",
"Chunk Updates": "MàJ chunks",
"Upload Budget": "Transfert",
@@ -91,10 +230,15 @@
"Worker Threads": "Threads",
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling Sharpness": "Netteté de la mise à l'échelle",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
"UI Brightness": "Luminosité UI",
"HDR Lighting": "Éclairage HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Les sources de lumière, le soleil et la lumière du soleil brillent au-delà du blanc. Désactivé : comme en SDR.",
"Smooth Gradients": "Dégradés doux",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Image 16 bits : les ciels et les ombres sombres ne se dégradent plus en bandes. Un peu plus de VRAM. Au prochain démarrage.",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Translucent Batching": "Regroupement translucide",
@@ -103,27 +247,72 @@
"On": "Oui",
"Off": "Non",
"Unlimited": "Illimité",
"Auto": "Auto",
"Custom": "Personnalisé",
"Performance": "Performances",
"Balanced": "Équilibré",
"Quality": "Qualité",
"Ultra": "Ultra",
"Ultra Performance": "Ultra perfs",
"Full": "Complet",
"Reduced": "Réduit",
"Efficient": "Économe",
"Precise": "Précis",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS et graphique",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Aucun coût",
"Low cost": "Coût faible",
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"HDR": "HDR",
"Not with software rendering, which runs it on the CPU.": "Pas avec le rendu logiciel, qui l'exécute sur le CPU.",
"Out of video memory: off until the next start.": "Mémoire vidéo épuisée : désactivé jusqu'au prochain démarrage.",
"The graphics device was lost: off until the next start.": "Le périphérique graphique a été perdu : désactivé jusqu'au prochain démarrage.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Pas encore sur DirectX 12 : essayez l'API graphique Vulkan.",
"Auto (%s)": "Auto (%s)",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Élimine le terrain proche sur le GPU : moins de CPU à grande distance de rendu, plus de GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Auto : l'essaie là où votre CPU limite l'image, le garde si les images s'accélèrent.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Accentue la netteté de l'image mise à l'échelle. Plus haut : bords plus nets, plus de scintillement.",
"horizon.realms.not_signed_in": "Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour jouer sur Realms.",
"Distance": "Distance",
"Display": "Affichage",
"Interface": "Interface",
"Textures": "Textures",
"Image": "Image",
"Overlay": "Surimpression",
"Graphics": "Graphismes",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.menus": "Menus",
"horizon.extras.music_page": "Page Musique",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom : à installer depuis Mods",
"horizon.extras.pack_browser": "Navigateur de packs de ressources",
"horizon.extras.music_page.tooltip": "Musique et sons gagne une page Musique... : lancez n'importe quelle piste intégrée maintenant, ou désactivez-en une pour que le jeu arrête de la choisir. Rien ne change tant que vous ne désactivez pas une piste.",
"horizon.friends.relay_stopped": "Trop de codes courts erronés : le relais est désactivé jusqu'à ce que vous partagiez à nouveau. Vos amis peuvent toujours rejoindre avec le code complet.",
"horizon.extras.pack_browser.tooltip": "La sélection des packs de ressources gagne un bouton Ajouter qui cherche sur Modrinth et installe un pack dans votre dossier de packs. Désactivé, le jeu ne contacte jamais Modrinth.",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"Opaque Ice": "Glace opaque",
"Draws ice in the solid pass, without its see-through texture.": "Dessine la glace dans la passe solide, sans sa texture transparente.",
"Hidden Faces": "Faces cachées",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Supprime les faces de feuilles derrière deux couches de feuilles : plus rapide, mêmes houppiers.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Entity Culling Precheck": "Pré-vérification du culling des entités",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Lance la pré-vérification AABB des créatures après ce nombre de créatures chargées. Zéro la désactive.",
"%s mobs": "%s créatures",
"Only applies to asynchronous entity culling.": "S'applique uniquement à l'occlusion asynchrone des entités.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
@@ -428,6 +617,24 @@
"Draws every particle.": "Dessine toutes les particules.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignore les particules hors écran, trop petites ou masquées par le terrain.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Ajouter un pack de ressources depuis Modrinth",
"horizon.pack.addWorking": "Interrogation de Modrinth...",
"horizon.pack.addNone": "Aucun pack de ressources trouvé",
"horizon.pack.addTitle": "Ajouter un pack de ressources",
"horizon.pack.install": "Installer",
"horizon.pack.installing": "Installation",
"horizon.pack.installed": "Installé",
"horizon.pack.search": "Rechercher",
"horizon.pack.searchHint": "Rechercher sur Modrinth...",
"horizon.pack.filters": "Filtres",
"horizon.pack.filter.realistic": "Réaliste",
"horizon.pack.filter.simplistic": "Simpliste",
"horizon.pack.filter.vanillaLike": "Style vanilla",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Impossible d'ajouter le pack : %s",
"horizon.extras.header.cosmetics": "Cosmétiques",
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
@@ -535,5 +742,308 @@
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
"horizon.options.repeat_interval": "Intervalle de répétition de navigation",
"horizon.options.milliseconds": "%s ms"
"horizon.options.milliseconds": "%s ms",
"horizon.pages.pvp": "PvP et HUD",
"horizon.pages.pvp.tooltip": "Réticule, HUD et réglages PvP, tous désactivés par défaut. Rien ici n'affiche ce que vanilla n'affiche pas.",
"horizon.pages.open.pvp": "PvP et HUD...",
"horizon.pvp.header.crosshair": "Réticule",
"horizon.pvp.crosshair": "Réticule personnalisé",
"horizon.pvp.crosshair_shape": "Forme",
"horizon.pvp.crosshair_size": "Taille",
"horizon.pvp.crosshair_gap": "Écart",
"horizon.pvp.crosshair_thickness": "Épaisseur",
"horizon.pvp.crosshair_colour": "Couleur",
"horizon.pvp.crosshair_outline": "Contour",
"horizon.pvp.crosshair_dot": "Point central",
"horizon.pvp.crosshair_hover": "Couleur sur les entités",
"horizon.pvp.crosshair.tooltip": "Dessine votre propre réticule à la place de celui de vanilla. Ses premiers réglages ressemblent à ceux de vanilla.",
"horizon.pvp.crosshair_shape.tooltip": "Une croix, un T, un X, un cercle, un carré ou seulement un point.",
"horizon.pvp.crosshair_size.tooltip": "La longueur de chaque branche, en pixels d'interface.",
"horizon.pvp.crosshair_gap.tooltip": "L'espace vide au centre.",
"horizon.pvp.crosshair_thickness.tooltip": "La largeur de chaque trait.",
"horizon.pvp.crosshair_colour.tooltip": "Inversé affiche la couleur en dessous, inversée, comme le fait le réticule de vanilla.",
"horizon.pvp.crosshair_outline.tooltip": "Un fin liseré noir, pour qu'il reste visible sur n'importe quel fond.",
"horizon.pvp.crosshair_dot.tooltip": "Un point au milieu du réticule.",
"horizon.pvp.crosshair_hover.tooltip": "Une autre couleur quand le réticule est sur une créature ou un joueur que vous pourriez toucher, comme le sait l'indicateur d'attaque de vanilla.",
"horizon.pvp.colour.inverted": "Inversé",
"horizon.pvp.colour.white": "Blanc",
"horizon.pvp.colour.red": "Rouge",
"horizon.pvp.colour.orange": "Orange",
"horizon.pvp.colour.yellow": "Jaune",
"horizon.pvp.colour.green": "Vert",
"horizon.pvp.colour.cyan": "Cyan",
"horizon.pvp.colour.blue": "Bleu",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Noir",
"horizon.pvp.shape.cross": "Croix",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Cercle",
"horizon.pvp.shape.square": "Carré",
"horizon.pvp.shape.dot": "Point",
"horizon.pvp.outline": "Contour de bloc personnalisé",
"horizon.pvp.outline.tooltip": "Change le contour du bloc que vous regardez.",
"horizon.pvp.outline_colour": "Couleur",
"horizon.pvp.outline_colour.tooltip": "La couleur du contour. Noir à 40 % est celui de vanilla.",
"horizon.pvp.outline_opacity": "Opacité",
"horizon.pvp.outline_opacity.tooltip": "À quel point le contour est opaque.",
"horizon.pvp.outline_thickness": "Épaisseur",
"horizon.pvp.outline_thickness.tooltip": "La largeur du contour, en multiples de celui de vanilla.",
"horizon.pvp.outline_fill": "Remplissage",
"horizon.pvp.outline_fill.tooltip": "Teinte légèrement les faces du bloc que vous regardez.",
"horizon.pvp.hit": "Couleur de coup personnalisée",
"horizon.pvp.hit.tooltip": "Change la couleur dont les créatures et les joueurs clignotent quand ils sont blessés. Rouge à 30 % est celle de vanilla.",
"horizon.pvp.hit_colour": "Couleur",
"horizon.pvp.hit_colour.tooltip": "La couleur dont clignotent les créatures et les joueurs blessés.",
"horizon.pvp.hit_opacity": "Opacité",
"horizon.pvp.hit_opacity.tooltip": "La part de couleur qui les recouvre.",
"horizon.pvp.header.outline": "Contour de bloc",
"horizon.pvp.header.hit": "Couleur de coup",
"horizon.pvp.hud_armour": "Armure",
"horizon.pvp.hud_armour.tooltip": "L'armure que vous portez, tête en premier, avec les utilisations restantes de chaque pièce.",
"horizon.pvp.hud_armour_corner": "Coin de l'armure",
"horizon.pvp.hud_armour_corner.tooltip": "Où l'armure s'affiche.",
"horizon.pvp.hud_armour_percent": "Durabilité en %",
"horizon.pvp.hud_armour_percent.tooltip": "Les utilisations restantes en pourcentage du maximum de la pièce, plutôt qu'en nombre.",
"horizon.pvp.hud_effects": "Effets",
"horizon.pvp.hud_effects.tooltip": "Vos effets avec leurs noms, niveaux et temps restant, à la place des icônes en haut à droite.",
"horizon.pvp.hud_effects_corner": "Coin des effets",
"horizon.pvp.hud_effects_corner.tooltip": "Où les effets s'affichent.",
"horizon.pvp.hud_held": "Nombre d'objets en main",
"horizon.pvp.hud_held.tooltip": "Combien d'exemplaires de l'objet en main votre inventaire contient ; des flèches avec un arc ou une arbalète.",
"horizon.pvp.hud_held_corner": "Coin du compteur en main",
"horizon.pvp.hud_held_corner.tooltip": "Où le compteur s'affiche.",
"horizon.pvp.hud_background": "Fonds des widgets",
"horizon.pvp.hud_background.tooltip": "Une boîte sombre derrière chaque widget.",
"horizon.pvp.hud_opacity": "Opacité du fond",
"horizon.pvp.hud_opacity.tooltip": "À quel point les boîtes derrière les widgets sont sombres.",
"horizon.pvp.hud_text_colour": "Couleur du texte",
"horizon.pvp.hud_text_colour.tooltip": "La couleur du texte des widgets. La durabilité garde son vert à rouge.",
"horizon.pvp.hud_shadow": "Ombre du texte",
"horizon.pvp.hud_shadow.tooltip": "Une ombre sous le texte des widgets, comme le texte du HUD de vanilla.",
"horizon.pvp.header.hud": "Widgets du HUD",
"horizon.pvp.corner.top_left": "En haut à gauche",
"horizon.pvp.corner.top_right": "En haut à droite",
"horizon.pvp.corner.bottom_left": "En bas à gauche",
"horizon.pvp.corner.bottom_right": "En bas à droite",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "La latence que le serveur vous rapporte, en millisecondes. Multijoueur uniquement.",
"horizon.pvp.hud_ping_corner": "Coin du ping",
"horizon.pvp.hud_ping_corner.tooltip": "Où le ping s'affiche.",
"horizon.pvp.hud_coords": "Coordonnées",
"horizon.pvp.hud_coords.tooltip": "Votre position en blocs et la direction que vous regardez, comme F3 les affiche. Masqué quand le monde ou le serveur réduit F3.",
"horizon.pvp.hud_coords_corner": "Coin des coordonnées",
"horizon.pvp.hud_coords_corner.tooltip": "Où les coordonnées s'affichent.",
"horizon.pvp.hud_server": "Adresse du serveur",
"horizon.pvp.hud_server.tooltip": "L'adresse du serveur sur lequel vous jouez. Multijoueur uniquement.",
"horizon.pvp.hud_server_corner": "Coin du serveur",
"horizon.pvp.hud_server_corner.tooltip": "Où l'adresse s'affiche.",
"horizon.pvp.hud_combo": "Compteur de combo",
"horizon.pvp.hud_combo.tooltip": "Les coups consécutifs que le serveur a confirmés, jusqu'à ce que vous soyez blessé ou que 3 secondes passent sans coup.",
"horizon.pvp.hud_combo_corner": "Coin du combo",
"horizon.pvp.hud_combo_corner.tooltip": "Où le combo s'affiche.",
"horizon.pvp.hud.ping": "Ping : %s ms",
"horizon.pvp.hud.coords": "XYZ : %s %s %s",
"horizon.pvp.hud.facing": "Direction : %s",
"horizon.pvp.hud.combo": "Combo : %s",
"horizon.pvp.hud.direction.north": "Nord",
"horizon.pvp.hud.direction.south": "Sud",
"horizon.pvp.hud.direction.east": "Est",
"horizon.pvp.hud.direction.west": "Ouest",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Images dessinées pendant la dernière seconde.",
"horizon.pvp.hud_fps_corner": "Coin des FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Où les FPS s'affichent.",
"horizon.pvp.hud_cps": "Clics par seconde",
"horizon.pvp.hud_cps.tooltip": "Vos clics gauche et droit pendant la dernière seconde.",
"horizon.pvp.hud_cps_corner": "Coin des CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Où les clics par seconde s'affichent.",
"horizon.pvp.hud_keys": "Touches",
"horizon.pvp.hud_keys.tooltip": "Vos touches de déplacement, boutons de souris et saut, allumés tant que vous les tenez, avec les clics par seconde.",
"horizon.pvp.hud_keys_corner": "Coin des touches",
"horizon.pvp.hud_keys_corner.tooltip": "Où les touches s'affichent.",
"horizon.pvp.hud_clock": "Horloge",
"horizon.pvp.hud_clock.tooltip": "L'heure de votre ordinateur.",
"horizon.pvp.hud_clock_corner": "Coin de l'horloge",
"horizon.pvp.hud_clock_corner.tooltip": "Où l'horloge s'affiche.",
"horizon.pvp.hud_reach": "Portée",
"horizon.pvp.hud_reach.tooltip": "À quelle distance de vos yeux votre dernier coup a porté, pendant 3 secondes. Ce n'est qu'une mesure : votre portée reste celle de vanilla.",
"horizon.pvp.hud_reach_corner": "Coin de la portée",
"horizon.pvp.hud_reach_corner.tooltip": "Où la portée s'affiche.",
"horizon.pvp.hud_speed": "Vitesse",
"horizon.pvp.hud_speed.tooltip": "À quelle vitesse vous vous déplacez au sol, en blocs par seconde.",
"horizon.pvp.hud_speed_corner": "Coin de la vitesse",
"horizon.pvp.hud_speed_corner.tooltip": "Où la vitesse s'affiche.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS : %s | %s",
"horizon.pvp.hud.reach": "Portée : %s",
"horizon.pvp.hud.speed": "Vitesse : %s b/s",
"horizon.pvp.hud.keys.attack": "Clic G",
"horizon.pvp.hud.keys.use": "Clic D",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Niveaux d'enchantement en chiffres",
"horizon.pvp.numeric_enchants.tooltip": "Les infobulles et la table d'enchantement affichent Tranchant 5 au lieu de Tranchant V.",
"horizon.pvp.header.items": "Objets",
"horizon.pvp.fire_lower": "Feu plus bas",
"horizon.pvp.fire_lower.tooltip": "Descend les flammes que vous voyez en brûlant, pour qu'elles couvrent moins l'écran.",
"horizon.pvp.shield_size": "Taille du bouclier",
"horizon.pvp.shield_size.tooltip": "La taille du bouclier en main, à la première personne.",
"horizon.pvp.totem_size": "Taille de l'animation du totem",
"horizon.pvp.totem_size.tooltip": "La taille du totem qui remplit l'écran quand il vous sauve.",
"horizon.pvp.glint_colour": "Couleur du reflet d'enchantement",
"horizon.pvp.glint_colour.tooltip": "La couleur du scintillement sur les objets et armures enchantés. Désactivé, c'est le violet de vanilla.",
"horizon.pvp.clear_water": "Eau limpide",
"horizon.pvp.clear_water.tooltip": "Sous l'eau, voyez aussi loin que vanilla le permet après 30 secondes, dès le départ.",
"horizon.pvp.clear_lava": "Lave limpide",
"horizon.pvp.clear_lava.tooltip": "Dans la lave, voyez aussi loin que la Résistance au feu de vanilla le permet, sans l'effet.",
"horizon.pvp.header.visual": "Ajustements visuels",
"horizon.pvp.view_time": "Heure du jour",
"horizon.pvp.view_time.tooltip": "L'heure du jour que vous voyez : seulement le ciel et sa lumière. Le temps du monde continue et rien n'est envoyé au serveur.",
"horizon.pvp.view_weather": "Météo",
"horizon.pvp.view_weather.tooltip": "La météo que vous voyez : seulement la pluie, sa lumière et le brouillard. La météo du monde continue et rien n'est envoyé au serveur.",
"horizon.pvp.header.view": "Votre vue",
"horizon.pvp.view.world": "Celle du monde",
"horizon.pvp.time.morning": "Matin",
"horizon.pvp.time.noon": "Midi",
"horizon.pvp.time.sunset": "Coucher du soleil",
"horizon.pvp.time.midnight": "Minuit",
"horizon.pvp.weather.clear": "Dégagé",
"horizon.pvp.weather.rain": "Pluie",
"horizon.pvp.weather.thunder": "Orage",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Message copié",
"horizon.chat.search": "Rechercher dans le chat :",
"horizon.pvp.chat_timestamps": "Horodatage",
"horizon.pvp.chat_timestamps.tooltip": "L'heure d'arrivée de chaque message, devant lui.",
"horizon.pvp.chat_compact": "Chat compact",
"horizon.pvp.chat_compact.tooltip": "Un message répété à la suite ne s'affiche qu'une fois, avec le nombre de fois : (x3).",
"horizon.pvp.chat_copy": "Copier au clic",
"horizon.pvp.chat_copy.tooltip": "Cliquer sur un message dans le chat ouvert copie son texte, sauf si le clic fait autre chose.",
"horizon.pvp.chat_search": "Recherche dans le chat",
"horizon.pvp.chat_search.tooltip": "Ctrl+F dans le chat ouvert : ce que vous tapez n'affiche que les messages qui le contiennent. Entrée ou Ctrl+F à nouveau arrête.",
"horizon.pvp.preset": "Préréglage PvP",
"horizon.pvp.preset.tooltip": "Active le réticule personnalisé, les widgets d'armure et d'effets, le sprint et l'accroupissement à bascule, le feu plus bas, et les mods compteur de FPS et touches s'ils sont installés. Vos réglages de chacun restent.",
"horizon.pvp.hud_editor_key": "Touche de l'éditeur de HUD",
"horizon.pvp.hud_editor_key.tooltip": "Ouvre l'éditeur de HUD depuis le jeu. Cliquez, puis appuyez sur une touche ; Échap la retire.",
"horizon.pvp.editor": "Modifier le HUD",
"horizon.pvp.editor.tooltip": "Déplacez les widgets sur le jeu : faites-les glisser n'importe où, redimensionnez-les avec la molette, clic droit sur l'un pour le remettre dans son coin.",
"horizon.pvp.editor.hint": "Glisser les widgets · Molette : taille · Clic droit : retour au coin",
"horizon.pvp.combat_feel": "Sensation de combat",
"horizon.pvp.combat_feel.tooltip": "Legacy dessine les combats comme en 1.8.9 : l'objet en main, son balancement, le fait de manger, l'arc et une épée levée tant que vous maintenez l'utilisation, sans la plongée après chaque coup ni l'indicateur d'attaque. Seulement l'apparence : ce que vous envoyez à un serveur reste pareil.",
"horizon.pvp.combat_indicator": "Indicateur d'attaque en Legacy",
"horizon.pvp.combat_indicator.tooltip": "Garde l'indicateur d'attaque sous Legacy, que 1.8.9 n'avait pas.",
"horizon.pvp.combat_feel.modern": "Moderne",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Sensation de combat",
"horizon.pvp.combat_sounds": "Sons de coup de 1.8",
"horizon.pvp.combat_sounds.tooltip": "Sous Legacy, omet les sons d'attaque ajoutés en 1.9 (fort, faible, balayage, critique...). Les sons de blessure restent.",
"horizon.pvp.combat_rules": "Combat 1.8 dans les mondes que vous hébergez",
"horizon.pvp.combat_rules.tooltip": "Le solo et les mondes que vous ouvrez en LAN se battent selon les règles de 1.8.9 : pas de temps de recharge d'attaque ni de balayage, dégâts d'arme de 1.8, Force, Tranchant et coups critiques. Les serveurs que vous rejoignez gardent leurs propres règles.",
"Bindings": "Boutons",
"Play": "Jeu",
"Device": "Appareil",
"Look": "Vue",
"Movement": "Déplacement",
"Menus": "Menus",
"Actions": "Actions",
"Reset": "Réinitialiser",
"Detected": "Détecté",
"Glyphs": "Symboles",
"Feedback": "Retour",
"Controller": "Manette",
"How the sticks turn and move you, and how menus repeat and scroll.": "Comment les sticks vous font tourner et avancer, et comment les menus se répètent et défilent.",
"Which button does what. Each player has their own.": "Quel bouton fait quoi. Chaque joueur a les siens.",
"The controller the game sees, its button glyphs and its feedback.": "La manette que le jeu voit, les symboles de ses boutons et son retour.",
"Press this row, then the button that should do it.": "Appuyez sur cette ligne, puis sur le bouton qui doit le faire.",
"Off, the game ignores every controller for this player.": "Désactivé, le jeu ignore toutes les manettes pour ce joueur.",
"How fast the right stick turns the view.": "À quelle vitesse le stick droit fait tourner la vue.",
"Turn the view the other way, left and right.": "Fait tourner la vue dans l'autre sens, de gauche à droite.",
"Turn the view the other way, up and down.": "Fait tourner la vue dans l'autre sens, de haut en bas.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Jusqu'où le stick droit bouge avant que la vue tourne. Augmentez-le si la vue dérive.",
"Hold the button to sneak, or press it once to start and again to stop.": "Maintenez le bouton pour s'accroupir, ou appuyez une fois pour commencer et de nouveau pour arrêter.",
"How far the left stick moves before you walk. Raise it if you drift.": "Jusqu'où le stick gauche bouge avant que vous marchiez. Augmentez-le si vous dérivez.",
"How far a trigger is pulled before it counts as pressed.": "Jusqu'où une gâchette est enfoncée avant d'être comptée comme pressée.",
"Shows what your buttons do right now, in play and in inventories.": "Montre ce que font vos boutons maintenant, en jeu et dans les inventaires.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Montre seulement ce que vous pouvez faire avec ce que vous regardez, et les boutons principaux de l'inventaire.",
"Doesn't show what your buttons do.": "N'affiche pas ce que font vos boutons.",
"Shows the buttons of the controller you're using.": "Montre les boutons de la manette que vous utilisez.",
"Always shows Xbox buttons.": "Montre toujours les boutons Xbox.",
"Always shows PlayStation buttons.": "Montre toujours les boutons PlayStation.",
"Always shows Nintendo buttons.": "Montre toujours les boutons Nintendo.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, à droite, confirme et saute, et B revient en arrière, comme sur une Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, en bas, confirme et saute, et A revient en arrière, comme sur les autres manettes.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Ouvre le clavier de Steam sur un Steam Deck sous Steam, et celui de Horizon sinon.",
"Always opens Horizon's keyboard.": "Ouvre toujours le clavier de Horizon.",
"Always opens Steam's keyboard.": "Ouvre toujours le clavier de Steam.",
"A DualSense's light shows your health, from green through amber to red.": "La lumière d'une DualSense montre votre santé, du vert à l'orange puis au rouge.",
"A DualSense's light changes with the dimension you're in.": "La lumière d'une DualSense change selon la dimension où vous êtes.",
"A DualSense keeps its own light.": "Une DualSense garde sa propre lumière.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permet de fabriquer avec LT + A sur les serveurs, pas seulement dans vos propres mondes. Certains serveurs le considèrent comme une macro.",
"Show the buttons for what you can do, while you play with a controller.": "Affiche les boutons de ce que vous pouvez faire pendant que vous jouez à la manette.",
"How fast the stick moves the crosshair in inventories.": "À quelle vitesse le stick déplace le réticule dans les inventaires.",
"How fast the right stick scrolls a list.": "À quelle vitesse le stick droit fait défiler une liste.",
"How long a held direction waits before it repeats.": "Combien de temps une direction maintenue attend avant de se répéter.",
"How long a held direction takes between repeats.": "Combien de temps une direction maintenue met entre deux répétitions.",
"Which on-screen keyboard opens when a text field is pressed.": "Quel clavier à l'écran s'ouvre quand on appuie dans un champ de texte.",
"Every action back on its default button.": "Remet chaque action sur son bouton par défaut.",
"These settings belong to this player's profile.": "Ces réglages appartiennent au profil de ce joueur.",
"The controller driving this player, or else the first one connected.": "La manette qui pilote ce joueur, ou sinon la première connectée.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Le fabricant et le produit USB que le système rapporte. Utile pour signaler une manette qui se comporte mal.",
"The glyph family the game picked from the controller's maker.": "La famille de symboles que le jeu a choisie d'après le fabricant de la manette.",
"Follow the controller, or always show one family's buttons.": "Suivre la manette, ou toujours montrer les boutons d'une même famille.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si les boutons A et B d'une manette Nintendo fonctionnent par leur position ou par leur lettre.",
"The buttons menus show right now.": "Les boutons que les menus montrent maintenant.",
"Rumble when something happens to you.": "Vibre quand quelque chose vous arrive.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "La manette continue de jouer quand cette fenêtre n'a pas le focus, et le perdre ne met pas en pause.",
"A DualSense's triggers push back on bows and tools.": "Les gâchettes d'une DualSense résistent sur les arcs et les outils.",
"What a DualSense's light shows, over USB or Bluetooth.": "Ce que montre la lumière d'une DualSense, en USB ou en Bluetooth.",
"Player profile": "Profil du joueur",
"Vendor and product": "Fabricant et produit",
"Detected glyphs": "Symboles détectés",
"Glyphs in use": "Symboles utilisés",
"Unknown": "Inconnu",
"No controller": "Aucune manette",
"Editing: %s": "Modification : %s",
"Press the button for this. %s cancels.": "Appuyez sur le bouton pour cela. %s annule.",
"This button also does: %s": "Ce bouton fait aussi : %s",
"horizon.pad.notice.connected.press": "Appuyez pour l'utiliser",
"horizon.recipe_transfer.missing": "Objets manquants",
"horizon.recipe_transfer.no_room": "Inventaire plein",
"horizon.recipe_transfer.unavailable": "La recette ne tient pas dans ce menu",
"horizon.recipe_transfer.unsupported": "Le serveur exige une recette connue",
"horizon.studio.timeline.freecam_on": "Caméra libre : OUI",
"horizon.studio.timeline.freecam_off": "Caméra libre : NON",
"horizon.studio.timeline.freecam_short": "Cam.",
"horizon.studio.timeline.freecam_active_short": "Cam. ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Image",
"horizon.studio.timeline.delete_keyframe_short": "− Suppr.",
"horizon.studio.timeline.split": "Diviser",
"horizon.studio.timeline.marker": "Marqueur %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s scènes",
"horizon.studio.timeline.scene_title": "Scène %s",
"horizon.studio.timeline.sequence": "Séquence",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Fichier",
"horizon.studio.topbar.camera": "Caméra",
"horizon.studio.topbar.view": "Vue",
"horizon.studio.topbar.file_short": "Fich.",
"horizon.studio.topbar.camera_short": "Cam.",
"horizon.studio.topbar.view_short": "Vue",
"horizon.studio.topbar.game_pause_menu": "⏸ Menu de pause",
"horizon.studio.topbar.pause_short": "Pause",
"horizon.studio.topbar.open_recordings": "📁 Ouvrir le dossier des enregistrements",
"horizon.studio.topbar.close_replay": "✕ Fermer le replay",
"horizon.studio.topbar.exit": "Quitter",
"horizon.studio.topbar.toggle_freecam": "Basculer la caméra libre",
"horizon.studio.topbar.reset_to_player": "👤 Revenir au joueur",
"horizon.studio.topbar.unlock_freecam": "🔓 Libérer la caméra libre",
"horizon.studio.topbar.lock_to_spline": "🔒 Fixer à la courbe",
"horizon.studio.topbar.add_keyframe": "➕ Ajouter une image clé",
"horizon.studio.topbar.add_marker": "Ajouter un marqueur",
"horizon.studio.topbar.clear_keyframes": "🗑 Effacer les images clés",
"horizon.studio.topbar.timeline_hud": "⏱ Chronologie",
"horizon.studio.topbar.split_scene": "Diviser la scène"
}
@@ -3,27 +3,127 @@
"horizon.extras.pregen": "Pré-gerar chunks",
"horizon.extras.pregen.tooltip": "Habilita /pregen nos seus mundos de um jogador e em LAN. Com comandos habilitados, use /pregen start para gerar uma área antes de explorá-la. Não habilita o comando nos servidores em que você entra.",
"horizon.options.controller": "Controlador",
"horizon.options.three_d_skin_layers": "Camadas de skin 3D",
"horizon.options.vibration": "Vibração",
"horizon.options.play_in_background": "Jogar em segundo plano",
"horizon.options.adaptive_triggers": "Gatilhos adaptativos",
"horizon.options.gameplay_guide": "Guia de jogo",
"horizon.options.gameplay_guide.basic": "Básico",
"horizon.options.gameplay_guide.full": "Completo",
"horizon.options.gameplay_guide.off": "Desligado",
"horizon.controller.guide.use": "Usar",
"horizon.controller.guide.open": "Abrir",
"horizon.controller.guide.ride": "Montar",
"horizon.controller.guide.trade": "Negociar",
"horizon.controller.guide.feed": "Alimentar",
"horizon.controller.guide.place": "Colocar",
"horizon.controller.guide.eat": "Comer",
"horizon.controller.guide.drink": "Beber",
"horizon.controller.guide.shoot": "Atirar",
"horizon.controller.guide.load": "Carregar",
"horizon.controller.guide.throw": "Arremessar",
"horizon.controller.guide.raise": "Erguer",
"horizon.controller.guide.equip": "Equipar",
"horizon.controller.guide.fish": "Pescar",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Acelerar",
"horizon.controller.guide.break": "Quebrar",
"horizon.controller.guide.attack": "Atacar",
"horizon.controller.guide.spectate": "Assistir",
"horizon.controller.guide.spectator_menu": "Menu de espectador",
"horizon.controller.guide.release_shoot": "Solte para atirar",
"horizon.controller.guide.release_throw": "Solte para arremessar",
"horizon.controller.guide.release_stop": "Solte para parar",
"horizon.controller.guide.fly": "Voar",
"horizon.controller.guide.fly_up": "Subir voando",
"horizon.controller.guide.fly_down": "Descer voando",
"horizon.controller.guide.swim_up": "Subir nadando",
"horizon.controller.guide.swim_down": "Descer nadando",
"horizon.controller.guide.glide": "Planejar",
"horizon.controller.guide.dismount": "Desmontar",
"horizon.controller.guide.stand": "Levantar-se",
"horizon.controller.guide.drop": "Soltar item",
"horizon.controller.guide.swap": "Trocar de mão",
"horizon.controller.guide.take": "Pegar",
"horizon.controller.guide.take_half": "Pegar metade",
"horizon.controller.guide.put": "Depositar",
"horizon.controller.guide.put_one": "Depositar um",
"horizon.controller.guide.swap_stack": "Trocar",
"horizon.controller.guide.quick_move": "Mover rápido",
"horizon.controller.guide.drop_stack": "Soltar pilha",
"horizon.controller.guide.place_recipe": "Colocar receita",
"horizon.controller.guide.place_recipe_all": "Colocar tudo",
"horizon.controller.guide.alternatives": "Alternativas",
"horizon.controller.guide.select": "Selecionar",
"horizon.controller.guide.close": "Fechar",
"horizon.controller.guide.pages": "Páginas",
"horizon.controller.guide.categories": "Categorias",
"horizon.controller.guide.tabs": "Abas",
"horizon.options.lightbar": "Barra de luzes",
"horizon.options.look_sensitivity": "Sensibilidade da câmera",
"horizon.options.invert_x": "Inverter câmera X",
"horizon.options.invert_y": "Inverter câmera Y",
"horizon.options.craft_on_servers": "Criação rápida em servidores",
"horizon.controller.direct_craft": "Criação rápida",
"horizon.options.sneak_button": "Botão de agachar",
"horizon.options.move_deadzone": "Zona morta do movimento",
"horizon.options.look_deadzone": "Zona morta da câmera",
"horizon.options.trigger_threshold": "Limiar do gatilho",
"horizon.options.button_glyphs": "Glifos de botão",
"horizon.options.on_screen_keyboard": "Teclado na tela",
"horizon.options.chat_delay": "Atraso no bate-papo",
"horizon.options.lightbar.health": "Saúde",
"horizon.options.lightbar.dimension": "Dimensão",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "Playstation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Botões da Nintendo",
"horizon.options.nintendo_buttons.position": "Por posição",
"horizon.options.nintendo_buttons.letter": "Por letra",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizonte",
"horizon.options.keyboard.steam": "Vapor",
"horizon.options.unavailable": "Este recurso ainda não está disponível no Horizon.",
"horizon.options.search": "Configurações de pesquisa...",
"horizon.studio.record.usage": "Uso: /studio record <start [nome]|stop|status>",
"horizon.studio.record.started": "Gravação do estúdio iniciada: %s",
"horizon.studio.record.saved": "Gravação do estúdio salva: %s",
"horizon.studio.record.error": "Erro na gravação do estúdio: %s",
"horizon.studio.record.status.active": "Gravação do estúdio ativa — %s s, %s pacotes, %s KB (%s)",
"horizon.studio.record.status.inactive": "Gravação do estúdio inativa",
"horizon.studio.replay.usage": "Uso: /studio replay <open CAMINHO|pause|resume|speed MULTIPLICADOR|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Uso: /studio replay open <CAMINHO>",
"horizon.studio.replay.opened": "Reprodução do estúdio aberta: %s (%s s, %s pacotes)",
"horizon.studio.replay.open_error": "Não foi possível abrir a reprodução: %s",
"horizon.studio.replay.paused": "Reprodução do estúdio pausada",
"horizon.studio.replay.resumed": "Reprodução do estúdio retomada",
"horizon.studio.replay.inactive": "Nenhuma reprodução do estúdio ativa",
"horizon.studio.replay.speed.usage": "Uso: /studio replay speed <MULTIPLICADOR>",
"horizon.studio.replay.speed.set": "Velocidade da reprodução do estúdio definida para %s×",
"horizon.studio.replay.speed.invalid": "Velocidade de reprodução inválida",
"horizon.studio.replay.seek.usage": "Uso: /studio replay seek <MILISSEGUNDOS>",
"horizon.studio.replay.seeked": "Reprodução do estúdio avançada para %s ms (%s s)",
"horizon.studio.replay.seek.error": "Não foi possível avançar a reprodução: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Retroceder só está disponível para uma reprodução autônoma.",
"horizon.studio.replay.seek.invalid": "Posição de reprodução inválida",
"horizon.studio.replay.status": "Reprodução do estúdio: %s / %s ms (%s%%), velocidade %s×, pausada %s, concluída %s",
"horizon.studio.replay.closed": "Reprodução do estúdio fechada",
"horizon.studio.freecam.enabled": "Câmera livre do estúdio ativada. Use WASD para se mover, Espaço e Shift para subir ou descer, e Control para ir mais rápido.",
"horizon.studio.freecam.disabled": "Câmera livre do estúdio desativada",
"horizon.studio.freecam.unavailable": "A câmera livre do estúdio não encontrou uma câmera de jogador",
"horizon.studio.freecam.replay_required": "A câmera livre do estúdio fica disponível durante a reprodução de uma gravação",
"horizon.studio.freecam.usage": "Uso: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Quadro-chave de câmera adicionado em %s ms. O caminho é salvo quando você fecha a reprodução.",
"horizon.studio.keyframe.cleared": "Caminho da câmera limpo. A alteração é salva quando você fecha a reprodução.",
"horizon.studio.keyframe.removed": "Quadro-chave de câmera %s removido. A alteração é salva quando você fecha a reprodução.",
"horizon.studio.keyframe.save_error": "Não foi possível salvar o caminho da câmera: %s",
"horizon.studio.keyframe.remove_usage": "Uso: /studio keyframe remove <ÍNDICE>",
"horizon.studio.keyframe.index_invalid": "O índice do quadro-chave de câmera está fora do intervalo",
"horizon.studio.keyframe.usage": "Uso: /studio keyframe <add|remove ÍNDICE|clear>",
"horizon.studio.path.locked": "Reprodução do caminho da câmera do estúdio ativada",
"horizon.studio.path.unlocked": "Reprodução do caminho da câmera do estúdio parada",
"horizon.studio.path.usage": "Uso: /studio path <lock|unlock> (adicione pelo menos um quadro-chave primeiro)",
"horizon.pages.mods": "Modificações",
"horizon.pages.shader_packs": "Pacotes de sombreadores",
"horizon.pages.shader_options": "Opções de sombreamento",
@@ -31,6 +131,7 @@
"horizon.pages.controller_binds": "Ligações de controlador",
"horizon.pages.extras": "Extras Horizonte",
"horizon.pages.settings": "Configurações do Horizon",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "Agente de IA",
"horizon.split.leave": "Sair",
"horizon.pages.split_settings": "Configurações de tela dividida",
@@ -51,6 +152,7 @@
"horizon.pages.music.empty": "Sem faixas de música: os sons do jogo não são carregados.",
"horizon.pages.music.tooltip": "As faixas integradas, por situação: toque uma agora ou desligue alguma para que o jogo pare de selecioná-la.",
"horizon.pages.settings.tooltip": "Configurações próprias do Horizon: gráficos, controladores, extras e mods.",
"horizon.pages.studio.tooltip": "Navegue pelas gravações salvas e seus metadados.",
"horizon.pages.graphics.tooltip": "Gráficos, simplesmente: cada configuração diz quanto custa.",
"horizon.pages.graphics_advanced.tooltip": "Todas as configurações gráficas, incluindo Horizon, com o custo de cada uma.",
"horizon.pages.controller.tooltip": "Controladores: seus botões, manípulos, vibração e navegação no menu.",
@@ -65,11 +167,20 @@
"horizon.pages.open.shader_packs": "Pacotes de sombreadores...",
"horizon.pages.open.controller": "Configurações do controlador...",
"horizon.pages.open.split_setup": "Tela dividida...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Modos...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos Horizonte...",
"horizon.pages.open.controller_binds": "Ligações do controlador...",
"horizon.pages.open.horizon_settings": "Configurações do Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Gravações Salvas",
"horizon.studio.empty": "Nenhuma gravação encontrada na pasta de gravações.",
"horizon.studio.empty_hint": "Use /studio record start <nome> em um mundo, ou coloque arquivos .mcpr lá.",
"horizon.studio.open_folder": "Abrir gravações",
"horizon.studio.open_replay": "Reproduzir gravação",
"horizon.studio.refresh": "Atualizar",
"horizon.studio.unknown_server": "Desconhecido",
"horizon.pages.open.music": "Música...",
"horizon.mods.empty": "Sem mods: coloque os arquivos .hmod na pasta horizonte-mods",
"horizon.mods.stopped": " (Parado)",
@@ -78,6 +189,28 @@
"horizon.mods.settings": "Configurações",
"horizon.mods.in_world": " (deixe o mundo mudar)",
"horizon.mods.open_folder": "Abra a pasta Mod",
"horizon.mods.browse": "Explorar mods...",
"horizon.mods.browse.title": "Explorar mods",
"horizon.mods.browse.search": "Pesquisar mods...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Lendo o registro de mods do Horizon...",
"horizon.mods.browse.failed": "Não foi possível ler o registro de mods: %s",
"horizon.mods.browse.none": "Nenhum mod encontrado",
"horizon.mods.browse.has_update": " (atualização)",
"horizon.mods.browse.is_installed": " (instalado)",
"horizon.mods.browse.by": "por %s",
"horizon.mods.browse.installing": "Instalando...",
"horizon.mods.browse.install": "Instalar",
"horizon.mods.browse.update": "Atualizar",
"horizon.mods.browse.installed": "Instalado",
"horizon.mods.browse.open_page": "Abrir página",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Registro de alterações",
"horizon.mods.browse.done": "%s %s está instalado e ligado.",
"horizon.mods.browse.install_failed": "Não foi possível instalar: %s",
"horizon.mods.switch.worldgen": "muda a geração do mundo",
"horizon.mods.switch.registries": "adiciona blocos, itens ou outras entradas de registro",
"horizon.mods.switch.restart": "Reinicie o jogo para aplicar %s: eles adicionam blocos, itens ou geração de mundo.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copiar detalhes da conexão",
"horizon.multiplayer.join": "Junte-se",
@@ -89,6 +222,7 @@
"LOD Distance": "Distância de LOD",
"Far Detail": "Detalhe distante",
"Cave Culling": "Abate em Caverna",
"GPU Culling": "Abate na GPU",
"Entity Culling": "Seleção de entidade",
"Chunk Updates": "Atualizações de blocos",
"Upload Budget": "Carregar orçamento",
@@ -100,11 +234,17 @@
"Frame Pacing": "Ritmo de quadro",
"Worker Threads": "Threads de trabalho",
"Resolution Scale": "Escala de resolução",
"Anti-aliasing": "Antisserrilhado",
"Upscaling": "Aumento de escala",
"Upscaling Sharpness": "Nitidez do aumento de escala",
"Paper White": "Papel Branco",
"Peak Brightness": "Brilho máximo",
"Black Level": "Nível de preto",
"UI Brightness": "Brilho da IU",
"HDR Lighting": "Iluminação HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Fontes de luz, o sol e a luz solar brilham além do branco. Desligado: igual ao SDR.",
"Smooth Gradients": "Gradientes suaves",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Quadro de 16 bits: céus escuros e sombras não formam faixas. Um pouco mais de VRAM. Próxima inicialização.",
"Battery Mode": "Modo Bateria",
"Graphics API": "API gráfica",
"Translucent Batching": "Lote Translúcido",
@@ -118,6 +258,7 @@
"Performance": "Desempenho",
"Balanced": "Equilibrado",
"Quality": "Qualidade",
"Ultra": "Ultra",
"Ultra Performance": "Ultradesempenho",
"Native": "Nativo",
"Full": "Completo",
@@ -125,22 +266,32 @@
"Efficient": "Eficiente",
"Precise": "Preciso",
"Auto (VSync)": "Automático (VSync)",
"FIFO": "FIFO",
"Mailbox": "Caixa de correio",
"Immediate": "Imediato",
"Vulkan": "Vulcano",
"DirectX 12": "DirectX12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS e gráfico",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Sem custo",
"Low cost": "Baixo custo",
"Medium cost": "Custo médio",
"High cost": "Alto custo",
"Saves time": "Economiza tempo",
"HDR": "HDR",
"Graphics": "Gráficos",
"Done": "Concluído",
"This feature is not yet available in Horizon.": "Este recurso ainda não está disponível no Horizon.",
"Your display or system doesn't offer HDR output.": "Seu monitor ou sistema não oferece saída HDR.",
"Turn HDR on to use it.": "Ative o HDR para usá-lo.",
"Your GPU or graphics API doesn't support it.": "Sua GPU ou API gráfica não oferece suporte.",
"Not with software rendering, which runs it on the CPU.": "Não com renderização por software, que o executa na CPU.",
"Out of video memory: off until the next start.": "Sem memória de vídeo: desligado até a próxima inicialização.",
"The graphics device was lost: off until the next start.": "O dispositivo gráfico foi perdido: desligado até a próxima inicialização.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Ainda não no DirectX 12: tente a API gráfica Vulkan.",
"%s chunks": "%s pedaços",
"%s per frame": "%s por quadro",
"%s MiB/frame": "%s MiB/quadro",
@@ -163,6 +314,8 @@
"How far mobs, crops and machines keep working. CPU time.": "Até onde mobs, colheitas e máquinas continuam trabalhando. Tempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A que distância os mobs e os itens são atraídos, em proporção ao normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignora terreno escondido atrás de seções fechadas. Fora custa muito.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Abate o terreno próximo na GPU: menos CPU com distância de renderização alta, mais GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automático: tenta onde sua CPU limita o quadro e mantém se os quadros ficarem mais rápidos.",
"Hides entities behind opaque blocks.": "Oculta entidades atrás de blocos opacos.",
"No entity occlusion culling.": "Nenhuma seleção de oclusão de entidade.",
"CPU occlusion test runs this frame.": "O teste de oclusão da CPU executa este quadro.",
@@ -186,6 +339,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels mundiais por pixel da tela. Abaixo de 100% é mais rápido na GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza bordas irregulares. FXAA: mais suave. TAA: coisas mais suaves e em movimento podem deixar rastros.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Torna o mundo menor e o reconstrói com TAA. Mais rápido, mais suave.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Deixa a imagem ampliada mais nítida. Mais alto: bordas mais nítidas, mais cintilação.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Saída HDR em uma tela HDR: lava, fogo e luz solar brilham mais intensamente.",
"How frames reach the screen while VSync is off.": "Como os frames chegam à tela enquanto o VSync está desligado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente dorme até o próximo quadro; Giros precisos, como baunilha.",
@@ -205,6 +359,7 @@
"Frame rate and frame times over the game, with a graph.": "Taxa de quadros e tempos de quadros ao longo do jogo, com gráfico.",
"horizon.multiplayer.finding_friend": "Encontrando o mundo do seu amigo...",
"horizon.multiplayer.not_signed_in": "Este servidor está em modo online. Faça login com sua conta da Microsoft no inicializador do Horizon para ingressar.",
"horizon.realms.not_signed_in": "Entre com sua conta Microsoft no launcher do Horizon para jogar nos Realms.",
"horizon.multiplayer.invalid_address": "Endereço de servidor inválido: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado diretamente ao seu amigo...",
"horizon.multiplayer.connected_friend_relay": "Conectado ao seu amigo através do relé...",
@@ -215,6 +370,7 @@
"Shortcut": "Atalho",
"Distance": "Distância",
"Display": "Exibição",
"Interface": "Interface",
"Sky and weather": "Céu e clima",
"Lighting and entities": "Iluminação e entidades",
"Textures": "Texturas",
@@ -257,9 +413,12 @@
"horizon.extras.no_ai": "Sem IA",
"horizon.extras.agent_control": "Controle de Agente",
"horizon.extras.discord_presence": "Presença rica em discórdia",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: instale em Mods",
"horizon.extras.splitscreen": "Tela dividida",
"horizon.extras.chat_animation": "Bate-papo tranquilo",
"horizon.extras.chat_indicator": "Indicador de mensagem",
"horizon.extras.wheel_cycles_list_settings.tooltip": "A roda de rolagem sobre um botão em uma lista de configurações altera seu valor. Desligado, como na vanilla, a roda rola a lista.",
"horizon.extras.wheel_cycles_settings": "A roda de rolagem altera as configurações",
"horizon.extras.pack_browser": "Navegador de pacote de recursos",
"horizon.extras.fullbright.tooltip": "Mostre blocos e entidades em plena luz, mesmo em áreas escuras.",
@@ -320,6 +479,7 @@
"horizon.friends.stopped": "Parou de compartilhar o código de convite.",
"horizon.friends.short_code_changed": "Alguém continuou tentando um código curto errado, então ele mudou.",
"horizon.friends.wrong_code": "Alguém tentou um código errado.",
"horizon.friends.relay_stopped": "Muitos códigos curtos errados, então o relé fica desligado até você compartilhar de novo. Amigos ainda podem entrar com o código completo.",
"horizon.friends.code_ready": "Seu código de convite está pronto: Menu do Jogo, Jogue com Amigos. Copie e envie para seus amigos.",
"horizon.friends.share_failed": "Não foi possível compartilhar o mundo: %s",
"horizon.friends.finding_route": "Descobrir como os amigos podem entrar em contato com você…",
@@ -339,6 +499,7 @@
"horizon.splitscreen.setup.new_window": "Nova janela",
"horizon.splitscreen.setup.fill_monitor": "Preencha %s",
"horizon.splitscreen.setup.done": "Concluído",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Ligado",
"horizon.splitscreen.setup.off": "Desligado",
"horizon.splitscreen.setup.player": "Jogador %s",
@@ -396,6 +557,9 @@
"horizon.splitscreen.setup.help.give_up_pad": "Seu controlador para de dirigir você e é convidado a entrar (A).",
"horizon.splitscreen.setup.note.held_at": "%s a %s: A deixa cair aqui, B coloca de volta.",
"horizon.splitscreen.setup.note.held": "%s pegou: esquerda/direita escolhe onde, A deixa cair, B coloca de volta.",
"horizon.splitscreen.online_only": "Este servidor está no modo online: perfis off-line não podem entrar nele, cada jogador precisa da própria conta Minecraft.",
"horizon.splitscreen.own_account": "Este servidor precisa da conta Minecraft de cada jogador",
"horizon.splitscreen.local_only": "Outros jogadores só podem entrar neste servidor com a própria conta Minecraft: perfis off-line jogam no seu mundo, na LAN ou em um servidor na sua rede local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos atrás de blocos opacos. Async verifica fora do thread; entidades marcam.",
"Block Entities": "Bloquear entidades",
"Shulker Boxes": "Caixas Shulker",
@@ -414,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "Reutiliza a geometria da estátua até que o estado do bloco mude.",
"Leaf Culling": "Abate de folhas",
"Every leaf face. Vanilla's fancy leaves.": "Cada rosto de folha. Folhas elegantes de baunilha.",
"Opaque Ice": "Gelo opaco",
"Draws ice in the solid pass, without its see-through texture.": "Desenha o gelo na passagem sólida, sem sua textura transparente.",
"Distant Plant Dither": "Vibração de plantas distantes",
"Keeps fine plants in high-res packs from fading far away.": "Evita que plantas finas em embalagens de alta resolução desapareçam ao longe.",
"Screenshot Quality": "Qualidade da captura de tela",
@@ -421,7 +587,18 @@
"Frame Generation": "Geração de quadros",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Mostra um quadro composto entre cada dois quadros reais: mais suave, um pouco mais de atraso.",
"Shadow Cache": "Cache de sombra",
"Shaders": "Sombreadores",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Pacotes de sombreadores, do Horizon e da pasta shaders/, e suas opções.",
"Shader packs": "Pacotes de sombreadores",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Predefinição da própria vanilla: define apenas as opções da vanilla, como o Minecraft faz.",
"Fullscreen takes the display over, at the mode below.": "A tela cheia assume o monitor, no modo abaixo.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "O modo de exibição que a tela cheia exclusiva usa. Aplica-se quando você sai.",
"Blurs the world behind menus.": "Desfoca o mundo atrás dos menus.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Como as texturas são amostradas: mais nítidas em ângulo com o modo Anisotrópico.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Até onde vai a filtragem anisotrópica. Mais alto é mais nítido em ângulo.",
"Only with Anisotropic texture filtering.": "Apenas com filtragem de textura anisotrópica.",
"Shader Packs...": "Pacotes de sombreadores...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Escolhe um pacote de sombreadores, do Horizon ou da pasta shaders/, e define suas opções.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza o mapa de sombras do pacote quando o sol, a visão e o mundo correspondem. Desativado por padrão.",
"Find BlueMap": "Encontre o BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Nos servidores, encontra seu mapa da web BlueMap para terrenos distantes. Próxima adesão.",
@@ -429,6 +606,8 @@
"How far around you BlueMap tiles are downloaded. Next join.": "A que distância ao seu redor os blocos do BlueMap são baixados. Próxima adesão.",
"Synchronous": "Síncrono",
"Asynchronous": "Assíncrono",
"Hidden Faces": "Faces ocultas",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta faces de folhas atrás de duas camadas de folhas: mais rápido, as mesmas copas.",
"Solid": "Sólido",
"Solid Aggressive": "Sólido Agressivo",
"Hollow": "Oco",
@@ -450,6 +629,10 @@
"Auto (%s nits)": "Automático (%s lêndeas)",
"%.2f nits": "%.2f lêndeas",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos atrás de blocos opacos. Atualização de entidades. Saia com pacotes personalizados.",
"Entity Culling Precheck": "Pré-verificação do abate de entidades",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Executa a pré-verificação AABB de mobs após esta quantidade de mobs carregados. Zero a desativa.",
"%s mobs": "%s mobs",
"Only applies to asynchronous entity culling.": "Aplica-se apenas ao abate assíncrono de entidades.",
"horizon.extras.repro_keys": "Atalhos de gravação",
"horizon.extras.repro_keys.tooltip": "F3+R inicia ou interrompe uma gravação de reprodução e F3+E grava seu pacote. Desativado por padrão: uma gravação copia o mundo.",
"horizon.agent.record_session": "Iniciar sessão de gravação",
@@ -461,6 +644,17 @@
"horizon.repro.error": "Reprodução: %s",
"horizon.repro.no_recording": "Ainda não há gravação para exportar.",
"horizon.repro.replay_started": "Repetição iniciada: %s",
"horizon.splitscreen.profiles.offline": " (Off-line)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Entre novamente no launcher",
"horizon.splitscreen.profiles.online": "Microsoft on-line",
"horizon.splitscreen.account_unavailable": "%s está off-line. Entre novamente no launcher e reinicie o jogo.",
"horizon.splitscreen.account_expired": "A sessão Microsoft de %s expirou. Entre novamente no launcher e reinicie o jogo.",
"Particle Culling": "Abate de partículas",
"Draws every particle.": "Desenha todas as partículas.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Ignora partículas fora da tela, subpixel e escondidas pelo terreno.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Ignora partículas fora da tela, subpixel e escondidas pelo terreno com menos trabalho na thread de renderização.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Adicione um pacote de recursos do Modrinth",
"horizon.pack.addWorking": "Perguntando a Modrinth...",
"horizon.pack.addNone": "Nenhum pacote de recursos encontrado",
@@ -474,10 +668,17 @@
"horizon.pack.filter.realistic": "Realista",
"horizon.pack.filter.simplistic": "Simplista",
"horizon.pack.filter.vanillaLike": "Semelhante à baunilha",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "O pacote não pôde ser adicionado: %s",
"horizon.extras.header.cosmetics": "Cosméticos",
"horizon.extras.cosmetics_mine": "Meus cosméticos",
"horizon.extras.cosmetics_mine.tooltip": "Oculto: todo jogador do Horizon vê você sem seus cosméticos, inclusive você. A mesma opção do guarda-roupa do lançador.",
"key.emote": "Roda de emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Nenhum emote ainda",
"horizon.emote_wheel.help": "Solte ou clique para reproduzir · 1–8 · Esc fecha",
"horizon.extras.cosmetics_others": "Cosméticos de outros jogadores",
"horizon.extras.cosmetics_others.tooltip": "O que os outros jogadores do Horizon usam: tudo, apenas os estilos dos crachás ou nada. Escondido, o jogo nunca pergunta o que eles vestem.",
"horizon.extras.cosmetics_hide_fx": "Ocultar rastros e auras de outros jogadores",
@@ -501,5 +702,348 @@
"Pack settings": "Configurações do pacote",
"Built-in settings": "Configurações internas",
"%s settings": "Configurações de %s",
"Settings made by the pack's author: they change only this pack.": "Configurações criadas pelo autor do pacote: elas mudam apenas este pacote."
"Settings made by the pack's author: they change only this pack.": "Configurações criadas pelo autor do pacote: elas mudam apenas este pacote.",
"horizon.moderation.duration.minute": "%s minuto",
"horizon.moderation.duration.minutes": "%s minutos",
"horizon.moderation.duration.hour": "%s hora",
"horizon.moderation.duration.hours": "%s horas",
"horizon.moderation.duration.day": "%s dia",
"horizon.moderation.duration.days": "%s dias",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Não há ninguém silenciado",
"horizon.moderation.mutelist.count": "Há %s silenciado(s):",
"horizon.moderation.chat_filter": "O filtro de bate-papo",
"horizon.moderation.until_unmuted": "até ser liberado",
"horizon.moderation.remaining": "por mais %s",
"horizon.moderation.mutelist.entry": "%2$s silenciou %1$s %3$s",
"horizon.moderation.invalid_duration": "%s não é uma duração: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <jogador> [duração] [motivo]",
"horizon.moderation.usage.tempmute": "/tempmute <jogador> <duração> [motivo]",
"horizon.moderation.usage.unmute": "/unmute <jogador>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "por %s",
"horizon.moderation.muted": "%1$s foi silenciado %2$s",
"horizon.moderation.unmuted": "%s foi liberado",
"horizon.moderation.not_muted": "%s não está silenciado",
"horizon.tempban.usage": "/tempban <jogador> <duração> [motivo]",
"horizon.tempban.invalid_duration": "%s não é uma duração: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Banimentos temporários precisam de um servidor dedicado",
"horizon.tempban.success": "%1$s por %2$s, até %3$s",
"horizon.tempban.list.empty": "Não há banimentos temporários",
"horizon.tempban.list.count": "Há %s banimento(s) temporário(s):",
"horizon.tempban.list.entry": "%1$s foi banido por %2$s %3$s (até %4$s)",
"horizon.options.pointer_speed": "Velocidade do ponteiro do menu",
"horizon.options.scroll_speed": "Velocidade de rolagem do menu",
"horizon.options.repeat_delay": "Atraso de repetição da navegação",
"horizon.options.repeat_interval": "Intervalo de repetição da navegação",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "No modo um jogador",
"horizon.extras.cosmetics_solo.tooltip": "Mostre seus cosméticos e o emblema do seu crachá também nos seus mundos de um jogador (seu crachá com Crachá próprio). Desligado: apenas em servidores.",
"horizon.extras.horizon_marks": "Logos do Horizon",
"horizon.extras.horizon_marks.tooltip": "Mostra o logo do Horizon ao lado dos jogadores do Horizon: um pequeno logo antes dos nomes de outros jogadores que usam o Horizon, acima das cabeças deles e na lista de jogadores. Só jogadores do Horizon o veem: os servidores não recebem nada.",
"horizon.extras.horizon_me": "Mostrar-me como Horizon",
"horizon.extras.horizon_me.tooltip": "Mostra você como jogador do Horizon: outros jogadores do Horizon veem o logo ao lado do seu nome. Ligado, o serviço responde a quem perguntar sobre você que você jogou Horizon nas últimas 3 horas; desligado, ele nunca informa sobre você.",
"horizon.pages.pvp": "PvP e HUD",
"horizon.pages.pvp.tooltip": "Configurações de mira, HUD e PvP, todas desligadas por padrão. Nada aqui mostra o que a vanilla não mostra.",
"horizon.pages.open.pvp": "PvP e HUD...",
"horizon.pvp.header.crosshair": "Mira",
"horizon.pvp.crosshair": "Mira personalizada",
"horizon.pvp.crosshair_shape": "Forma",
"horizon.pvp.crosshair_size": "Tamanho",
"horizon.pvp.crosshair_gap": "Espaço",
"horizon.pvp.crosshair_thickness": "Espessura",
"horizon.pvp.crosshair_colour": "Cor",
"horizon.pvp.crosshair_outline": "Contorno",
"horizon.pvp.crosshair_dot": "Ponto central",
"horizon.pvp.crosshair_hover": "Cor sobre entidades",
"horizon.pvp.crosshair.tooltip": "Desenha a sua própria mira em vez da mira da vanilla. Suas primeiras configurações se parecem com as da vanilla.",
"horizon.pvp.crosshair_shape.tooltip": "Uma cruz, um T, um X, um círculo, um quadrado ou apenas um ponto.",
"horizon.pvp.crosshair_size.tooltip": "O comprimento de cada braço, em pixels da interface.",
"horizon.pvp.crosshair_gap.tooltip": "O espaço vazio no meio.",
"horizon.pvp.crosshair_thickness.tooltip": "A largura de cada linha.",
"horizon.pvp.crosshair_colour.tooltip": "Invertido mostra a cor sob a mira invertida, como a mira da vanilla faz.",
"horizon.pvp.crosshair_outline.tooltip": "Uma borda preta fina, para aparecer em qualquer fundo.",
"horizon.pvp.crosshair_dot.tooltip": "Um ponto no meio da mira.",
"horizon.pvp.crosshair_hover.tooltip": "Outra cor enquanto a mira está sobre um mob ou um jogador que você poderia atingir, como o indicador de ataque da vanilla reconhece.",
"horizon.pvp.colour.inverted": "Invertido",
"horizon.pvp.colour.white": "Branco",
"horizon.pvp.colour.red": "Vermelho",
"horizon.pvp.colour.orange": "Laranja",
"horizon.pvp.colour.yellow": "Amarelo",
"horizon.pvp.colour.green": "Verde",
"horizon.pvp.colour.cyan": "Ciano",
"horizon.pvp.colour.blue": "Azul",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Preto",
"horizon.pvp.shape.cross": "Cruz",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Círculo",
"horizon.pvp.shape.square": "Quadrado",
"horizon.pvp.shape.dot": "Ponto",
"horizon.pvp.outline": "Contorno de bloco personalizado",
"horizon.pvp.outline.tooltip": "Altera o contorno ao redor do bloco que você olha.",
"horizon.pvp.outline_colour": "Cor",
"horizon.pvp.outline_colour.tooltip": "A cor do contorno. Preto a 40% é o da vanilla.",
"horizon.pvp.outline_opacity": "Opacidade",
"horizon.pvp.outline_opacity.tooltip": "Quão sólido é o contorno.",
"horizon.pvp.outline_thickness": "Espessura",
"horizon.pvp.outline_thickness.tooltip": "A largura do contorno, em vezes a da vanilla.",
"horizon.pvp.outline_fill": "Preenchimento",
"horizon.pvp.outline_fill.tooltip": "Colore levemente as faces do bloco que você olha.",
"horizon.pvp.hit": "Cor de acerto personalizada",
"horizon.pvp.hit.tooltip": "Altera a cor que mobs e jogadores piscam quando são feridos. Vermelho a 30% é o da vanilla.",
"horizon.pvp.hit_colour": "Cor",
"horizon.pvp.hit_colour.tooltip": "A cor que mobs e jogadores feridos piscam.",
"horizon.pvp.hit_opacity": "Opacidade",
"horizon.pvp.hit_opacity.tooltip": "Quanto da cor os cobre.",
"horizon.pvp.header.outline": "Contorno de bloco",
"horizon.pvp.header.hit": "Cor de acerto",
"horizon.pvp.hud_armour": "Armadura",
"horizon.pvp.hud_armour.tooltip": "A armadura que você veste, começando pela cabeça, com quantos usos restam em cada peça.",
"horizon.pvp.hud_armour_corner": "Canto da armadura",
"horizon.pvp.hud_armour_corner.tooltip": "Onde a armadura aparece.",
"horizon.pvp.hud_armour_percent": "Durabilidade em %",
"horizon.pvp.hud_armour_percent.tooltip": "Usos restantes como porcentagem do máximo da peça, em vez de um número.",
"horizon.pvp.hud_effects": "Efeitos",
"horizon.pvp.hud_effects.tooltip": "Seus efeitos com nomes, níveis e tempo restante, no lugar dos ícones no canto superior direito.",
"horizon.pvp.hud_effects_corner": "Canto dos efeitos",
"horizon.pvp.hud_effects_corner.tooltip": "Onde os efeitos aparecem.",
"horizon.pvp.hud_held": "Contagem do item na mão",
"horizon.pvp.hud_held.tooltip": "Quantos itens da sua mão o inventário tem; flechas com arco ou besta.",
"horizon.pvp.hud_held_corner": "Canto da contagem na mão",
"horizon.pvp.hud_held_corner.tooltip": "Onde a contagem aparece.",
"horizon.pvp.hud_background": "Fundos dos widgets",
"horizon.pvp.hud_background.tooltip": "Uma caixa escura atrás de cada widget.",
"horizon.pvp.hud_opacity": "Opacidade do fundo",
"horizon.pvp.hud_opacity.tooltip": "Quão escuras são as caixas atrás dos widgets.",
"horizon.pvp.hud_text_colour": "Cor do texto",
"horizon.pvp.hud_text_colour.tooltip": "A cor do texto dos widgets. A durabilidade mantém o gradiente de verde a vermelho.",
"horizon.pvp.hud_shadow": "Sombra do texto",
"horizon.pvp.hud_shadow.tooltip": "Uma sombra sob o texto dos widgets, como o texto do HUD da vanilla tem.",
"horizon.pvp.header.hud": "Widgets do HUD",
"horizon.pvp.corner.top_left": "Superior esquerdo",
"horizon.pvp.corner.top_right": "Superior direito",
"horizon.pvp.corner.bottom_left": "Inferior esquerdo",
"horizon.pvp.corner.bottom_right": "Inferior direito",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "A latência que o servidor informa para você, em milissegundos. Apenas multijogador.",
"horizon.pvp.hud_ping_corner": "Canto do ping",
"horizon.pvp.hud_ping_corner.tooltip": "Onde o ping aparece.",
"horizon.pvp.hud_coords": "Coordenadas",
"horizon.pvp.hud_coords.tooltip": "Sua posição em blocos e a direção para onde você olha, como o F3 mostra. Oculto quando o mundo ou o servidor reduz o F3.",
"horizon.pvp.hud_coords_corner": "Canto das coordenadas",
"horizon.pvp.hud_coords_corner.tooltip": "Onde as coordenadas aparecem.",
"horizon.pvp.hud_server": "Endereço do servidor",
"horizon.pvp.hud_server.tooltip": "O endereço do servidor em que você joga. Apenas multijogador.",
"horizon.pvp.hud_server_corner": "Canto do servidor",
"horizon.pvp.hud_server_corner.tooltip": "Onde o endereço aparece.",
"horizon.pvp.hud_combo": "Contador de combo",
"horizon.pvp.hud_combo.tooltip": "Acertos seguidos que o servidor confirmou, até você ser ferido ou passarem 3 segundos sem um.",
"horizon.pvp.hud_combo_corner": "Canto do combo",
"horizon.pvp.hud_combo_corner.tooltip": "Onde o combo aparece.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Olhando para: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Norte",
"horizon.pvp.hud.direction.south": "Sul",
"horizon.pvp.hud.direction.east": "Leste",
"horizon.pvp.hud.direction.west": "Oeste",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Quadros desenhados no último segundo.",
"horizon.pvp.hud_fps_corner": "Canto do FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Onde o FPS aparece.",
"horizon.pvp.hud_cps": "Cliques por segundo",
"horizon.pvp.hud_cps.tooltip": "Seus cliques esquerdo e direito no último segundo.",
"horizon.pvp.hud_cps_corner": "Canto do CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Onde os cliques por segundo aparecem.",
"horizon.pvp.hud_keys": "Teclas",
"horizon.pvp.hud_keys.tooltip": "Suas teclas de movimento, botões do mouse e salto, acesos enquanto você os segura, com cliques por segundo.",
"horizon.pvp.hud_keys_corner": "Canto das teclas",
"horizon.pvp.hud_keys_corner.tooltip": "Onde as teclas aparecem.",
"horizon.pvp.hud_clock": "Relógio",
"horizon.pvp.hud_clock.tooltip": "A hora do seu computador.",
"horizon.pvp.hud_clock_corner": "Canto do relógio",
"horizon.pvp.hud_clock_corner.tooltip": "Onde o relógio aparece.",
"horizon.pvp.hud_reach": "Alcance",
"horizon.pvp.hud_reach.tooltip": "A que distância dos seus olhos seu último golpe acertou, por 3 segundos. Apenas mede: seu alcance continua o da vanilla.",
"horizon.pvp.hud_reach_corner": "Canto do alcance",
"horizon.pvp.hud_reach_corner.tooltip": "Onde o alcance aparece.",
"horizon.pvp.hud_speed": "Velocidade",
"horizon.pvp.hud_speed.tooltip": "Quão rápido você se move pelo chão, em blocos por segundo.",
"horizon.pvp.hud_speed_corner": "Canto da velocidade",
"horizon.pvp.hud_speed_corner.tooltip": "Onde a velocidade aparece.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Alcance: %s",
"horizon.pvp.hud.speed": "Velocidade: %s b/s",
"horizon.pvp.hud.keys.attack": "Esq.",
"horizon.pvp.hud.keys.use": "Dir.",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Níveis de encantamento em números",
"horizon.pvp.numeric_enchants.tooltip": "As dicas e a mesa de encantamento mostram Afiação 5 em vez de Afiação V.",
"horizon.pvp.header.items": "Itens",
"horizon.pvp.fire_lower": "Fogo mais baixo",
"horizon.pvp.fire_lower.tooltip": "Baixa as chamas que você vê enquanto queima, para cobrirem menos a tela.",
"horizon.pvp.shield_size": "Tamanho do escudo",
"horizon.pvp.shield_size.tooltip": "O tamanho do escudo na sua mão, em primeira pessoa.",
"horizon.pvp.totem_size": "Tamanho da animação do totem",
"horizon.pvp.totem_size.tooltip": "O tamanho do totem que preenche a tela quando ele salva você.",
"horizon.pvp.glint_colour": "Cor do brilho de encantamento",
"horizon.pvp.glint_colour.tooltip": "A cor do brilho em itens e armaduras encantados. Desligado é o roxo da vanilla.",
"horizon.pvp.clear_water": "Água límpida",
"horizon.pvp.clear_water.tooltip": "Debaixo d'água, veja desde o início tão longe quanto a vanilla permite após 30 segundos ali.",
"horizon.pvp.clear_lava": "Lava límpida",
"horizon.pvp.clear_lava.tooltip": "Na lava, veja tão longe quanto a Resistência ao Fogo da vanilla permite, sem o efeito.",
"horizon.pvp.header.visual": "Ajustes visuais",
"horizon.pvp.view_time": "Hora do dia",
"horizon.pvp.view_time.tooltip": "A hora do dia que você vê: apenas o céu e sua luz. O tempo do mundo continua e nada é enviado ao servidor.",
"horizon.pvp.view_weather": "Clima",
"horizon.pvp.view_weather.tooltip": "O clima que você vê: apenas a chuva, sua luz e o nevoeiro. O clima do mundo continua e nada é enviado ao servidor.",
"horizon.pvp.header.view": "Sua visão",
"horizon.pvp.view.world": "Do mundo",
"horizon.pvp.time.morning": "Manhã",
"horizon.pvp.time.noon": "Meio-dia",
"horizon.pvp.time.sunset": "Pôr do sol",
"horizon.pvp.time.midnight": "Meia-noite",
"horizon.pvp.weather.clear": "Limpo",
"horizon.pvp.weather.rain": "Chuva",
"horizon.pvp.weather.thunder": "Trovoada",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Bate-papo",
"horizon.chat.copied": "Mensagem copiada",
"horizon.chat.search": "Pesquisar no bate-papo:",
"horizon.pvp.chat_timestamps": "Horários",
"horizon.pvp.chat_timestamps.tooltip": "A hora em que cada mensagem chegou, antes dela.",
"horizon.pvp.chat_compact": "Bate-papo compacto",
"horizon.pvp.chat_compact.tooltip": "Uma mensagem repetida em sequência aparece uma vez, com quantas vezes chegou: (x3).",
"horizon.pvp.chat_copy": "Copiar ao clicar",
"horizon.pvp.chat_copy.tooltip": "Clicar em uma mensagem no bate-papo aberto copia seu texto, a menos que o clique faça outra coisa.",
"horizon.pvp.chat_search": "Pesquisa no bate-papo",
"horizon.pvp.chat_search.tooltip": "Ctrl+F no bate-papo aberto: o que você digita mostra apenas as mensagens que o contêm. Enter ou Ctrl+F de novo interrompe.",
"horizon.pvp.preset": "Predefinição de PvP",
"horizon.pvp.preset.tooltip": "Ativa a mira personalizada, os widgets de armadura e efeitos, a alternância de corrida e agachar, o fogo mais baixo e os mods de contador de FPS e teclas, se instalados. Suas configurações de cada um são mantidas.",
"horizon.pvp.hud_editor_key": "Tecla do editor de HUD",
"horizon.pvp.hud_editor_key.tooltip": "Abre o editor de HUD a partir do jogo. Clique e pressione uma tecla; Esc remove a atribuição.",
"horizon.pvp.editor": "Editar HUD",
"horizon.pvp.editor.tooltip": "Mova os widgets no jogo: arraste-os para qualquer lugar, redimensione-os com a roda e clique com o botão direito em um para devolvê-lo ao canto.",
"horizon.pvp.editor.hint": "Arraste os widgets · Roda: tamanho · Botão direito: volta ao canto",
"horizon.pvp.combat_feel": "Sensação de combate",
"horizon.pvp.combat_feel.tooltip": "O Legado desenha as lutas como a 1.8.9: o item na sua mão, seu balanço, comer, o arco e uma espada erguida enquanto você segura o uso, sem a descida após cada golpe nem o indicador de ataque. Apenas a aparência: o que você envia a um servidor continua igual.",
"horizon.pvp.combat_indicator": "Indicador de ataque no Legado",
"horizon.pvp.combat_indicator.tooltip": "Mantém o indicador de ataque no Legado, que a 1.8.9 não tinha.",
"horizon.pvp.combat_feel.modern": "Moderno",
"horizon.pvp.combat_feel.legacy": "Legado (1.8.9)",
"horizon.pvp.header.combat": "Sensação de combate",
"horizon.pvp.combat_sounds": "Sons de acerto da 1.8",
"horizon.pvp.combat_sounds.tooltip": "No Legado, deixa de fora os sons de ataque que a 1.9 adicionou (forte, fraco, varredura, crítico...). Os sons de dano permanecem.",
"horizon.pvp.combat_rules": "Combate da 1.8 nos mundos que você hospeda",
"horizon.pvp.combat_rules.tooltip": "O modo um jogador e os mundos que você abre para a LAN lutam pelas regras da 1.8.9: sem recarga de ataque nem varredura, dano de arma da 1.8, Força, Afiação e acertos críticos. Os servidores em que você entra mantêm as próprias regras.",
"Bindings": "Ligações",
"Play": "Jogar",
"Device": "Dispositivo",
"Look": "Câmera",
"Movement": "Movimento",
"Menus": "Menus",
"Actions": "Ações",
"Reset": "Redefinir",
"Detected": "Detectado",
"Glyphs": "Glifos",
"Feedback": "Feedback",
"Controller": "Controlador",
"How the sticks turn and move you, and how menus repeat and scroll.": "Como os manípulos viram e movem você, e como os menus repetem e rolam.",
"Which button does what. Each player has their own.": "Qual botão faz o quê. Cada jogador tem os seus.",
"The controller the game sees, its button glyphs and its feedback.": "O controlador que o jogo vê, seus glifos de botão e seu feedback.",
"Press this row, then the button that should do it.": "Pressione esta linha e depois o botão que deve fazer isso.",
"Off, the game ignores every controller for this player.": "Desligado, o jogo ignora todos os controladores deste jogador.",
"How fast the right stick turns the view.": "Quão rápido o manípulo direito vira a câmera.",
"Turn the view the other way, left and right.": "Inverte a câmera na horizontal.",
"Turn the view the other way, up and down.": "Inverte a câmera na vertical.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Quanto o manípulo direito se move antes de a câmera virar. Aumente se a câmera se mover sozinha.",
"Hold the button to sneak, or press it once to start and again to stop.": "Segure o botão para agachar, ou pressione-o uma vez para começar e de novo para parar.",
"How far the left stick moves before you walk. Raise it if you drift.": "Quanto o manípulo esquerdo se move antes de você andar. Aumente se você se mover sozinho.",
"How far a trigger is pulled before it counts as pressed.": "Quanto um gatilho precisa ser puxado para contar como pressionado.",
"Shows what your buttons do right now, in play and in inventories.": "Mostra o que seus botões fazem agora, no jogo e nos inventários.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Mostra apenas o que você pode fazer com o que está olhando e os botões principais do inventário.",
"Doesn't show what your buttons do.": "Não mostra o que seus botões fazem.",
"Shows the buttons of the controller you're using.": "Mostra os botões do controlador que você está usando.",
"Always shows Xbox buttons.": "Sempre mostra os botões do Xbox.",
"Always shows PlayStation buttons.": "Sempre mostra os botões do PlayStation.",
"Always shows Nintendo buttons.": "Sempre mostra os botões da Nintendo.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, à direita, confirma e pula, e B volta, como no Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, embaixo, confirma e pula, e A volta, como nos outros controladores.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Abre o teclado do Steam em um Steam Deck pelo Steam, e o do Horizon caso contrário.",
"Always opens Horizon's keyboard.": "Sempre abre o teclado do Horizon.",
"Always opens Steam's keyboard.": "Sempre abre o teclado do Steam.",
"A DualSense's light shows your health, from green through amber to red.": "A luz de um DualSense mostra sua saúde, do verde passando pelo âmbar até o vermelho.",
"A DualSense's light changes with the dimension you're in.": "A luz de um DualSense muda com a dimensão em que você está.",
"A DualSense keeps its own light.": "Um DualSense mantém a própria luz.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permite criar com LT + A em servidores que não são os seus mundos. Alguns servidores consideram isso um macro.",
"Show the buttons for what you can do, while you play with a controller.": "Mostra os botões para o que você pode fazer enquanto joga com um controlador.",
"How fast the stick moves the crosshair in inventories.": "Quão rápido o manípulo move a mira nos inventários.",
"How fast the right stick scrolls a list.": "Quão rápido o manípulo direito rola uma lista.",
"How long a held direction waits before it repeats.": "Quanto tempo uma direção segurada espera antes de repetir.",
"How long a held direction takes between repeats.": "Quanto tempo uma direção segurada leva entre repetições.",
"Which on-screen keyboard opens when a text field is pressed.": "Qual teclado na tela abre quando um campo de texto é pressionado.",
"Every action back on its default button.": "Coloca todas as ações de volta no botão padrão.",
"These settings belong to this player's profile.": "Estas configurações pertencem ao perfil deste jogador.",
"The controller driving this player, or else the first one connected.": "O controlador que controla este jogador, ou então o primeiro conectado.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "O fornecedor e o produto USB que o sistema informa. Útil ao relatar um controlador com mau funcionamento.",
"The glyph family the game picked from the controller's maker.": "A família de glifos que o jogo escolheu a partir do fabricante do controlador.",
"Follow the controller, or always show one family's buttons.": "Siga o controlador, ou mostre sempre os botões de uma família.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Se o A e o B de um controlador da Nintendo funcionam pela posição ou pelas letras.",
"The buttons menus show right now.": "Os botões que os menus mostram agora.",
"Rumble when something happens to you.": "Vibração quando algo acontece com você.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "O controlador continua funcionando enquanto esta janela não está em foco, e perder o foco não pausa.",
"A DualSense's triggers push back on bows and tools.": "Os gatilhos de um DualSense oferecem resistência em arcos e ferramentas.",
"What a DualSense's light shows, over USB or Bluetooth.": "O que a luz de um DualSense mostra, por USB ou Bluetooth.",
"Player profile": "Perfil do jogador",
"Vendor and product": "Fornecedor e produto",
"Detected glyphs": "Glifos detectados",
"Glyphs in use": "Glifos em uso",
"Unknown": "Desconhecido",
"No controller": "Nenhum controlador",
"Editing: %s": "Editando: %s",
"Press the button for this. %s cancels.": "Pressione o botão para isso. %s cancela.",
"This button also does: %s": "Este botão também faz: %s",
"horizon.pad.notice.connected.press": "Pressione para usá-lo",
"horizon.recipe_transfer.missing": "Itens faltando",
"horizon.recipe_transfer.no_room": "Inventário cheio",
"horizon.recipe_transfer.unavailable": "A receita não cabe neste menu",
"horizon.recipe_transfer.unsupported": "O servidor exige uma receita conhecida",
"horizon.studio.timeline.freecam_on": "Câmera livre: LIGADA",
"horizon.studio.timeline.freecam_off": "Câmera livre: DESLIGADA",
"horizon.studio.timeline.freecam_short": "Câm",
"horizon.studio.timeline.freecam_active_short": "Câm ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Chave",
"horizon.studio.timeline.delete_keyframe_short": "− Excl",
"horizon.studio.timeline.split": "Dividir",
"horizon.studio.timeline.marker": "Marcador %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s Cenas",
"horizon.studio.timeline.scene_title": "Cena %s",
"horizon.studio.timeline.sequence": "Sequência",
"horizon.studio.topbar.title": "✦ Estúdio",
"horizon.studio.topbar.file": "Arquivo",
"horizon.studio.topbar.camera": "Câmera",
"horizon.studio.topbar.view": "Exibir",
"horizon.studio.topbar.file_short": "Arquivo",
"horizon.studio.topbar.camera_short": "Câm",
"horizon.studio.topbar.view_short": "Ver",
"horizon.studio.topbar.game_pause_menu": "⏸ Menu de pausa",
"horizon.studio.topbar.pause_short": "Pausa",
"horizon.studio.topbar.open_recordings": "📁 Abrir pasta de gravações",
"horizon.studio.topbar.close_replay": "✕ Fechar reprodução",
"horizon.studio.topbar.exit": "Sair",
"horizon.studio.topbar.toggle_freecam": "Alternar câmera livre",
"horizon.studio.topbar.reset_to_player": "👤 Voltar ao jogador",
"horizon.studio.topbar.unlock_freecam": "🔓 Desbloquear câmera livre",
"horizon.studio.topbar.lock_to_spline": "🔒 Fixar na spline",
"horizon.studio.topbar.add_keyframe": "➕ Adicionar quadro-chave",
"horizon.studio.topbar.add_marker": "Adicionar marcador",
"horizon.studio.topbar.clear_keyframes": "🗑 Limpar quadros-chave",
"horizon.studio.topbar.timeline_hud": "⏱ Linha do tempo",
"horizon.studio.topbar.split_scene": "Dividir cena"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 859 B

@@ -320,16 +320,16 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
flag("offscreen", Ty::Bool, "render without showing a window, splitscreen's too (screenshots still work): locked or headless sessions"),
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("events", Observe, "events [--since SEQ] [--limit N]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, damage taken (hurt, with its cause), healing, food, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. --limit keeps the newest N and skipped counts the older ones left out. hurt and death carry the player position (a death drops the inventory there). The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42", "horizon-ctl events --limit 10"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events")], mcp: true, local: false),
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
@@ -413,7 +413,7 @@ pub const COMMANDS: &[Cmd] = &[
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Whole-number X and Z name a block and aim at its center (8 64 3 is 8.5 64 3.5); decimals are exact positions. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
@@ -443,14 +443,14 @@ pub const COMMANDS: &[Cmd] = &[
&[opt("id", Ty::Str, "an owned emote's id, or stop")], mcp: true, local: false),
// Act
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops, picked_up (inventory change, where a tool that broke shows as -1) and held (the item in hand afterwards, also on a timeout: a broken tool leaves the bare hand).",
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
cmd!("place", Act, "place X Y Z [--item ITEM] [--sneak]", "place the held item (or ITEM from the hotbar) at an empty position",
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first"), flag("sneak", Ty::Bool, "sneak while clicking: place against a crafting table or chest instead of opening it")], mcp: true, local: false),
cmd!("interact", Act, "interact X Y Z [FACE] [--sneak]", "right-click a block: open a chest or crafting table, press a button, open a door",
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click"), flag("sneak", Ty::Bool, "sneak while clicking: use the held item on the block instead of opening it")], mcp: true, local: false),
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
@@ -460,6 +460,14 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
cmd!("hunt", Act, "hunt ID [--timeout S]", "walk up to a mob and hit it until it is gone, then pick up its drops",
"For animals and monsters that move between calls: it re-reads the entity's position, walks within reach (sprinting), attacks with the held item, waits 12 ticks for the weapon to recharge, and repeats. Once the entity is gone (killed or despawned) it walks over its last position to collect the drops. Replies with hits, last_seen and inventory_change; times out with kind timeout (default 30 s).",
["horizon-ctl hunt 90", "horizon-ctl hunt 90 --timeout 60"],
&[p("id", Ty::Int, "entity id from entities"), flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: true),
cmd!("collect", Act, "collect [RADIUS]", "walk over every dropped item within RADIUS blocks (default 8) to pick it up",
"Drops from break, hunt or a death often land out of pickup range, in a hole or behind a corner. collect walks to each item nearest first, trying each once, and replies with inventory_change and left: the items it could not reach (with why, the walk's error, when it had one). It stops with kind hurt, listing the hurt events, as soon as the player takes damage.",
["horizon-ctl collect", "horizon-ctl collect 16"],
&[opt("radius", Ty::Num, "search radius in blocks (default 8)")], mcp: true, local: true),
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
@@ -468,12 +476,12 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen and inventory_change (items gained or lost). Fails without clicking when the entity is not under the crosshair, naming what is in the way.",
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status | pad connection connected|disconnected", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status | pad connection connected [FAMILY]|disconnected | pad family FAMILY", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held. FAMILY is xbox, playstation, nintendo or auto: connection connected raises the notice of that family's controller (PlayStation unless given), and family makes the pad playing that family's (its glyphs; a Nintendo one goes by its letters when Nintendo Buttons says so).",
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
&[p("what", Ty::Str, "a button name, stick, trigger, release, status or connection"), opt("a", Ty::Str, "down|up|tap, left|right, or connected|disconnected"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
&[p("what", Ty::Str, "a button name, stick, trigger, release, status, connection or family"), opt("a", Ty::Str, "down|up|tap, left|right, connected|disconnected, or a family"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
// Screens
@@ -578,9 +586,9 @@ pub const COMMANDS: &[Cmd] = &[
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("mcp", Scripting, "mcp [--paced]", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image. --paced stops a single-player world between tool calls: the pause menu opens after each action and closes before the next, so time passes only while an action (or wait) runs, not while the agent thinks; reading tools (state, inventory, entities, events...) leave it paused.",
["horizon-ctl mcp", "horizon-ctl mcp --paced"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
@@ -142,6 +142,10 @@ pub struct Session {
/// `--api LINE`: the protocol line this run speaks; a game on another line is refused.
pub api: Option<String>,
client: Option<Client>,
/// `mcp --paced`: the world stands still between tool calls (`mcp::Session::call`).
pub paced: bool,
/// The pause menu `--paced` opened, which the next action closes.
pub(crate) held: bool,
}
impl Session {
@@ -152,6 +156,8 @@ impl Session {
home: horizon_client::logging::game_home(),
api: None,
client: None,
paced: false,
held: false,
}
}
/// A failure while No AI is on: every command refuses (help aside).
@@ -185,6 +191,124 @@ impl Session {
Ok(reply)
}
/// `hunt ID`: walk up to a mob and hit it until it is gone, at most `timeout` seconds.
fn hunt(&mut self, id: i64, timeout: f64) -> Result<Value, Failure> {
let deadline = Instant::now() + Duration::from_secs_f64(timeout.max(0.0));
let before = self.game("inventory")?;
let (mut hits, mut seen) = (0, Value::Null);
loop {
let near = self.game("entities 48")?;
let Some(mob) = find_entity(&near, id) else {
break;
};
if mob["health"].as_f64().is_some_and(|h| h <= 0.0) {
// Dying: it vanishes after its death animation.
seen = mob;
break;
}
if Instant::now() > deadline {
return Err(Failure {
reply: json!({
"error": format!("entity {id} still alive after {timeout} s"),
"kind": "timeout",
"hits": hits,
"entity": mob,
}),
status: GAME_ERROR,
});
}
seen = mob.clone();
let pos = &mob["pos"];
if mob["distance"].as_f64().unwrap_or(f64::MAX) > 2.5 {
// A path may not exist (water, a fence); try again from wherever this ends.
let _ = self.game(&format!(
"goto {} {} {} --near 1.5 --sprint --timeout 3",
pos[0], pos[1], pos[2]
));
continue;
}
if self.game(&format!("attack {id}")).is_ok() {
hits += 1;
// Let the weapon recharge (a sword needs about 12 ticks).
self.game("wait 12")?;
} else {
let _ = self.game(&format!(
"goto {} {} {} --near 0.8 --timeout 2",
pos[0], pos[1], pos[2]
));
}
}
// Walk over the drops so they are picked up.
if let Some(pos) = seen["pos"].as_array() {
let _ = self.game(&format!(
"goto {} {} {} --near 0.5 --timeout 4",
pos[0], pos[1], pos[2]
));
self.game("wait 10")?;
}
let after = self.game("inventory")?;
Ok(json!({
"ok": true,
"gone": true,
"hits": hits,
"last_seen": seen,
"inventory_change": count_change(&before["counts"], &after["counts"]),
}))
}
/// `collect [RADIUS]`: walk over every dropped item within RADIUS, nearest first.
fn collect(&mut self, radius: f64) -> Result<Value, Failure> {
let before = self.game("inventory")?;
// Only the newest sequence number: events after it are this walk's.
let mut seq = self.game("events --since 18446744073709551615")?["last"]
.as_u64()
.unwrap_or_default();
let mut tried = Vec::new();
let mut left = Vec::new();
// Each item is tried once; one that stays put after a walk is out of reach.
while let Some(item) = nearest_item(&self.game(&format!("entities {radius}"))?, &tried) {
let id = item["id"].as_i64().unwrap_or_default();
tried.push(id);
let pos = &item["pos"];
let walk = self.game(&format!(
"goto {} {} {} --near 0.4 --timeout 4",
pos[0], pos[1], pos[2]
));
self.game("wait 4")?;
let near = self.game(&format!("entities {radius}"))?;
if let Some(mut still) = find_entity(&near, id) {
if let Err(e) = walk {
still["why"] = e.reply["error"].clone();
}
left.push(still);
}
// Stop when something hurts the player: walking on into a mob,
// a creeper or lava kills an agent that only reads the reply.
let events = self.game(&format!("events --since {seq}"))?;
seq = events["last"].as_u64().unwrap_or(seq);
let hurt = hurts(&events);
if !hurt.is_empty() {
let after = self.game("inventory")?;
return Err(Failure {
reply: json!({
"error": "stopped: the player was hurt",
"kind": "hurt",
"hurt": hurt,
"inventory_change": count_change(&before["counts"], &after["counts"]),
"left": left,
}),
status: GAME_ERROR,
});
}
}
let after = self.game("inventory")?;
Ok(json!({
"ok": true,
"inventory_change": count_change(&before["counts"], &after["counts"]),
"left": left,
}))
}
/// Run one command (CLI words); Ok(reply) or a failure.
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
let Some(name) = words.first().map(String::as_str) else {
@@ -233,13 +357,43 @@ impl Session {
[_, id] if id.parse::<i64>().is_ok() => id,
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
};
self.game(&format!("look-at entity {id}"))?;
let id: i64 = id.parse().unwrap_or_default();
let look = self.game(&format!("look-at entity {id}"))?;
if let Err(error) = aimed_at(&look, id) {
return Err(Failure {
reply: json!({ "error": error, "target": look["target"] }),
status: GAME_ERROR,
});
}
let before = self.game("inventory")?;
self.game("mouse right click")?;
self.game("wait 2")?;
let after = self.game("inventory")?;
let mut screen = self.game("screen")?;
screen["ok"] = json!(true);
screen["inventory_change"] = count_change(&before["counts"], &after["counts"]);
Ok(screen)
}
"hunt" => {
let (id, timeout) = match words {
[_, id] => (id.parse::<i64>().ok(), Some(30.0)),
[_, id, flag, s] if flag == "--timeout" => {
(id.parse::<i64>().ok(), s.parse::<f64>().ok())
}
_ => (None, None),
};
let (Some(id), Some(timeout)) = (id, timeout) else {
return Err(Failure::usage(format!("usage: {}", usage(name))));
};
self.hunt(id, timeout)
}
"collect" => match words {
[_] => self.collect(8.0),
[_, r] if r.parse::<f64>().is_ok_and(|r| r > 0.0) => {
self.collect(r.parse().unwrap_or(8.0))
}
_ => Err(Failure::usage(format!("usage: {}", usage(name)))),
},
"hold" => {
let (key, seconds) = match words {
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
@@ -300,6 +454,79 @@ impl Session {
}
}
/// Whether `look` (a `look-at entity` reply) put entity `id` under the crosshair; if not,
/// what is there instead, so a right click is not spent on a tree or the air.
fn aimed_at(look: &Value, id: i64) -> Result<(), String> {
let target = &look["target"];
let entity = &target["entity"];
if entity["id"].as_i64() == Some(id) {
return Ok(());
}
Err(if let Some(other) = entity["id"].as_i64() {
format!(
"entity {id} is not under the crosshair: {} {other} is in the way",
entity["type"].as_str().unwrap_or("an entity")
)
} else if let Some(block) = target["block"]["block"].as_str() {
format!(
"entity {id} is not under the crosshair: {block} at {} is in the way",
target["block"]["pos"]
)
} else {
format!("entity {id} is not under the crosshair: it is out of reach or moved; walk closer")
})
}
/// The entity `id` in an `entities` reply.
fn find_entity(reply: &Value, id: i64) -> Option<Value> {
reply["entities"]
.as_array()?
.iter()
.find(|e| e["id"].as_i64() == Some(id))
.cloned()
}
/// The nearest dropped item in an `entities` reply (nearest first) whose id is not in `skip`.
fn nearest_item(reply: &Value, skip: &[i64]) -> Option<Value> {
reply["entities"]
.as_array()?
.iter()
.find(|e| e["kind"] == "item" && !e["id"].as_i64().is_some_and(|id| skip.contains(&id)))
.cloned()
}
/// The `hurt` events in an `events` reply.
fn hurts(reply: &Value) -> Vec<Value> {
let events = reply["events"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
events
.iter()
.filter(|e| e["event"] == "hurt")
.cloned()
.collect()
}
/// Item counts that changed from `before` to `after` (`inventory` replies' `counts`).
fn count_change(before: &Value, after: &Value) -> Value {
let empty = serde_json::Map::new();
let (before, after) = (
before.as_object().unwrap_or(&empty),
after.as_object().unwrap_or(&empty),
);
let mut change = serde_json::Map::new();
for item in before.keys().chain(after.keys()) {
let n =
|m: &serde_json::Map<String, Value>| m.get(item).and_then(Value::as_i64).unwrap_or(0);
let delta = n(after) - n(before);
if delta != 0 {
change.insert(item.clone(), json!(delta));
}
}
Value::Object(change)
}
/// `logs [--lines N] [--crash]` for the game in `home`: the reply's `text` is the log's last
/// lines or the report.
fn logs(home: &Path, words: &[String]) -> Result<Value, Failure> {
@@ -563,13 +790,16 @@ fn main() -> ExitCode {
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
DENIED
}
Some("mcp") => match mcp::serve(&mut session) {
Ok(()) => 0,
Err(e) => {
eprintln!("horizon-ctl mcp: {e}");
UNREACHABLE
Some("mcp") => {
session.paced = args[1..].iter().any(|a| a == "--paced");
match mcp::serve(&mut session) {
Ok(()) => 0,
Err(e) => {
eprintln!("horizon-ctl mcp: {e}");
UNREACHABLE
}
}
},
}
// The original form: stdin, keep going after errors.
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
Some("script") => {
@@ -617,6 +847,74 @@ fn main() -> ExitCode {
mod tests {
use super::*;
#[test]
fn aimed_at_names_what_is_under_the_crosshair_instead() {
let on = json!({ "target": { "entity": { "id": 91, "type": "minecraft:sheep" } } });
assert_eq!(aimed_at(&on, 91), Ok(()));
let sheep = aimed_at(&on, 107).unwrap_err();
assert!(
sheep.contains("minecraft:sheep 91 is in the way"),
"{sheep}"
);
let log = json!({
"target": { "block": { "block": "minecraft:oak_log", "pos": [1, 2, 3] } }
});
let log = aimed_at(&log, 91).unwrap_err();
assert!(
log.contains("minecraft:oak_log at [1,2,3] is in the way"),
"{log}"
);
let nothing = aimed_at(&json!({ "target": null }), 91).unwrap_err();
assert!(nothing.contains("walk closer"), "{nothing}");
}
#[test]
fn find_entity_picks_the_id_out_of_an_entities_reply() {
let near = json!({ "entities": [{ "id": 3 }, { "id": 90, "type": "minecraft:chicken" }] });
assert_eq!(find_entity(&near, 90).unwrap()["type"], "minecraft:chicken");
assert!(find_entity(&near, 7).is_none());
assert!(find_entity(&json!({}), 90).is_none());
}
#[test]
fn hurts_keeps_only_hurt_events() {
let reply = json!({ "events": [
{ "event": "item", "seq": 1 },
{ "event": "hurt", "amount": 3.0, "seq": 2 },
{ "event": "heal", "seq": 3 },
], "last": 3 });
assert_eq!(
hurts(&reply),
vec![json!({ "event": "hurt", "amount": 3.0, "seq": 2 })]
);
assert!(hurts(&json!({})).is_empty());
}
#[test]
fn nearest_item_skips_mobs_and_items_already_tried() {
let near = json!({ "entities": [
{ "id": 1, "kind": "mob" },
{ "id": 2, "kind": "item" },
{ "id": 3, "kind": "item" },
] });
assert_eq!(nearest_item(&near, &[]).unwrap()["id"], 2);
assert_eq!(nearest_item(&near, &[2]).unwrap()["id"], 3);
assert!(nearest_item(&near, &[2, 3]).is_none());
}
#[test]
fn count_change_keeps_only_items_whose_count_moved() {
let before = json!({ "minecraft:shears": 1, "minecraft:white_wool": 1 });
let after = json!({
"minecraft:shears": 1, "minecraft:white_wool": 3, "minecraft:stick": 2
});
assert_eq!(
count_change(&before, &after),
json!({ "minecraft:white_wool": 2, "minecraft:stick": 2 })
);
assert_eq!(count_change(&after, &before)["minecraft:stick"], json!(-2));
}
#[test]
fn logs_reads_the_end_of_latest_log_and_the_newest_crash_report() {
let home = std::env::temp_dir().join(format!("horizon-ctl-logs-{}", std::process::id()));
@@ -69,9 +69,56 @@ pub fn base64(bytes: &[u8]) -> String {
out
}
/// Under `--paced`, whether a tool runs without the world moving: it only reads, or it
/// manages the session or the pause itself.
fn still(cmd: &commands::Cmd) -> bool {
matches!(cmd.group, commands::Group::Observe)
|| matches!(
cmd.name,
"hello"
| "status"
| "worlds"
| "logs"
| "save"
| "launch"
| "quit"
| "pause"
| "resume"
| "inventory"
| "ui"
)
}
impl Session {
/// One tool call: (content blocks, is_error).
/// The screen the game shows, if it answers.
fn screen_now(&mut self) -> Option<String> {
let reply = self.run(&["screen".to_owned()]).ok()?;
reply["screen"].as_str().map(str::to_owned)
}
/// `--paced`: an agent thinks between tool calls, often for many seconds, and in single
/// player the world went on without it (a mob kept hitting). The pause menu stops a
/// single-player world as vanilla's does; it is opened after an action and closed before
/// the next, only when the game is playing (not over another menu, nor a pause a person
/// opened). Reading tools leave it open. A LAN or server world does not stop.
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
let cmd = commands::COMMANDS
.iter()
.find(|c| c.mcp && commands::tool_name(c) == name);
let pace = self.paced && cmd.is_some_and(|c| !still(c));
if pace && std::mem::take(&mut self.held) && self.screen_now().as_deref() == Some("paused")
{
let _ = self.run(&["resume".to_owned()]);
}
let out = self.call_tool(name, args);
if pace && self.screen_now().as_deref() == Some("playing") {
self.held = self.run(&["pause".to_owned()]).is_ok();
}
out
}
/// One tool call: (content blocks, is_error).
fn call_tool(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
let Some(cmd) = commands::COMMANDS
.iter()
@@ -181,9 +228,37 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paced_reading_tools_keep_the_world_still() {
let find = |n: &str| commands::COMMANDS.iter().find(|c| c.name == n).unwrap();
for n in [
"state",
"inventory",
"entities",
"events",
"find-block",
"pause",
"save",
] {
assert!(still(find(n)), "{n} should not let time pass");
}
let moving = [
"goto",
"break",
"place",
"attack",
"wait",
"wait-for",
"screenshot",
"click-slot",
];
for n in moving {
assert!(!still(find(n)), "{n} needs the world running");
}
}
#[test]
fn base64_matches_rfc_4648() {
assert_eq!(base64(b""), "");
+4 -13
View File
@@ -2,10 +2,9 @@
//! if it helps, how long it stays. A view holds one at a time (`NoticeSlot`), so they never stack:
//! each player's game has its own slot, and what raises a notice (splitscreen's joins and
//! reconnects, a solo game's controller coming and going) decides that for itself.
use crate::gamepad::{Button, GlyphStyle};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::hud_kit::plate::{self, Plate};
use crate::pad_glyph;
use crate::pad_glyph::{self, Glyph};
use crate::toasts::Slide;
use std::time::{Duration, Instant};
@@ -30,13 +29,6 @@ pub enum Priority {
Attention,
}
/// A button shown before the body, in the player's glyphs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Glyph {
pub button: Button,
pub style: GlyphStyle,
}
pub struct Notice {
id: NoticeId,
priority: Priority,
@@ -66,6 +58,7 @@ impl Notice {
slide: Slide::default(),
}
}
/// A button shown before the body, in the glyphs of the controller it is about.
pub fn with_glyph(mut self, glyph: Glyph) -> Self {
self.glyph = Some(glyph);
self
@@ -132,9 +125,7 @@ impl NoticeSlot {
let w = batch.width;
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let max = (w - 24.0).min(260.0);
let glyph = notice
.glyph
.map(|g| (g, pad_glyph::width(atlas, g.button, g.style) + 4.0));
let glyph = notice.glyph.map(|g| (g, pad_glyph::width(atlas, g) + 4.0));
let indent = glyph.map_or(0.0, |g| g.1);
let lines = crate::split_profiles::wrap(&notice.body, max - indent, width);
let inner = lines
@@ -152,7 +143,7 @@ impl NoticeSlot {
let lx = (px + (pw - width(line) - indent) / 2.0).floor();
let y = py + 18.0 + 10.0 * k as f32;
if let (0, Some((g, _))) = (k, glyph) {
pad_glyph::draw(batch, atlas, g.button, g.style, lx, y);
pad_glyph::draw(batch, atlas, g, lx, y);
}
batch.text(atlas, line, lx + indent, y, rgba(200, 200, 200, 255), false);
}
+9 -2
View File
@@ -1342,7 +1342,9 @@ pub struct ExperienceOrb {
pub position: Vec3,
pub previous: Vec3,
pub velocity: Vec3,
pub value: u32,
/// `ExperienceOrb.DATA_VALUE`: an int that a save or command can leave at
/// 0 or below, which a pickup turns into no experience.
pub value: i32,
pub age: u32,
pub on_ground: bool,
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
@@ -1416,9 +1418,14 @@ impl ExperienceOrb {
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
pub fn can_merge(&self, id: u32, value: i32) -> bool {
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
}
/// The experience one pickup gives: `playerTouch` gives only a positive
/// remainder, so an orb of value 0 or below gives none. Mods see this too.
pub fn points(&self) -> u32 {
self.value.max(0) as u32
}
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
pub fn split(value: u32) -> u32 {
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
+178 -44
View File
@@ -1056,15 +1056,17 @@ pub fn mob_to_nbt(
} else {
map.insert("equipment".into(), Tag::compound(equipment));
}
let modelled = [
("minecraft:max_health", f64::from(m.attrs.max_health)),
("minecraft:movement_speed", m.attrs.speed),
("minecraft:jump_strength", m.extra.vector[0]),
];
// AttributeMap.pack writes the instances that exist: the ones the
// entity was loaded with, or all three randomized by finalizeSpawn.
let attributes = match map.get(&"attributes".into()) {
Some(Tag::List(list)) => list
// entity was loaded with. When the tag had no attributes list,
// instances are not synthesized (SummonCommand without finalizeSpawn
// saves attributes: []).
if let Some(Tag::List(list)) = map.get(&"attributes".into()) {
let modelled = [
("minecraft:max_health", f64::from(m.attrs.max_health)),
("minecraft:movement_speed", m.attrs.speed),
("minecraft:jump_strength", m.extra.vector[0]),
];
let attributes = list
.iter()
.map(|a| {
let id = text(a.get("id"));
@@ -1078,15 +1080,9 @@ pub fn mob_to_nbt(
_ => a.clone(),
}
})
.collect(),
_ => modelled
.iter()
.map(|(id, v)| {
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
})
.collect(),
};
map.insert("attributes".into(), Tag::List(attributes));
.collect();
map.insert("attributes".into(), Tag::List(attributes));
}
}
"minecraft:pig" | "minecraft:strider" => {
let mut equipment = match map.get(&"equipment".into()) {
@@ -1390,12 +1386,14 @@ pub fn mob_to_nbt(
// Zombie and Skeleton.addAdditionalSaveData (a husk keeps Zombie's):
// `ConversionTracker`'s conversion and affliction times, -1 when it is
// not converting or not afflicted.
// The tracker ticks for these, so the live counters win over the loaded
// values: one that left the water or the snow saves -1.
"minecraft:zombie" | "minecraft:husk" => {
for (key, value) in [
("DrownedConversionTime", conversion_time(m)),
("InWaterTime", m.extra.counter),
] {
map.entry(key.into()).or_insert(Tag::Int(value));
map.insert(key.into(), Tag::Int(value));
}
}
"minecraft:skeleton" => {
@@ -1403,7 +1401,7 @@ pub fn mob_to_nbt(
("StrayConversionTime", conversion_time(m)),
("FreezingTime", m.extra.counter),
] {
map.entry(key.into()).or_insert(Tag::Int(value));
map.insert(key.into(), Tag::Int(value));
}
}
// Drowned.convertsInWater is false, so its tracker never ticks: vanilla
@@ -1450,6 +1448,16 @@ pub fn mob_to_nbt(
if !m.fluids.eye_in_water {
map.insert("InWaterTime".into(), Tag::Int(-1));
}
// ZombieVillager and ZombifiedPiglin save Zombie's tracker too; Horizon
// never starts their water conversion, so its time is -1.
if matches!(m.species(), S::ZombieVillager | S::ZombifiedPiglin) {
map.entry("DrownedConversionTime".into())
.or_insert(Tag::Int(-1));
}
}
// Skeleton supplies its cached isInPowderSnow to the same isAfflicted check.
if m.species() == S::Skeleton && !m.in_powder_snow {
map.insert("FreezingTime".into(), Tag::Int(-1));
}
if matches!(
id,
@@ -1485,8 +1493,9 @@ pub fn mob_to_nbt(
// Every other living entity's first tick makes the movement_speed instance
// (LivingEntity.removeFrost reads it through getAttribute); before it, the
// mob has only the instances it was loaded with (the kept list above).
let speed_at_load =
crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin;
let speed_at_load = crate::mobs::doors::is_zombie(species)
|| species == crate::mobs::Species::Piglin
|| (species == crate::mobs::Species::Ravager && crate::mobs::ravager::immobile(m));
let mut made = Vec::new();
if speed_at_load || m.age > 0 {
made.push(Attr::MovementSpeed);
@@ -1504,16 +1513,23 @@ pub fn mob_to_nbt(
_ => Vec::new(),
};
let attr_id = format!("minecraft:{}", attr.name());
if !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
if let Some(existing) = list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
if species == crate::mobs::Species::Ravager
&& attr == Attr::MovementSpeed
&& let Tag::Compound(c) = existing
{
c.insert("base".into(), Tag::Double(base.to_bits()));
}
} else {
list.push(Tag::compound([
("id", string(&attr_id)),
("base", Tag::Double(base.to_bits())),
]));
map.insert("attributes".into(), Tag::List(list));
}
map.insert("attributes".into(), Tag::List(list));
}
// Mob.addAdditionalSaveData: the chances that are not the default.
if let Some(chances) = &m.extra.drop_chances {
@@ -2298,7 +2314,11 @@ pub fn mob_from_nbt(
}
}
if m.extra.vector[0] <= 0.0 {
m.extra.vector[0] = 0.7;
m.extra.vector[0] = if crate::mobs::equine::is_chested(m.species()) {
0.5
} else {
0.7
};
}
}
if crate::mobs::finalize::saves_variant_name(m.species())
@@ -2604,6 +2624,11 @@ pub fn mob_from_nbt(
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
if crate::mobs::ravager::immobile(&m)
&& !matches!(tag.get("attributes"), Some(Tag::List(l)) if l.iter().any(|a| text(a.get("id")).as_deref() == Some("minecraft:movement_speed")))
{
m.attrs.speed = 0.0;
}
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
@@ -3014,7 +3039,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
for (key, value) in [
("Health", Tag::Short(o.health as i16)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value as i16)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
@@ -3039,7 +3064,8 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
position,
previous: position,
velocity: motion(tag),
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
// `getShortOr("Value", 0)`, unclamped.
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
@@ -3620,11 +3646,15 @@ pub struct TridentSave<'a> {
pub life: i32,
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub pickup: Pickup,
pub left_owner: bool,
pub has_been_shot: bool,
pub owner: Option<[i32; 4]>,
pub no_gravity: bool,
pub fire_ticks: i32,
pub item: &'a crate::items::ItemStack,
}
/// `ThrownTrident.addAdditionalSaveData`, including its arrow/projectile state.
pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let mut tag = Base {
id: "minecraft:trident",
@@ -3636,19 +3666,26 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
fall_distance: 0.0,
}
.tag(raw);
// Projectile.addAdditionalSaveData: LeftOwner once set. A trident keeps no
// owner, and an ownerless projectile has left it on its first tick.
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
// including when raw NBT explicitly supplied false.
if let Tag::Compound(map) = &mut tag {
map.entry("LeftOwner".into()).or_insert(Tag::Byte(1));
map.remove(&"LeftOwner".into());
map.remove(&"Owner".into());
}
if t.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if let Some(owner) = t.owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
for (key, value) in [
("life", Tag::Short(t.life as i16)),
("shake", Tag::Byte(t.shake as i8)),
("inGround", Tag::Byte(i8::from(t.in_ground))),
// AbstractArrow.Pickup: 0 disallowed, 1 allowed, 2 creative only.
("pickup", Tag::Byte(if t.creative_only { 2 } else { 1 })),
("pickup", Tag::Byte(t.pickup.byte())),
("damage", Tag::Double(2.0_f64.to_bits())),
("HasBeenShot", Tag::Byte(1)),
("HasBeenShot", Tag::Byte(i8::from(t.has_been_shot))),
("crit", Tag::Byte(0)),
("PierceLevel", Tag::Byte(0)),
("SoundEvent", string("minecraft:item.trident.hit_ground")),
@@ -3666,8 +3703,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
}
tag
}
/// (position, motion, yaw, pitch, in ground, life, shake, dealt damage,
/// creative only, the trident item)
/// `AbstractArrow` and `Projectile.readAdditionalSaveData` for a trident.
pub struct TridentNbt {
pub position: Vec3,
pub motion: Vec3,
@@ -3677,11 +3713,15 @@ pub struct TridentNbt {
pub life: i32,
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub pickup: Pickup,
pub left_owner: bool,
pub has_been_shot: bool,
pub owner: Option<[i32; 4]>,
pub no_gravity: bool,
pub fire_ticks: i32,
pub item: crate::items::ItemStack,
}
/// `ThrownTrident.readAdditionalSaveData`, including its arrow/projectile defaults.
pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
let [yaw, pitch] = rotation(tag);
Some(TridentNbt {
@@ -3693,7 +3733,10 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
life: number(tag.get("life")).unwrap_or(0) as i32,
shake: number(tag.get("shake")).unwrap_or(0).max(0) as u32,
dealt_damage: flag(tag, "DealtDamage"),
creative_only: number(tag.get("pickup")) == Some(2),
pickup: Pickup::from_byte(number(tag.get("pickup")).unwrap_or(0)),
left_owner: flag(tag, "LeftOwner"),
has_been_shot: flag(tag, "HasBeenShot"),
owner: owner_uuid(tag),
no_gravity: flag(tag, "NoGravity"),
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
item: tag
@@ -5115,7 +5158,10 @@ mod tests {
life: 9,
shake: 3,
dealt_damage: true,
creative_only: true,
pickup: Pickup::CreativeOnly,
left_owner: true,
has_been_shot: true,
owner: Some([5, 6, 7, 8]),
no_gravity: true,
fire_ticks: 0,
item: &trident,
@@ -5128,7 +5174,9 @@ mod tests {
assert!(t.no_gravity);
assert!(matches!(tag.get("NoGravity"), Some(Tag::Byte(1))));
assert_eq!((t.in_ground, t.life, t.shake), (true, 9, 3));
assert_eq!((t.dealt_damage, t.creative_only), (true, true));
assert_eq!((t.dealt_damage, t.pickup), (true, Pickup::CreativeOnly));
assert!(t.left_owner && t.has_been_shot);
assert_eq!(t.owner, Some([5, 6, 7, 8]));
assert_eq!((t.yaw, t.pitch), (10.0, 20.0));
}
@@ -6073,7 +6121,14 @@ mod tests {
assert_eq!(attributes(&armor_stand_from_nbt(&saved, 1).unwrap()), speed);
}
#[test]
fn ghast_and_trident_save_their_always_present_keys() {
fn summoned_trident_defaults_are_not_pickable_or_already_shot() {
let raw = Tag::compound([
("Pos", doubles(&[0.0, 0.0, 0.0])),
("LeftOwner", Tag::Byte(0)),
]);
let loaded = trident_from_nbt(&raw).unwrap();
assert_eq!(loaded.pickup, Pickup::Disallowed);
assert!(!loaded.left_owner && !loaded.has_been_shot);
let trident = ItemStack::new("minecraft:trident", 1);
let tag = trident_to_nbt(
&TridentSave {
@@ -6085,15 +6140,20 @@ mod tests {
life: 0,
shake: 0,
dealt_damage: false,
creative_only: false,
pickup: loaded.pickup,
left_owner: loaded.left_owner,
has_been_shot: loaded.has_been_shot,
owner: loaded.owner,
no_gravity: false,
fire_ticks: 0,
item: &trident,
},
[0; 4],
None,
Some(&raw),
);
assert_eq!(tag.get("LeftOwner"), Some(&Tag::Byte(1)));
assert!(tag.get("LeftOwner").is_none());
assert_eq!(tag.get("HasBeenShot"), Some(&Tag::Byte(0)));
assert_eq!(tag.get("pickup"), Some(&Tag::Byte(0)));
}
#[test]
fn orbs_keep_their_count() {
@@ -7581,7 +7641,13 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get("InWaterTime")), Some(600));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
// A skeleton outside powder snow is not afflicted: -1 whatever the
// counter holds, as with a fresh or NoAI one.
let (_, tag) = round_trip(&new("minecraft:skeleton"));
assert_eq!(number(tag.get("FreezingTime")), Some(-1));
assert_eq!(number(tag.get("StrayConversionTime")), Some(-1));
let mut skeleton = new("minecraft:skeleton");
skeleton.in_powder_snow = true;
skeleton.extra.counter = 70;
let (back, tag) = round_trip(&skeleton);
assert_eq!(number(tag.get("FreezingTime")), Some(70));
@@ -7604,6 +7670,30 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get(conversion)), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (-1, 0));
}
// Zombie villagers and zombified piglins keep Zombie's tracker tags.
for kind in ["minecraft:zombie_villager", "minecraft:zombified_piglin"] {
let (_, tag) = round_trip(&new(kind));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1), "{kind}");
assert_eq!(number(tag.get("InWaterTime")), Some(-1), "{kind}");
}
// A ticking one that left the snow or the water saves -1 over the times
// it was loaded with (ConversionTracker.tick: setConverting(false)).
for (kind, conversion, affliction) in [
("minecraft:skeleton", "StrayConversionTime", "FreezingTime"),
("minecraft:zombie", "DrownedConversionTime", "InWaterTime"),
] {
let loaded = Tag::compound([
("id", string(kind)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
(conversion, Tag::Int(200)),
(affliction, Tag::Int(5)),
]);
let mut m = new(kind);
(m.extra.counter, m.extra.counter2) = (-1, 0);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&loaded), Some(&registry), |_| None);
assert_eq!(number(tag.get(conversion)), Some(-1), "{kind}");
assert_eq!(number(tag.get(affliction)), Some(-1), "{kind}");
}
// A saved conversion time starts one: `startConversion`.
let saved = Tag::compound([
("id", string("minecraft:zombie")),
@@ -10351,4 +10441,48 @@ mod equine_temper_preservation_tests {
);
}
}
#[test]
fn equine_and_ravager_attributes_parity() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
Arc::make_mut(&mut kind).species = Species::Donkey;
let types = [("minecraft:donkey".into(), kind.clone())].into();
let donkey = mob_from_nbt(
&Tag::compound([
("id", string("minecraft:donkey")),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]),
&types,
1,
None,
)
.unwrap();
assert_eq!(donkey.extra.vector[0], 0.5);
assert_eq!(
mob_to_nbt(&donkey, [0; 4], None, None, |_| None).get("attributes"),
Some(&Tag::List(vec![]))
);
Arc::make_mut(&mut kind).species = Species::Ravager;
let types = [("minecraft:ravager".into(), kind)].into();
let mut ravager = mob_from_nbt(
&Tag::compound([
("id", string("minecraft:ravager")),
("Pos", doubles(&[0.0, 0.0, 0.0])),
("RoarTick", Tag::Int(10)),
]),
&types,
2,
None,
)
.unwrap();
ravager.age = 1;
let saved = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
assert_eq!(
saved.get("attributes"),
Some(&Tag::List(vec![Tag::compound([
("id", string("minecraft:movement_speed")),
("base", Tag::Double(0.0f64.to_bits()))
])]))
);
}
}
+215 -20
View File
@@ -3287,6 +3287,8 @@ struct Game<'a> {
saved_for_exit: bool,
native_client_stopped: bool,
pending_save: Option<saving::ClosingSave>,
/// Scheduled end of the server tick (`MinecraftServer.haveTime`).
server_tick_deadline: Option<Instant>,
/// The last world frame reached the swap chain. A window the compositor
/// stops showing gets no frames, and the event loop must not spin on it.
frame_drawn: bool,
@@ -5552,6 +5554,7 @@ impl Game<'_> {
laps = horizon_client::spans::laps();
let sent = self.lan_broadcast_changes();
laps.lap("tb_broadcast");
self.save_chunks_eagerly();
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
});
@@ -6266,7 +6269,10 @@ impl Game<'_> {
);
self.container_edge_sound(key, true);
}
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
} else if matches!(&*name, "minecraft:beacon" | "minecraft:enchanting_table")
&& self.open_station(pos, &name)
{
// Station menus whose block has a block entity, not a container.
self.world.block_entities.get_or_insert_with(key, || {
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
});
@@ -7885,6 +7891,10 @@ impl Game<'_> {
// keep their own packet cutoff (`ordered.announced`, #10063).
self.ticked_entities_through = self.next_entity;
}
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
if !running {
self.save_chunks_eagerly();
}
self.begin_ordered_ticks();
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
@@ -8569,6 +8579,8 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -8633,6 +8645,8 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -8768,6 +8782,8 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
@@ -12504,7 +12520,8 @@ impl Game<'_> {
if !o.bounds().intersects(area) {
continue;
}
let (id, orb_value, vanilla) = (o.id, o.value, o.can_merge(merge_id as u32, value));
let vanilla = o.can_merge(merge_id as u32, value as i32);
let (id, orb_value) = (o.id, o.points());
if self.orb_merges(id, orb_value, 0, value, vanilla) {
target = Some(i);
break;
@@ -12569,9 +12586,9 @@ impl Game<'_> {
(
b.can_merge(a.id, a.value),
a.id,
a.value,
a.points(),
b.id,
b.value,
b.points(),
b.count,
b.age,
)
@@ -12638,7 +12655,8 @@ impl Game<'_> {
position: p,
previous: p,
velocity,
value,
// `getExperienceValue` sizes: 1 to 2477.
value: value as i32,
age: 0,
on_ground: false,
count: 1,
@@ -12806,7 +12824,7 @@ impl Game<'_> {
// ExperienceOrb.playerTouch: one orb every 2 ticks; a
// merged orb gives its value once per count.
self.xp_delay = 2;
gained += o.value;
gained += o.points();
taken.push(o.clone());
o.count -= 1;
return o.count > 0;
@@ -13997,11 +14015,15 @@ impl Game<'_> {
self.save
.save_chunk_tickets(&self.streaming.forced, self.tickets.portal_tickets())?;
self.streaming.save(&mut self.world, wait);
let now = Instant::now();
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))?;
// Prepare entities while the chunk writer runs, then use one barrier.
if !wait {
self.world.modified.autosaved(&keys, now);
}
self.save_entities(false)?;
self.save_raids();
if wait {
@@ -14009,6 +14031,51 @@ impl Game<'_> {
}
Ok(())
}
/// `ChunkMap.saveChunksEagerly`: snapshot only while the tick and writer
/// have room. NBT encoding, compression and durable writes stay on workers.
fn save_chunks_eagerly(&mut self) {
// No world open (`world_root` is set by `finish_open_world`): the save
// is `Game::new`'s placeholder under the working directory.
if self.remote.is_some()
|| self.world_root.as_os_str().is_empty()
|| self.agent.hold_server
|| self.clock.sprinting()
|| self
.server_lists
.as_ref()
.is_some_and(|lists| !lists.lock().unwrap().autosave)
{
return;
}
let deadline = self
.server_tick_deadline
.unwrap_or_else(|| Instant::now() + self.clock.tick_duration());
let mut modified = std::mem::take(&mut self.world.modified);
modified.save_eagerly(
Instant::now(),
|| Instant::now() < deadline && self.save.active_chunk_writes() < 128,
|key| self.world.chunks.contains_key(&key),
|key| {
// `saveChunkIfNeeded` saves only a LevelChunk, so a lit one; a
// column still waiting for its light keeps its queue entry.
let Some(chunk) = self.world.chunks.get(&key).filter(|c| c.lit) else {
return false;
};
let chunks = self.with_entities([chunk]);
match self
.save
.save_later(&chunks, self.pack.assets.registry, world_biomes!(self))
{
Ok(_) => true,
Err(e) => {
log::warn!("Horizon: failed to save changed chunks: {e}");
false
}
}
},
);
self.world.modified = modified;
}
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
/// saved icon is on the screen, grab the rendered frame once a second until
/// the save has one. The Select World list shows it as the world's preview.
@@ -14350,6 +14417,7 @@ impl Game<'_> {
camera: camera.to_array(),
sky_light: i32::from(light >> 4),
block_light: i32::from(light & 15),
biome: biomes.get(biome).name.clone(),
temperature: biomes.get(biome).temperature,
downfall: biomes.get(biome).downfall,
};
@@ -14628,7 +14696,9 @@ impl Game<'_> {
} else {
self.bolt_instances(camera)
};
let mut additive = Vec::new();
// Particles blended additively (resource packs' Polytone ones, #11486), from the
// render that built `entities`.
let mut additive = self.particles.take_additive();
// The dragon is drawn in every dimension, as any entity: a dragon
// summoned outside the End (the server's AddEntity) is shown too.
if !title {
@@ -15152,6 +15222,9 @@ impl Game<'_> {
}
});
}
for chunk in &unloaded {
self.world.modified.unloaded(&(chunk.x, chunk.z));
}
let dirty: Vec<_> = unloaded
.iter()
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
@@ -15406,6 +15479,18 @@ impl Game<'_> {
{
title.drop_file(path.clone());
}
// GLFW never hands a field the keys an input method consumes: while a
// composition is open, and the Enter or Escape that ends it (winit on
// Windows still sends that one right after `Ime::Commit`).
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &event
&& key_event.state == ElementState::Pressed
&& self.text_field_focused()
&& horizon_client::preedit::should_suppress_key()
{
return;
}
// A character that comes with no key (Windows' Alt codes, `Alt` + digits on the numeric
// keypad: a `WM_CHAR` after the Alt is released) reaches winit as a key event with no
// physical key. GLFW's char callback types it in the focused text field (`charTyped`),
@@ -15756,6 +15841,7 @@ impl Game<'_> {
{
return Ok(());
}
self.agent_cancel("the game window was closed; the game saves and exits");
self.begin_closing_save(true)?;
}
// Input method composition: the committed text types like a key's text.
@@ -17563,6 +17649,7 @@ impl<'a> Game<'a> {
saved_for_exit: false,
native_client_stopped: false,
pending_save: None,
server_tick_deadline: None,
frame_drawn: true,
world_icon: None,
forced_hit: None,
@@ -18239,6 +18326,10 @@ pub fn run_with() -> Result<()> {
horizon_client::spans::record_here(true);
}
let vanilla_root = options.resources.clone();
// Saves from before 1.13 upgrade with vanilla's tables, prepared from the player's jar.
horizon_world::datafix::set_resources(
&std::path::absolute(&vanilla_root).unwrap_or_else(|_| vanilla_root.clone()),
);
if let Some(error) = missing_resources(&vanilla_root) {
// The game build started by hand (the release's zip), not by the
// launcher: a window says where to get it. Scripts and servers get
@@ -18258,16 +18349,14 @@ pub fn run_with() -> Result<()> {
#[cfg(feature = "heartbeat")]
horizon_client::heartbeat::start();
let player_dir = horizon_client::logging::player_dir();
// horizon-extras: the official and partner servers are Horizon's (docs/forking.md).
// horizon-extras: the official server is Horizon's (docs/forking.md).
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
{
log::warn!("could not add the official server to servers.dat: {e}");
}
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_partner_servers(&player_dir)
{
log::warn!("could not add the partner servers to servers.dat: {e}");
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
}
mods::register_components(&vanilla_root)?;
@@ -18308,17 +18397,18 @@ pub fn run_with() -> Result<()> {
return run_loading(options, early_settings, vanilla_root, None, started);
}
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
vanilla_root.clone()
});
options.resources = mods::content_root(
&options.resources,
let base = mods::content_root(
&vanilla_root,
&early_settings.mods.enabled,
&options.dev_mods,
);
options.resources =
packs::startup_root(&vanilla_root, &base, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
base.clone()
});
let loaded = load_resources(
options.resources.clone(),
launch_world(&options),
@@ -18825,6 +18915,103 @@ mod tests {
));
}
#[test]
fn eager_saves_keep_dirty_order_cooldowns_and_writer_budget() {
use horizon_client::world::ModifiedChunks;
use std::cell::Cell;
let now = Instant::now();
let mut dirty = ModifiedChunks::default();
dirty.extend((0..27).map(|x| (x, 0)));
dirty.autosaved(&[(0, 0), (2, 0)], now);
dirty.remove(&(1, 0)); // already clean
let writes = Cell::new(127);
let mut chosen = Vec::new();
dirty.save_eagerly(
now,
|| writes.get() < 128,
|k| k != (3, 0),
|k| {
chosen.push(k);
writes.set(writes.get() + 1);
true
},
);
assert_eq!(chosen, [(4, 0)]); // cooldown, clean, absent, then budget exhausted
chosen.clear();
dirty.save_eagerly(
now,
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, (5..25).map(|x| (x, 0)).collect::<Vec<_>>()); // exactly 20
dirty.insert((4, 0)); // a later edit goes AFTER the still-cooled entries
chosen.clear();
dirty.save_eagerly(
now + Duration::from_secs(10),
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, [(0, 0), (2, 0), (25, 0), (26, 0), (4, 0)]);
dirty.insert((4, 0));
dirty.save_eagerly(
now + Duration::from_secs(11),
|| false,
|_| true,
|_| panic!("no time"),
);
assert!(dirty.contains(&(4, 0)));
dirty.unloaded(&(4, 0)); // unloading removes the cooldown even if reloaded immediately
chosen.clear();
dirty.save_eagerly(
now + Duration::from_secs(11),
|| true,
|_| true,
|k| {
chosen.push(k);
true
},
);
assert_eq!(chosen, [(4, 0)]);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn eager_saves_wait_for_an_open_world_and_for_light() {
let mut game = super::native_machines::tests::server_game();
let dir = std::env::temp_dir().join(format!("horizon-eager-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
game.save = Arc::new(horizon_client::save::SaveDir::new(
dir.clone(),
horizon_client::save::LevelShape::OVERWORLD,
));
Arc::make_mut(game.world.chunks.get_mut(&(0, 0)).unwrap()).lit = true;
game.world
.chunks
.insert((1, 0), Arc::new(horizon_client::chunk::Chunk::empty(1, 0)));
game.world.modified.extend([(0, 0), (1, 0)]);
game.save_chunks_eagerly(); // no world open: nothing to save into
assert!(game.world.modified.contains(&(0, 0)));
game.world_root = dir.clone();
game.save_chunks_eagerly();
assert!(!game.world.modified.contains(&(0, 0)));
assert!(game.world.modified.contains(&(1, 0))); // not lit yet
Arc::make_mut(game.world.chunks.get_mut(&(1, 0)).unwrap()).lit = true;
game.save_chunks_eagerly(); // still queued: no new dirty transition needed
assert!(!game.world.modified.contains(&(1, 0)));
game.save.flush().unwrap();
assert!(dir.join("region/r.0.0.mca").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn integrated_server_save_persists_owner_player_data() {
let dir = std::env::temp_dir().join(format!(
@@ -18901,6 +19088,8 @@ mod golem_defense_tests;
#[cfg(test)]
mod inline_feature_parse_tests;
#[cfg(test)]
mod inline_loot_reference_tests;
#[cfg(test)]
mod interaction_data_tests;
#[cfg(test)]
mod kill_die_cancel_tests;
@@ -18911,14 +19100,20 @@ mod loot_kill_guest_tests;
#[cfg(test)]
mod mannequin_save_tests;
#[cfg(test)]
mod mob_landing_support_tests;
#[cfg(test)]
mod orb_data_tests;
#[cfg(test)]
mod stand_break_loot_tests;
#[cfg(test)]
mod stand_command_hurt_tests;
#[cfg(test)]
mod storage_alias_tests;
#[cfg(test)]
mod sulfur_bucket_tests;
#[cfg(test)]
mod sulfur_prime_phase_tests;
#[cfg(test)]
mod test_pos_guest_tests;
#[cfg(test)]
mod vibration_source_tests;
+61 -1
View File
@@ -2,6 +2,13 @@
//! events (`CriteriaTriggers`), awarding with rewards, the toast and the
//! chat announcement, and `players/advancements/<uuid>.json`.
use super::{Dim, Game};
#[cfg(test)]
#[path = "inventory_changed_tests.rs"]
mod inventory_changed_tests;
#[cfg(test)]
#[path = "mod_toast_tests.rs"]
mod mod_toast_tests;
use horizon_client::actions::Action;
use horizon_client::{
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
@@ -24,6 +31,9 @@ pub(super) struct Toast {
icon: String,
frame: Frame,
title: Text,
/// A mod's own toast (`native_add_toast`): the line drawn in place of
/// the frame's label, `title` then being its description.
label: Option<Text>,
slide: Slide,
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
/// leaves the queue.
@@ -42,6 +52,7 @@ impl Toast {
icon: String::new(),
frame: Frame::Task,
title: Text::literal(""),
label: None,
slide: Slide::default(),
slot: None,
recipes: items,
@@ -49,6 +60,26 @@ impl Toast {
last_changed: 0,
}
}
/// A mod's own toast (`native_add_toast`, API @API:add_toast@): drawn as an `AdvancementToast`
/// with `label` in the frame label's place and `description` as its
/// title, yellow (pink, with the challenge sound, for a challenge).
pub(super) fn of_mod(icon: &str, label: Text, description: Text, challenge: bool) -> Self {
Self {
icon: icon.to_owned(),
frame: if challenge {
Frame::Challenge
} else {
Frame::Task
},
title: description,
label: Some(label),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
}
}
pub(super) fn is_recipe(&self) -> bool {
!self.recipes.is_empty()
}
@@ -1020,6 +1051,7 @@ impl Game<'_> {
icon: d.icon.clone(),
frame: d.frame,
title: text_of(&d.title),
label: None,
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
@@ -1127,6 +1159,17 @@ impl Game<'_> {
return;
}
self.adv.last_inventory = key;
// Native mods hear the change before any criterion is looked at, so
// they do with no `inventory_changed` advancement in the tree too
// (`native_player_inventory_changed`, API @API:player_inventory_changed@).
if self.remote.is_none()
&& !self.no_player_here()
&& !horizon_client::mod_api::mods().is_empty()
{
let stacks = inventory_slots(&self.inventory, &self.armor, &self.offhand);
let player = self.native_acting_id();
self.native_player_inventory_changed(player, &stacks);
}
let stacks: Vec<ItemView> = self
.inventory
.iter()
@@ -2234,6 +2277,7 @@ impl Game<'_> {
let x = batch.width - 160.0 * t.slide.portion;
let fully_for = t.slide.fully_for;
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
let mod_label = t.label.clone();
let recipe = t
.is_recipe()
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
@@ -2260,7 +2304,7 @@ impl Game<'_> {
// the frame's label; a longer one is wrapped at 125, the label fading
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
let lines = atlas.font.split(&title.resolve_legacy(), 125.0);
let label = Text::translate(key, &[]);
let label = mod_label.unwrap_or_else(|| Text::translate(key, &[]));
let rgb = color & 0x00FF_FFFF;
if let [line] = lines.as_slice() {
batch.text_styled(atlas, &label, x + 30.0, y + 7.0, color, false);
@@ -2489,6 +2533,7 @@ impl Game<'_> {
icon: d.icon.clone(),
frame: d.frame,
title: text_of(&d.title),
label: None,
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
@@ -3421,3 +3466,18 @@ mod movement_predicate_tests {
);
}
}
/// A player's 41 slots in `Inventory` numbering, for
/// `native_player_inventory_changed`: the main inventory (0..36), the armour
/// feet to head (36..40; `armor` holds it head first), the off hand (40).
pub(super) fn inventory_slots(
inventory: &[Option<ItemStack>],
armor: &[Option<ItemStack>; 4],
offhand: &Option<ItemStack>,
) -> Vec<Option<ItemStack>> {
let mut slots: Vec<Option<ItemStack>> = inventory.iter().take(36).cloned().collect();
slots.resize(36, None);
slots.extend(armor.iter().rev().cloned());
slots.push(offhand.clone());
slots
}
+209 -22
View File
@@ -54,8 +54,8 @@ pub(super) const HELP: &[&str] = &[
"mouse-move DX DY relative mouse motion, turned like a player's mouse",
"attack [ID] swing at the target (after looking at entity ID)",
"break X Y Z [--timeout S] mine a block until it breaks; reports drops and pickups",
"place X Y Z [--item ITEM] place the held (or given) item at a position",
"interact X Y Z [FACE] right-click a block (open a door, chest, crafting table...)",
"place X Y Z [--item ITEM] [--sneak] place the held (or given) item at a position",
"interact X Y Z [FACE] [--sneak] right-click a block (open a door, chest, crafting table...)",
"use-item [--hold S] right-click with the held item (eat, drink, throw, draw a bow)",
"select SLOT|ITEM select a hotbar slot (1-9) or the hotbar slot holding ITEM",
"drop [all] drop one of the held item (all: the stack)",
@@ -133,6 +133,16 @@ impl Args<'_> {
v.parse()
.map_err(|_| format!("{what} must be a number, not {v}").into())
}
/// X or Z of a goto target: a whole number names a block, so aim at its
/// center the way the player stands in it; a decimal is an exact position.
fn column(&self, i: usize, what: &str) -> Result<f64> {
let v = self.f64(i, what)?;
Ok(if whole(self.get(i, what)?) {
v + 0.5
} else {
v
})
}
fn i32(&self, i: usize, what: &str) -> Result<i32> {
let v = self.get(i, what)?;
v.parse()
@@ -179,6 +189,9 @@ fn face_dir(name: &str) -> Result<[i32; 3]> {
fn add(a: [i32; 3], b: [i32; 3]) -> [i32; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn whole(v: &str) -> bool {
!v.contains(['.', 'e', 'E'])
}
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
@@ -236,6 +249,8 @@ enum TaskKind {
report_tick: u64,
release: Option<Instant>,
place: bool,
/// `--sneak`: the click waits until the sneak key has been held.
sneak: Option<SneakClick>,
},
Attack {
id: Option<u32>,
@@ -275,6 +290,16 @@ struct Break {
broke_tick: u64,
drops: BTreeMap<u32, ItemStack>,
}
/// A right click made while sneaking (placing against a crafting table or a
/// chest instead of opening it): the sneak key is held for a couple of ticks
/// first, so the server has the player's shift state before the click.
struct SneakClick {
/// Not clicked yet: click on this tick.
click_tick: Option<u64>,
/// The block (and face) to aim at again, as sneaking lowers the eye.
aim: Option<([i32; 3], Option<[i32; 3]>)>,
hold: f64,
}
struct Task {
kind: TaskKind,
reply: Sender<String>,
@@ -382,6 +407,13 @@ impl Game<'_> {
if s.damage > 0 {
v["damage"] = json!(s.damage);
}
// How worn a tool or armour piece is: its durability and what is left.
let items = self.res.items();
if horizon_client::stations::is_damageable(items, s) {
let max = horizon_client::stations::max_damage(items, s);
v["max_damage"] = json!(max);
v["durability_left"] = json!((i64::from(max) - i64::from(s.damage)).max(0));
}
if let Some(c) = horizon_client::item_data::encode_components(s) {
v["components"] = json!(horizon_client::snbt::to_snbt(&c));
}
@@ -758,7 +790,20 @@ impl Game<'_> {
);
}
list.sort_by(|a, b| a.0.total_cmp(&b.0));
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
// What the search covered, so an empty list reads as "none within
// the radius" rather than "none at all": everything this client
// tracks, and the loaded chunks.
json!({
"entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
"radius": radius,
"type": want,
"tracked": {
"mobs": self.mobs.iter().filter(|m| !m.removed).count(),
"items": self.dropped.len(),
"orbs": self.orbs.len(),
},
"loaded_chunks": self.world.chunks.len(),
})
}
pub(super) fn inventory_value(&self) -> Value {
@@ -895,12 +940,15 @@ impl Game<'_> {
let point = |f: &[i32; 3]| [0, 1, 2].map(|i| centre[i] + f64::from(f[i]) * 0.45);
dist(eye, point(a)).total_cmp(&dist(eye, point(b)))
});
// The first other block a ray stopped at, to name what is in the way.
let mut in_the_way: Option<[i32; 3]> = None;
for f in faces {
for inset in [0.45, 0.3, 0.0] {
let point = [0, 1, 2].map(|i| centre[i] + f64::from(f[i]) * inset);
let (yaw, pitch) = yaw_pitch(eye, point);
let dir = super::view_vector(yaw, pitch);
if let Some(hit) = self.clip_ray(eye, dir, self.reach())
let hit = self.clip_ray(eye, dir, self.reach());
if let Some(hit) = &hit
&& hit.pos == pos
&& (face.is_none() || hit.face == f)
{
@@ -908,6 +956,11 @@ impl Game<'_> {
self.pitch = pitch;
return Ok(hit.face);
}
if let Some(hit) = hit
&& hit.pos != pos
{
in_the_way.get_or_insert(hit.pos);
}
}
}
let d = dist(eye, centre);
@@ -918,13 +971,56 @@ impl Game<'_> {
)
.into());
}
if let Some(p) = in_the_way {
let name = &self
.res
.facts()
.get(self.world.block(p[0], p[1], p[2]))
.name;
return Err(format!(
"no face of block {pos:?} is visible from here: {name} at {p:?} is in the way"
)
.into());
}
Err(format!("no face of block {pos:?} is visible from here").into())
}
/// `--sneak` on place or interact: hold the sneak key now and click a
/// couple of ticks later, from the lowered eye (see [`SneakClick`]).
fn sneak_click(
&mut self,
a: &Args<'_>,
aim: Option<([i32; 3], Option<[i32; 3]>)>,
hold: f64,
) -> Option<SneakClick> {
if !a.has("sneak") {
return None;
}
self.keys.press(KeyCode::ShiftLeft, Source::Agent);
Some(SneakClick {
click_tick: Some(self.ticks + 2),
aim,
hold,
})
}
/// A left or right click, as the mouse would.
fn agent_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
self.mouse_mapping(button, pressed, Source::Agent)
}
/// Ends the running agent command (and a turn) with `why`, so the agent
/// hears why rather than the socket just closing (the window closing
/// while it waits, say).
pub(super) fn agent_cancel(&mut self, why: &str) {
let reply = json!({ "error": why, "cancelled": true }).to_string();
if let Some(t) = self.agent.task.take() {
let _ = t.reply.send(reply.clone());
}
if let Some(g) = self.agent.glide.take() {
let _ = g.reply.send(reply);
}
self.release_agent_keys();
}
fn release_agent_keys(&mut self) {
for k in MOVE_KEYS {
self.keys.release(&k, Source::Agent);
@@ -1195,7 +1291,7 @@ impl Game<'_> {
}
"goto" => {
self.playing()?;
let target = [a.f64(0, "X")?, a.f64(1, "Y")?, a.f64(2, "Z")?];
let target = [a.column(0, "X")?, a.f64(1, "Y")?, a.column(2, "Z")?];
let near = a.num("near", 0.6)?.max(0.3);
let timeout = a.num("timeout", 60.0)?;
let p = self.player.as_ref().ok_or("no player")?.position;
@@ -1375,6 +1471,7 @@ impl Game<'_> {
let mut aimed = !self.res.facts().is_air(before)
&& !self.res.facts().get(before).outline.is_empty()
&& self.aim_block(pos, None).is_ok();
let mut aim_at = aimed.then_some((pos, None));
for (_, d) in FACES.iter().filter(|_| !aimed) {
let d = *d;
let support = add(pos, d);
@@ -1389,6 +1486,7 @@ impl Game<'_> {
let face = d.map(|v| -v);
if self.aim_block(support, Some(face)).is_ok() {
aimed = true;
aim_at = Some((support, Some(face)));
break;
}
}
@@ -1399,8 +1497,11 @@ impl Game<'_> {
.into());
}
let inventory = self.inventory_counts();
self.agent_mouse(MouseButton::Right, true)?;
self.agent_mouse(MouseButton::Right, false)?;
let sneak = self.sneak_click(&a, aim_at, 0.0);
if sneak.is_none() {
self.agent_mouse(MouseButton::Right, true)?;
self.agent_mouse(MouseButton::Right, false)?;
}
let report_tick = self.ticks + 2;
self.start_task(
"place",
@@ -1411,6 +1512,7 @@ impl Game<'_> {
report_tick,
release: None,
place: true,
sneak,
},
reply,
5.0,
@@ -1419,22 +1521,29 @@ impl Game<'_> {
"interact" | "use-item" => {
self.playing()?;
let hold = a.num("hold", 0.0)?;
let mut aim_at = None;
let pos = if name == "interact" {
let pos = a.block_pos(0)?;
let face = a.pos.get(3).map(|f| face_dir(f)).transpose()?;
self.aim_block(pos, face)?;
aim_at = Some((pos, face));
Some(pos)
} else {
None
};
let before = pos.map(|p| self.world.block(p[0], p[1], p[2]));
let inventory = self.inventory_counts();
self.agent_mouse(MouseButton::Right, true)?;
let release = if hold > 0.0 {
Some(Instant::now() + Duration::from_secs_f64(hold))
} else {
self.agent_mouse(MouseButton::Right, false)?;
let sneak = self.sneak_click(&a, aim_at, hold);
let release = if sneak.is_some() {
None
} else {
self.agent_mouse(MouseButton::Right, true)?;
if hold > 0.0 {
Some(Instant::now() + Duration::from_secs_f64(hold))
} else {
self.agent_mouse(MouseButton::Right, false)?;
None
}
};
let report_tick = self.ticks + 2;
self.start_task(
@@ -1450,6 +1559,7 @@ impl Game<'_> {
report_tick,
release,
place: false,
sneak,
},
reply,
hold + 5.0,
@@ -1964,7 +2074,29 @@ impl Game<'_> {
report_tick,
release,
place,
sneak,
} => {
if let Some(s) = sneak
&& let Some(at) = s.click_tick
{
if self.ticks < at && !timed_out && interrupted.is_none() {
return None;
}
s.click_tick = None;
if interrupted.is_none() && !timed_out {
if let Some((p, f)) = s.aim {
let _ = self.aim_block(p, f);
}
let _ = self.agent_mouse(MouseButton::Right, true);
if s.hold > 0.0 {
*release = Some(Instant::now() + Duration::from_secs_f64(s.hold));
} else {
let _ = self.agent_mouse(MouseButton::Right, false);
}
*report_tick = self.ticks + 2;
return None;
}
}
if let Some(at) = release {
if Instant::now() < *at && !timed_out && interrupted.is_none() {
return None;
@@ -1990,6 +2122,23 @@ impl Game<'_> {
{
return None;
}
// Likewise a block's menu (crafting table, furnace, chest)
// opens once the server's open-screen packet comes: wait for
// it while nothing else came of the click yet.
if !*place
&& pos.is_some()
&& !changed
&& self.remote.is_some()
&& self.screen == Screen::Playing
&& *inventory == self.inventory_counts()
&& self.ticks < *report_tick + 20
&& !timed_out
{
return None;
}
if sneak.is_some() {
self.keys.release(&KeyCode::ShiftLeft, Source::Agent);
}
let after = pos.map(|p| self.block_json(p));
let used = delta(inventory, &self.inventory_counts());
let mut v = json!({
@@ -2001,9 +2150,24 @@ impl Game<'_> {
"screen": self.screen_label(),
});
if *place && !changed {
v["error"] = json!(
// The player's own box (0.6 wide, 1.8 high) in the cell
// is the usual reason: say so.
let p = self.position();
let player_inside = pos.is_some_and(|b| {
(0..3).all(|i| {
let (lo, hi) = if i == 1 {
(p[1], p[1] + 1.8)
} else {
(p[i] - 0.3, p[i] + 0.3)
};
lo < f64::from(b[i]) + 1.0 && hi > f64::from(b[i])
})
});
v["error"] = json!(if player_inside {
"the block did not change: the player is standing in that cell; move out of it first"
} else {
"the block did not change (blocked by an entity, or the item cannot be placed)"
);
});
}
Some(v)
}
@@ -2036,13 +2200,13 @@ impl Game<'_> {
if navigation::arrived(p, g.target, g.near) {
return Some(
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
"distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
);
}
if timed_out {
return Some(
json!({ "error": "timed out before arriving", "arrived": false, "position": p,
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
"target": g.target, "distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
);
}
let previous_next = g.next;
@@ -2065,7 +2229,7 @@ impl Game<'_> {
if g.replans > 3 {
return Some(
json!({ "error": "stuck: cannot make progress", "arrived": false,
"position": p, "target": g.target, "distance": dist(p, g.target) }),
"position": p, "target": g.target, "distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target) }),
);
}
if g.next > 0 && g.next < g.path.nodes.len() {
@@ -2084,7 +2248,7 @@ impl Game<'_> {
return Some(
json!({ "error": "no further walking progress through loaded terrain",
"arrived": false, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
"distance": dist(p, g.target), "horizontal_distance": horizontal(p, g.target), "warning": g.warning }),
);
};
g.path = path;
@@ -2127,6 +2291,14 @@ impl Game<'_> {
None
}
/// Whether a mined block is gone: air, or the bare fluid a waterlogged
/// block or ice leaves behind.
fn block_gone(&self, state: BlockStateId) -> bool {
let facts = self.res.facts();
let f = facts.get(state);
facts.is_air(state) || f.fluid != horizon_client::blocks::Fluid::None && !f.waterlogged
}
fn step_break(
&mut self,
b: &mut Break,
@@ -2138,6 +2310,11 @@ impl Game<'_> {
if let Some(why) = interrupted {
return Some(json!({ "error": why, "pos": b.pos }));
}
if state != b.before && !self.block_gone(state) {
// Changed into another block while mined (dirt spread to
// grass, a crop grew, copper weathered): keep mining it.
b.before = state;
}
if state != b.before {
// Broken: collect what drops for a moment.
let _ = self.agent_mouse(MouseButton::Left, false);
@@ -2145,10 +2322,13 @@ impl Game<'_> {
b.collect_until = Some(self.ticks + 20);
} else if timed_out {
let _ = self.agent_mouse(MouseButton::Left, false);
return Some(
json!({ "error": "timed out before the block broke", "pos": b.pos,
"block": self.state_string(state), "ticks": self.ticks - b.started_tick }),
);
// The held item explains most slow breaks: a tool that broke
// mid-vein leaves the bare hand, and stone never breaks by hand.
return Some(json!({
"error": "timed out before the block broke", "pos": b.pos,
"block": self.state_string(state), "ticks": self.ticks - b.started_tick,
"held": self.held().map(|s| self.stack_value(s)),
}));
} else if self.screen != Screen::Playing {
return Some(
json!({ "error": format!("the {} screen opened", self.screen_label()), "pos": b.pos }),
@@ -2193,6 +2373,7 @@ impl Game<'_> {
"ticks": b.broke_tick - b.started_tick,
"drops": counts(b.drops.values().cloned()),
"picked_up": delta(&b.inventory, &self.inventory_counts()),
"held": self.held().map(|s| self.stack_value(s)),
"items_left_on_ground": remaining,
"on_ground": self.dropped.iter().filter(|i| b.drops.contains_key(&i.id)).map(|i| json!({
"id": i.id, "item": &*i.stack.item, "count": i.stack.count,
@@ -2206,6 +2387,12 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
fn whole_goto_coordinates_name_a_block() {
assert!(whole("8") && whole("-12"));
assert!(!whole("8.0") && !whole("8.25") && !whole("1e2"));
}
#[test]
fn only_wait_keeps_held_input() {
assert!(!TaskKind::Wait { until_tick: 40 }.releases_input());
@@ -1158,6 +1158,10 @@ impl Game<'_> {
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
}
let (pos, drops) = broken_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
@@ -1167,6 +1171,42 @@ impl Game<'_> {
self.entity_killed_event(at, false);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `LivingEntity.dropFromLootTable` for a stand that is not small, with
/// `mob_drops` on (`shouldDropLoot`): its type's table
/// `minecraft:entities/armor_stand` with seed 0 (`getLootTableSeed`), so
/// the table's random sequence or else the level's random, each stack
/// spawned at the stand's position. Its view is only the type and whether
/// it burns; no damage source, killer or player is known here.
fn armor_stand_death_loot(&mut self, at: [f64; 3], on_fire: bool) {
const TABLE: &str = "minecraft:entities/armor_stand";
let Some(loot) = horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
) else {
return;
};
if !loot.has_table(TABLE) {
return;
}
let params = horizon_world::loot::EntityLootParams {
this: horizon_world::loot::LootEntity {
on_fire,
baby: false,
..horizon_world::loot::LootEntity::new("minecraft:armor_stand")
},
..horizon_world::loot::EntityLootParams::default()
};
let stacks = match loot.random_sequence(TABLE) {
Some(id) => {
let random = self.rand_sequences.get(id, self.level.seed);
loot.roll_entity(TABLE, random, &params)
}
None => loot.roll_entity(TABLE, &mut self.world.random, &params),
};
for stack in stacks.iter().filter(|s| !s.is_empty()) {
self.spawn_item_own(super::creatures::from_loot(stack), at, 10);
}
}
/// `ArmorStand.hurtServer` for a command's damage of `damage_type` from the
/// causing entity `causing`, up to its sources that act and still return
/// false: a blast breaks the stand (`brokenByAnything`, `kill`), fire that
@@ -702,12 +702,26 @@ impl Game<'_> {
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
/// (42 frame blocks) its target stays until it dies or leaves the 8
/// blocks, else a random enemy in water or rain is chosen; the target
/// takes 4 magic damage.
/// takes 4 magic damage. The target is the block entity's (`Target`), so
/// a saved or `/data` one is kept or dropped as vanilla's reference is.
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
conduit.target = match conduit.target {
let key = (pos[0], pos[1], pos[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::Conduit(target)) => Some(*target),
_ => None,
};
// EntityReference.getLivingEntity: a reference no loaded mob answers
// is no target, and no new one is chosen in its place this time.
let current = match saved {
Some(Some(uuid)) => Some(self.mob_by_uuid(uuid)),
Some(None) => None,
None => conduit.target.map(Some),
};
conduit.target = match current {
_ if !hunting => None,
None => self.conduit_new_target(pos),
Some(id) => self
Some(None) => None,
Some(Some(id)) => self
.mobs
.iter()
.find(|m| m.id == id && !m.removed)
@@ -723,6 +737,16 @@ impl Game<'_> {
})
.map(|m| m.id),
};
if saved.is_some() {
let target = conduit.target.map(|id| self.mob_uuid(id));
if saved != Some(target)
&& let Some(BlockEntity::Conduit(saved)) = self.world.block_entities.get_mut(&key)
{
// `sendBlockUpdated`: the new target reaches the clients.
*saved = target;
self.mark_block_entity_updated(key);
}
}
let Some(m) = conduit
.target
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
@@ -323,6 +323,8 @@ impl Game<'_> {
) {
let key = (pos[0], pos[1], pos[2]);
self.pending.banner.remove(&pos);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.sculk_place_entity(state);
let Some(saved) = self.saved_block_entity(key) else {
return;
};
+1 -1
View File
@@ -744,7 +744,7 @@ impl Game<'_> {
inv_friction = 0.9;
}
Status::UnderWater => {
buoyancy = 0.01;
buoyancy = f64::from(0.01_f32);
inv_friction = 0.45;
}
Status::InAir => inv_friction = 0.9,
@@ -0,0 +1,114 @@
//! Run alone with private prepared resources.
use super::super::*;
use super::{MAX_CLIENT_FIREWORK_EXPLOSIONS, client_fireworks_ok};
use crate::mod_api::{FireworkExplosion, FireworkShape, Host};
use horizon_client::chunk::Chunk;
use horizon_client::particles::ParticleLevel;
fn star(colors: &[u32]) -> FireworkExplosion {
FireworkExplosion {
shape: FireworkShape::Star,
colors: colors.to_vec(),
fade: vec![0xFFFFFF],
trail: true,
twinkle: false,
}
}
#[test]
fn only_finite_shows_of_up_to_64_explosions_are_drawn() {
let at = [0.0, 70.0, 0.0];
assert!(client_fireworks_ok(at, [0.0; 3], &[]));
assert!(client_fireworks_ok(
at,
[0.0, 0.1, 0.0],
&[star(&[0xFF0000])]
));
let many = vec![star(&[0x00FF00]); MAX_CLIENT_FIREWORK_EXPLOSIONS];
assert!(client_fireworks_ok(at, [0.0; 3], &many));
let too_many = vec![star(&[0x00FF00]); MAX_CLIENT_FIREWORK_EXPLOSIONS + 1];
assert!(!client_fireworks_ok(at, [0.0; 3], &too_many));
for bad in [f64::NAN, f64::INFINITY] {
assert!(!client_fireworks_ok([bad, 70.0, 0.0], [0.0; 3], &[]));
assert!(!client_fireworks_ok(at, [0.0, bad, 0.0], &[]));
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world = World::new(game.res.facts().air, 1);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let at = [0.5, 70.0, 0.5];
let show = [star(&[0xFF5555, 0xFFFF55]), star(&[0x55FF55])];
// No client player yet: nothing.
game.player = None;
assert!(!(&mut game as &mut dyn Host).client_fireworks(at, [0.0; 3], &show));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
let entities = game.rockets.len();
assert!((&mut game as &mut dyn Host).client_fireworks(at, [0.0, 0.05, 0.0], &show));
assert!(
!(&mut game as &mut dyn Host).client_fireworks(at, [f64::NAN, 0.0, 0.0], &show),
"non-finite input"
);
// No rocket: only the picture and the sound.
assert_eq!(game.rockets.len(), entities);
game.particles.sounds.clear();
let tick = |game: &mut Game<'_>| {
let level = ParticleLevel {
world: &game.world,
facts: game.res.facts(),
collision: game.collision,
player: None,
};
game.particles.tick(&level);
};
tick(&mut game);
assert!(
game.particles
.sounds
.iter()
.any(|s| s.0 == "minecraft:entity.firework_rocket.blast"),
"the starter bangs on its first tick: {:?}",
game.particles.sounds
);
// Its first explosion's sparks reached the live queue.
tick(&mut game);
assert!(!game.particles.is_empty());
// A server's game draws nothing.
game.options.execution_role = execution_role::ExecutionRole::IntegratedServer;
assert!(!(&mut game as &mut dyn Host).client_fireworks(at, [0.0; 3], &show));
}
+11
View File
@@ -165,6 +165,17 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl CombatTracker {
/// The newest damage of this fight for `events`: when it landed (game
/// time), its damage type, the amount and the name of what dealt it.
pub(super) fn last_hit(&self) -> Option<(i64, String, Option<f32>, Option<String>)> {
let e = self.entries.last()?;
let by = e
.attacker
.as_ref()
.map(|(_, t)| t.plain())
.or_else(|| e.direct.as_ref().map(Text::plain));
Some((self.last_damage, e.damage_type.clone(), Some(e.damage), by))
}
/// The last blow taken: its damage type and the causing entity's id (0
/// for none), for `LivingDeathEvent`.
pub(super) fn last_blow(&self) -> Option<(&str, u32)> {
@@ -133,6 +133,9 @@ impl Game<'_> {
{
return Ok((guest.conn, menu.slot_count() - 36, *window));
}
if let Some(station) = host.play.get(&guest.conn).and_then(|p| p.station.as_ref()) {
return Ok((guest.conn, station.menu.slot_count() - 36, station.window));
}
Ok((guest.conn, 0, 0))
})
}
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff. Show more